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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
94
library/vendors/sx_android_jni/sx_android_jni.c
vendored
Normal file
94
library/vendors/sx_android_jni/sx_android_jni.c
vendored
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user