diff --git a/library/modules/gpu/gles3.sx b/library/modules/gpu/gles3.sx index d1f2e82..f92a63a 100644 --- a/library/modules/gpu/gles3.sx +++ b/library/modules/gpu/gles3.sx @@ -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) { diff --git a/library/modules/platform/android.sx b/library/modules/platform/android.sx index 290706b..2e126c5 100644 --- a/library/modules/platform/android.sx +++ b/library/modules/platform/android.sx @@ -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) { diff --git a/library/vendors/sx_android_jni/sx_android_jni.c b/library/vendors/sx_android_jni/sx_android_jni.c new file mode 100644 index 0000000..cc1edb9 --- /dev/null +++ b/library/vendors/sx_android_jni/sx_android_jni.c @@ -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 +#include +#include + +// 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 diff --git a/src/imports.zig b/src/imports.zig index c88176d..1c1761e 100644 --- a/src/imports.zig +++ b/src/imports.zig @@ -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,