platform: iOS safe-area insets + keyboard observer

UIKitPlatform now reads `[UIView safeAreaInsets]` (UIEdgeInsets = 32-byte
struct: top, left, bottom, right CGFloats) in begin_frame, and subscribes
to UIKeyboardWillChangeFrameNotification on NSNotificationCenter. The
chess game's build_ui pads its root by `g_safe_insets`, so the Dynamic
Island no longer overlaps the board on iPhone 17 Pro — all 8 ranks and
files are visible.

Struct returns >16 bytes (UIEdgeInsets, CGRect) go through the arm64
x8 indirect-result-pointer convention; expressing the return type on a
typed `objc_msgSend` fn-pointer cast generates the right call sequence.
Same pattern used to unwrap the keyboard's CGRect from NSValue
(UIKeyboardFrameEndUserInfoKey).

show_keyboard / hide_keyboard now drive a hidden UITextField subview as
the firstResponder source. resignFirstResponder dismisses; observer
fires with height=0 → safe_insets bottom collapses.

Deferred (next iteration): wrap the inset update in
[UIView animateWithDuration: animations:^{ ... }] to land in the same
CoreAnimation transaction as the keyboard. sx doesn't have block
syntax yet — we'd need a C shim that takes an fn-ptr and builds the
block. Today the inset snaps while the keyboard slides; the lag is
visible but the rest of the wiring is in place.

examples/66-uikit-platform.sx updated: each tap toggles the keyboard
+ advances the clear color (red→green→blue), so the observer can be
observed firing via the visible keyboard slide.
This commit is contained in:
agra
2026-05-17 17:07:33 +03:00
parent 4e27a7e6c9
commit 1af8e1ffd5
2 changed files with 148 additions and 13 deletions

View File

