android: dpi_scale, scissor, JNI safe-insets, touch input

Four Android UX wins landing together; all verified end-to-end on a
Pixel 7 Pro (board fills width, info-panel text renders, status bar
inset honored, tap-to-select + tap-to-move plays 1. e4).

- AndroidPlatform.init reads density via AConfiguration_getDensity
  (app->config at offset 32) and sets dpi_scale = density / 160. The
  hardcoded 1.0 had been making every logical unit equal one physical
  pixel; ChessBoardView's 520-default size_that_fits fallback then
  rendered at ~half the framebuffer width on the device, and glyphs
  rasterized at literal 11-13 physical pixels were essentially invisible
  on a 2340-tall display.
- gles3.sx set_scissor un-stubbed; with dpi_scale right the renderer
  feeds in valid pixel bounds and the Y-flip math lands inside the
  framebuffer.
- New library/vendors/sx_android_jni/sx_android_jni.c walks
  activity -> window -> decorView -> rootWindowInsets via JNI and
  publishes the system-bar insets. safe_insets() lazy-queries the
  first call after EGL is up (decor view isn't attached at bootstrap).
- sx_android_install_input_handler sets app->onInputEvent; sx-side
  sx_android_input_event translates AMotionEvent DOWN/MOVE/UP/CANCEL
  into existing mouse_down/mouse_moved/mouse_up Events so the chess
  board's tap-to-select + ScrollView drag path Just Works. Coordinates
  divided by dpi_scale so layout-side hit tests match. poll_events
  drains its slice after returning (mirrors the SDL pattern).
- src/imports.zig now routes #import c { #source / #include } paths
  through the same chain as #import (importing dir -> CWD -> stdlib
  search paths). Lets library-owned C helpers like the JNI bridge
  live in sx/library/vendors/ without forcing consumers to vendor a
  copy. Existing CWD-relative consumer layouts (chess's vendors/...)
  still resolve first, so no regression.

86/86 regression tests pass.
This commit is contained in:
agra
2026-05-19 11:09:41 +03:00
parent b5bf789b7b
commit 4849cfb904
4 changed files with 236 additions and 7 deletions

View File

@@ -303,12 +303,8 @@ impl GPU for Gles3Gpu {
set_scissor :: (self: *Gles3Gpu, x: s32, y: s32, w: s32, h: s32) {
inline if OS != .android { return; }
// TODO: re-enable once we figure out why the renderer passes
// a 0×0 clip rect on Android (chess's ScrollView path). The
// bounds the renderer feeds us land outside the framebuffer
// and clip everything off-screen.
// glEnable(GL_SCISSOR_TEST);
// glScissor(x, self.pixel_h - (y + h), w, h);
glEnable(GL_SCISSOR_TEST);
glScissor(x, self.pixel_h - (y + h), w, h);
}
disable_scissor :: (self: *Gles3Gpu) {

View File

@@ -25,6 +25,13 @@
#import "modules/platform/types.sx";
#import "modules/platform/api.sx";
// JNI bridge for system-bar inset queries. The .c lives in the library's
// vendor area; the compiler resolves the `#source` path through the
// stdlib search list so consumers don't need to vendor a copy.
#import c {
#source "vendors/sx_android_jni/sx_android_jni.c";
};
// ── Foreign declarations ────────────────────────────────────────────────
__android_log_print :: (prio: s32, tag: *u8, fmt: *u8) -> s32 #foreign;
@@ -35,6 +42,19 @@ ALooper_pollOnce :: (ms: s32, outFd: *s32, outEvents: *s32, outData: **void) ->
ANativeWindow_getWidth :: (window: *void) -> s32 #foreign;
ANativeWindow_getHeight :: (window: *void) -> s32 #foreign;
AConfiguration_getDensity :: (config: *void) -> s32 #foreign;
// Input event APIs (libandroid). Touch motion arrives via the queued
// AInputEvent stream that native_app_glue pumps through `onInputEvent`.
AInputEvent_getType :: (event: *void) -> s32 #foreign;
AMotionEvent_getAction :: (event: *void) -> s32 #foreign;
AMotionEvent_getX :: (event: *void, pointer_index: u64) -> f32 #foreign;
AMotionEvent_getY :: (event: *void, pointer_index: u64) -> f32 #foreign;
// JNI/glue bridges from vendors/sx_android_jni/sx_android_jni.c.
sx_android_query_safe_insets :: (activity: *void, top: *s32, left: *s32, bottom: *s32, right: *s32) -> void #foreign;
sx_android_install_input_handler :: (app: *void, handler: (*void, *void) -> s32) -> void #foreign;
// EGL — display/surface/context/config are opaque to us.
eglGetDisplay :: (display_id: *void) -> *void #foreign;
eglInitialize :: (display: *void, major: *s32, minor: *s32) -> u32 #foreign;
@@ -63,6 +83,14 @@ EGL_RED_SIZE :s32: 0x3024;
EGL_DEPTH_SIZE :s32: 0x3025;
EGL_CONTEXT_CLIENT_VERSION :s32: 0x3098;
// AInputEvent / AMotionEvent constants used by the input handler.
AINPUT_EVENT_TYPE_MOTION :s32: 2;
AMOTION_EVENT_ACTION_MASK :s32: 0xff;
AMOTION_EVENT_ACTION_DOWN :s32: 0;
AMOTION_EVENT_ACTION_UP :s32: 1;
AMOTION_EVENT_ACTION_MOVE :s32: 2;
AMOTION_EVENT_ACTION_CANCEL :s32: 3;
// CLOCK_MONOTONIC = 1 on linux/bionic. Used for delta_time + target_present_time.
CLOCK_MONOTONIC :s32: 1;
@@ -71,6 +99,7 @@ CLOCK_MONOTONIC :s32: 1;
// the layout has changed (see examples/99-android-egl-clear.sx's notes).
APP_OFF_window :s64: 72;
APP_OFF_destroyRequested :s64: 100;
APP_OFF_config :s64: 32; // AConfiguration* — follows activity (24).
SRC_OFF_process :s64: 16; // id(4) + pad(4) + app*(8) = process fn-ptr
// timespec on linux/aarch64: tv_sec (s64) + tv_nsec (s64).
@@ -98,6 +127,8 @@ ACTIVITY_OFF_internalData :s64: 32;
// which routes `read_file_bytes` through AAssetManager_open on Android.
g_android_asset_manager : *void = null;
g_android_internal_path : *u8 = null;
g_android_config : *void = null;
g_android_activity : *void = null;
// ── Bootstrap (called by user's `android_main`) ────────────────────────
// Stashes the NDK app pointer the OS handed to `android_main(app)` so
@@ -116,6 +147,7 @@ sx_android_bootstrap :: (app: *void) {
base : s64 = xx app;
activity_pp : **void = xx (base + APP_OFF_activity);
activity_ptr := activity_pp.*;
g_android_activity = activity_ptr;
if activity_ptr != null {
act_base : s64 = xx activity_ptr;
mgr_pp : **void = xx (act_base + ACTIVITY_OFF_assetManager);
@@ -123,6 +155,8 @@ sx_android_bootstrap :: (app: *void) {
path_pp : **u8 = xx (act_base + ACTIVITY_OFF_internalData);
g_android_internal_path = path_pp.*;
}
cfg_pp : **void = xx (base + APP_OFF_config);
g_android_config = cfg_pp.*;
}
}
@@ -161,7 +195,7 @@ AndroidPlatform :: struct {
// events when the user rotates the device.
pixel_w: s32 = 0;
pixel_h: s32 = 0;
dpi_scale: f32 = 1.0; // TODO: query AConfiguration_getDensity
dpi_scale: f32 = 1.0;
delta_time: f32 = 0.016;
last_frame_time: f64 = 0.0;
@@ -170,12 +204,15 @@ AndroidPlatform :: struct {
frame_closure: ?Closure() = null;
events: List(Event) = .{};
last_touch: Point = .{};
touch_active: bool = false;
stop_requested: bool = false;
safe_top: f32 = 0.0;
safe_left: f32 = 0.0;
safe_bottom: f32 = 0.0;
safe_right: f32 = 0.0;
safe_insets_queried: bool = false;
keyboard_visible: bool = false;
keyboard_height: f32 = 0.0;
@@ -187,18 +224,33 @@ impl Platform for AndroidPlatform {
init :: (self: *AndroidPlatform, title: [:0]u8, w: s32, h: s32) -> bool {
inline if OS != .android { return false; }
g_android_plat = self;
if g_android_config != null {
density := AConfiguration_getDensity(g_android_config);
if density > 0 {
self.dpi_scale = xx density / 160.0;
}
}
true;
}
run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) {
inline if OS == .android {
self.frame_closure = frame_fn;
if g_android_app != null {
sx_android_install_input_handler(g_android_app, sx_android_input_event);
}
android_run_loop(self);
}
}
poll_events :: (self: *AndroidPlatform) -> []Event {
out : []Event = .{ ptr = xx self.events.items, len = self.events.len };
// Drain after exposing the slice — the user iterates synchronously
// before returning to the run loop, and new touches won't arrive
// until the next ALooper_pollOnce drains the input queue. Setting
// len=0 keeps the backing buffer alive so the next append reuses
// the same allocation.
self.events.len = 0;
out;
}
@@ -229,6 +281,21 @@ impl Platform for AndroidPlatform {
}
safe_insets :: (self: *AndroidPlatform) -> EdgeInsets {
inline if OS == .android {
// Query once after EGL is up — getRootWindowInsets() returns
// null until the window has been attached, so calling at
// bootstrap is too early.
if !self.safe_insets_queried and g_android_activity != null and self.egl_surface != null {
t : s32 = 0; l : s32 = 0; b : s32 = 0; r : s32 = 0;
sx_android_query_safe_insets(g_android_activity, @t, @l, @b, @r);
inv : f32 = if self.dpi_scale > 0.0 then 1.0 / self.dpi_scale else 1.0;
self.safe_top = xx t * inv;
self.safe_left = xx l * inv;
self.safe_bottom = xx b * inv;
self.safe_right = xx r * inv;
self.safe_insets_queried = true;
}
}
EdgeInsets.{
top = self.safe_top,
left = self.safe_left,
@@ -275,6 +342,58 @@ impl Platform for AndroidPlatform {
}
}
// ── Internal: input bridge ─────────────────────────────────────────────
//
// native_app_glue's process_input loop calls this once per AInputEvent
// pulled off the input queue. Single touch only for now — point-down /
// move / point-up — translated to sx mouse_down / mouse_moved /
// mouse_up so the existing handle_event chain (drag, scroll, square
// selection) Just Works. Coordinates from AMotionEvent_get{X,Y} are
// physical pixels; divide by dpi_scale before publishing so the
// layout-side hit-testing matches its own logical-coord frames.
//
// Returns 1 (consumed) for motion events, 0 for everything else so
// native_app_glue still routes key events through the default handler.
sx_android_input_event :: (app: *void, event: *void) -> s32 {
inline if OS != .android { return 0; }
if event == null { return 0; }
if g_android_plat == null { return 0; }
if AInputEvent_getType(event) != AINPUT_EVENT_TYPE_MOTION {
return 0;
}
plat := g_android_plat;
raw_action := AMotionEvent_getAction(event);
action := raw_action & AMOTION_EVENT_ACTION_MASK;
px := AMotionEvent_getX(event, 0);
py := AMotionEvent_getY(event, 0);
inv : f32 = if plat.dpi_scale > 0.0 then 1.0 / plat.dpi_scale else 1.0;
pos : Point = .{ x = px * inv, y = py * inv };
if action == AMOTION_EVENT_ACTION_DOWN {
plat.events.append(.mouse_down(.{ position = pos, button = .left }));
plat.last_touch = pos;
plat.touch_active = true;
} else if action == AMOTION_EVENT_ACTION_MOVE {
delta : Point = .{
x = pos.x - plat.last_touch.x,
y = pos.y - plat.last_touch.y,
};
plat.events.append(.mouse_moved(.{ position = pos, delta = delta }));
plat.last_touch = pos;
} else if action == AMOTION_EVENT_ACTION_UP {
plat.events.append(.mouse_up(.{ position = pos, button = .left }));
plat.touch_active = false;
} else if action == AMOTION_EVENT_ACTION_CANCEL {
plat.events.append(.mouse_up(.{ position = pos, button = .left }));
plat.touch_active = false;
}
1;
}
// ── Internal: event loop + EGL bringup ─────────────────────────────────
android_run_loop :: (self: *AndroidPlatform) {

View File

@@ -0,0 +1,94 @@
// JNI helpers used by modules/platform/android.sx. Kept in the library
// so consumers don't need to vendor an identically-named copy. The sx
// compiler resolves `#source "vendors/..."` against the stdlib search
// paths in addition to the consumer's project root.
#ifdef __ANDROID__
#include <android/native_activity.h>
#include <android/input.h>
#include <jni.h>
// Mirror of struct android_app (NDK 29 / arm64) up to the fields we touch.
// Avoids depending on the glue header in the sx library compile path.
struct sx_android_app_min {
void* userData;
void (*onAppCmd)(struct sx_android_app_min* app, int cmd);
int (*onInputEvent)(struct sx_android_app_min* app, AInputEvent* event);
// ...rest of struct ignored; we only assign onInputEvent.
};
// Install an sx-side handler as `app->onInputEvent`. native_app_glue's
// process_input loop calls this for every AInputEvent it pulls off the
// input queue. Returning 1 marks the event as consumed.
void sx_android_install_input_handler(void* app,
int (*handler)(void* app, void* event)) {
if (app == 0) return;
struct sx_android_app_min* a = (struct sx_android_app_min*)app;
a->onInputEvent = (int (*)(struct sx_android_app_min*, AInputEvent*))handler;
}
// Query system-bar insets (status bar, nav bar) via JNI:
// activity → getWindow() → getDecorView()
// → getRootWindowInsets()
// → getSystemWindowInset[Top|Left|Bottom|Right]()
// Out params receive physical-pixel insets; the sx caller divides by
// dpi_scale to convert to logical units. Falls back to zeros if the
// view isn't attached yet (e.g. called before the first frame).
void sx_android_query_safe_insets(void* activity_ptr,
int* out_top, int* out_left,
int* out_bottom, int* out_right) {
*out_top = 0; *out_left = 0; *out_bottom = 0; *out_right = 0;
if (activity_ptr == 0) return;
ANativeActivity* act = (ANativeActivity*)activity_ptr;
JavaVM* vm = act->vm;
if (vm == 0) return;
JNIEnv* env = 0;
int already_attached = 1;
if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
already_attached = 0;
if ((*vm)->AttachCurrentThread(vm, &env, 0) != 0) return;
}
jobject activity_obj = act->clazz;
jclass activity_cls = (*env)->GetObjectClass(env, activity_obj);
jmethodID m_getWindow = (*env)->GetMethodID(env, activity_cls,
"getWindow", "()Landroid/view/Window;");
if (m_getWindow == 0) goto done;
jobject window = (*env)->CallObjectMethod(env, activity_obj, m_getWindow);
if (window == 0) goto done;
jclass window_cls = (*env)->GetObjectClass(env, window);
jmethodID m_getDecorView = (*env)->GetMethodID(env, window_cls,
"getDecorView", "()Landroid/view/View;");
if (m_getDecorView == 0) goto done;
jobject decor = (*env)->CallObjectMethod(env, window, m_getDecorView);
if (decor == 0) goto done;
jclass view_cls = (*env)->GetObjectClass(env, decor);
jmethodID m_getRootInsets = (*env)->GetMethodID(env, view_cls,
"getRootWindowInsets", "()Landroid/view/WindowInsets;");
if (m_getRootInsets == 0) goto done;
jobject insets = (*env)->CallObjectMethod(env, decor, m_getRootInsets);
if (insets == 0) goto done;
jclass insets_cls = (*env)->GetObjectClass(env, insets);
jmethodID m_top = (*env)->GetMethodID(env, insets_cls,
"getSystemWindowInsetTop", "()I");
jmethodID m_left = (*env)->GetMethodID(env, insets_cls,
"getSystemWindowInsetLeft", "()I");
jmethodID m_bottom = (*env)->GetMethodID(env, insets_cls,
"getSystemWindowInsetBottom", "()I");
jmethodID m_right = (*env)->GetMethodID(env, insets_cls,
"getSystemWindowInsetRight", "()I");
if (m_top) *out_top = (*env)->CallIntMethod(env, insets, m_top);
if (m_left) *out_left = (*env)->CallIntMethod(env, insets, m_left);
if (m_bottom) *out_bottom = (*env)->CallIntMethod(env, insets, m_bottom);
if (m_right) *out_right = (*env)->CallIntMethod(env, insets, m_right);
done:
if (!already_attached) (*vm)->DetachCurrentThread(vm);
}
#endif

View File

@@ -202,6 +202,26 @@ pub fn resolveImports(
if (decl.data == .c_import_decl) {
const ci = decl.data.c_import_decl;
// Resolve `#source` / `#include` paths through the same chain
// as `#import`: importing-file's directory → CWD → stdlib
// search paths. This lets sx-library modules ship their own
// C helpers (e.g. the Android JNI insets bridge) without
// forcing every consumer to vendor an identically-named copy.
if (ci.sources.len > 0) {
var resolved = try allocator.alloc([]const u8, ci.sources.len);
for (ci.sources, 0..) |raw_src, idx| {
resolved[idx] = try resolveImportPath(allocator, io, base_dir, raw_src, null, stdlib_paths);
}
decl.data.c_import_decl.sources = resolved;
}
if (ci.includes.len > 0) {
var resolved = try allocator.alloc([]const u8, ci.includes.len);
for (ci.includes, 0..) |raw_inc, idx| {
resolved[idx] = try resolveImportPath(allocator, io, base_dir, raw_inc, null, stdlib_paths);
}
decl.data.c_import_decl.includes = resolved;
}
// Parse headers to get synthetic function declarations
const result = c_import.processCImport(
allocator,