@@ -26,6 +26,26 @@ EAGL_API_GLES3 :: 3;
// 16 bytes and returns via the FP-register path on arm64.
CGPoint :: struct { x: f64; y: f64; }
// UIEdgeInsets = {top, left, bottom, right} CGFloats — 32 bytes; exceeds the
// 16-byte registers cutoff so it returns via the x8 indirect-result-pointer
// convention. sx generates the right call sequence when the function-pointer
// type declares it as a by-value struct return.
UIEdgeInsets :: struct {
top: f64;
left: f64;
bottom: f64;
right: f64;
}
// CGRect for unwrapping NSValue-wrapped rects (e.g. keyboard end frame). Same
// 32-byte indirect-return shape as UIEdgeInsets.
CGRect :: struct {
x: f64;
y: f64;
width: f64;
height: f64;
}
// GLenum constants for renderbuffer/framebuffer setup that aren't in opengl.sx's
// loader path (they live on the framework's symbol table directly).
GL_RENDERBUFFER :u32: 0x8D41;
@@ -46,6 +66,9 @@ UIKitPlatform :: struct {
framebuffer: u32 = 0;
gl_initialized: bool = false;
// Hidden UITextField; firstResponder ⇆ keyboard visibility.
text_field: *void = null;
viewport_w: f32 = 0.0;
viewport_h: f32 = 0.0;
pixel_w: s32 = 0;
@@ -110,6 +133,9 @@ impl Platform for UIKitPlatform {
}
begin_frame :: (self: *UIKitPlatform) -> FrameContext {
inline if OS == .ios {
uikit_refresh_safe_insets(self);
}
FrameContext.{
viewport_w = self.viewport_w,
viewport_h = self.viewport_h,
@@ -146,8 +172,23 @@ impl Platform for UIKitPlatform {
};
}
show_keyboard :: (self: *UIKitPlatform) { }
hide_keyboard :: (self: *UIKitPlatform) { }
show_keyboard :: (self: *UIKitPlatform) {
inline if OS == .ios {
if self.text_field == null { return; }
sel_become := sel_registerName("becomeFirstResponder".ptr);
msg_b : (*void, *void) -> u8 = xx objc_msgSend;
msg_b(self.text_field, sel_become);
}
}
hide_keyboard :: (self: *UIKitPlatform) {
inline if OS == .ios {
if self.text_field == null { return; }
sel_resign := sel_registerName("resignFirstResponder".ptr);
msg_b : (*void, *void) -> u8 = xx objc_msgSend;
msg_b(self.text_field, sel_resign);
}
}
stop :: (self: *UIKitPlatform) { }
@@ -176,6 +217,19 @@ uikit_extern_nsstring :: (name: [*]u8) -> *void {
// so non-iOS builds never reference the unresolved UIKit symbols below.
// ───────────────────────────────────────────────────────────────────────────
uikit_refresh_safe_insets :: (plat: *UIKitPlatform) {
inline if OS != .ios { return; }
if plat.gl_view == null { return; }
sel_safe := sel_registerName("safeAreaInsets".ptr);
msg_insets : (*void, *void) -> UIEdgeInsets = xx objc_msgSend;
i := msg_insets(plat.gl_view, sel_safe);
plat.safe_top = xx i.top;
plat.safe_left = xx i.left;
plat.safe_bottom = xx i.bottom;
plat.safe_right = xx i.right;
}
uikit_chdir_to_bundle :: () {
inline if OS != .ios { return; }
NSBundle := objc_getClass("NSBundle".ptr);
@@ -202,6 +256,9 @@ uikit_register_classes :: () {
class_addMethod(SxAppDelegate,
sel_registerName("setWindow:".ptr),
xx uikit_window_setter, "v@:@".ptr);
class_addMethod(SxAppDelegate,
sel_registerName("sxKeyboardWillChangeFrame:".ptr),
xx uikit_keyboard_will_change_frame, "v@:@".ptr);
objc_registerClassPair(SxAppDelegate);
@@ -209,6 +266,60 @@ uikit_register_classes :: () {
}
}
// NSNotification callback. The notification's userInfo dict has the
// keyboard's end-frame and animation curve/duration.
// UIKeyboardFrameEndUserInfoKey → NSValue wrapping CGRect (screen coords)
// UIKeyboardAnimationDurationUserInfoKey → NSNumber wrapping double
// Keyboard height = how much of the screen the keyboard covers from the bottom.
// If the keyboard's end Y >= screen.height, the keyboard is offscreen (hiding).
uikit_keyboard_will_change_frame :: (self: *void, _cmd: *void, notification: *void) callconv(.c) {
if g_uikit_plat == null { return; }
plat := g_uikit_plat;
sel_user_info := sel_registerName("userInfo".ptr);
sel_obj_for_key := sel_registerName("objectForKey:".ptr);
sel_cg_rect_value := sel_registerName("CGRectValue".ptr);
sel_double_value := sel_registerName("doubleValue".ptr);
sel_screen := sel_registerName("screen".ptr);
sel_bounds := sel_registerName("bounds".ptr);
msg_o : (*void, *void) -> *void = xx objc_msgSend;
msg_oo : (*void, *void, *void) -> *void = xx objc_msgSend;
msg_rect : (*void, *void) -> CGRect = xx objc_msgSend;
msg_d : (*void, *void) -> f64 = xx objc_msgSend;
user_info := msg_o(notification, sel_user_info);
if user_info == null { return; }
end_value := msg_oo(user_info, sel_obj_for_key,
ns_string("UIKeyboardFrameEndUserInfoKey".ptr));
if end_value == null { return; }
end_rect := msg_rect(end_value, sel_cg_rect_value);
// UIKeyboardAnimationDurationUserInfoKey is also in userInfo; reading it
// and running our inset update inside a `[UIView animateWithDuration:...]`
// block would put us in the same CoreAnimation transaction as the keyboard
// (zero-lag sync). Blocks aren't yet expressible from sx, so we update the
// inset synchronously — content snaps while the keyboard slides.
// Screen height in points. The window lives on the connected scene's screen.
if plat.window == null { return; }
win_screen := msg_o(plat.window, sel_screen);
screen_bounds := msg_rect(win_screen, sel_bounds);
// Keyboard height = how much of the screen the keyboard covers from the
// bottom. When the keyboard is hiding, its end-frame.y == screen.height,
// so the height comes out 0 and visible becomes false.
kb_top : f64 = end_rect.y;
sh : f64 = screen_bounds.height;
h := sh - kb_top;
if h < 0.0 { h = 0.0; }
if h > sh { h = sh; }
plat.keyboard_height = xx h;
plat.keyboard_visible = h > 0.5;
}
uikit_create_gl_context :: (plat: *UIKitPlatform) {
inline if OS != .ios { return; }
@@ -251,12 +362,12 @@ uikit_window_setter :: (self: *void, _cmd: *void, w: *void) callconv(.c) {
uikit_did_finish_launching :: (self: *void, _cmd: *void, app: *void, opts: *void) -> u8 callconv(.c) {
result : u8 = 1;
inline if OS == .ios {
result = uikit_did_finish_launching_ios(app);
result = uikit_did_finish_launching_ios(self, app);
}
result;
}
uikit_did_finish_launching_ios :: (app: *void) -> u8 {
uikit_did_finish_launching_ios :: (delegate: *void, app: *void) -> u8 {
if g_uikit_plat == null {
NSLog(ns_string("[sx] no platform\n".ptr));
return 0;
@@ -381,13 +492,25 @@ uikit_did_finish_launching_ios :: (app: *void) -> u8 {
// a layout pass; layoutSubviews calls uikit_setup_renderbuffer.
msg_v(plat.window, sel_make_key_visible);
// Safe insets stay zero for now — struct-return ABI for `safeAreaInsets`
// (UIEdgeInsets = 4 CGFloats) isn't expressible without dedicated
// msgSend_stret plumbing. Revisit when keyboard sync needs it.
plat.safe_top = 0.0;
plat.safe_left = 0.0;
plat.safe_bottom = 0.0;
plat.safe_right = 0.0;
// Hidden UITextField as the firstResponder source for show_keyboard /
// hide_keyboard. Lives as a subview of the GL view so it's in the
// responder chain but is sized 0×0 so it can't be tapped.
UITextField := objc_getClass("UITextField".ptr);
sel_add_subview := sel_registerName("addSubview:".ptr);
tf_raw := msg_o(UITextField, sel_alloc);
plat.text_field = msg_o(tf_raw, sel_init);
msg_oo(plat.gl_view, sel_add_subview, plat.text_field);
// Subscribe SxAppDelegate to UIKeyboardWillChangeFrameNotification. We
// use the AppDelegate as the observer since it already exists; the IMP
// updates g_uikit_plat directly so we don't need ivar storage.
NSNotificationCenter := objc_getClass("NSNotificationCenter".ptr);
sel_default_center := sel_registerName("defaultCenter".ptr);
sel_add_observer := sel_registerName("addObserver:selector:name:object:".ptr);
msg_o5 : (*void, *void, *void, *void, *void, *void) -> void = xx objc_msgSend;
center := msg_o(NSNotificationCenter, sel_default_center);
msg_o5(center, sel_add_observer, delegate, sel_registerName("sxKeyboardWillChangeFrame:".ptr),
ns_string("UIKeyboardWillChangeFrameNotification".ptr), xx 0);
// CADisplayLink: vsync-driven tick into our SxGLView.
plat.display_link = msg_oso(CADisplayLink, sel_link_with_target, plat.gl_view, sel_tick);