diff --git a/examples/66-uikit-platform.sx b/examples/66-uikit-platform.sx index c9ca2c8..17137a0 100644 --- a/examples/66-uikit-platform.sx +++ b/examples/66-uikit-platform.sx @@ -1,7 +1,9 @@ // UIKitPlatform end-to-end smoke: boots the AppDelegate, installs an // SxGLView with a CAEAGLLayer + GLES3 context + CADisplayLink, polls // UITouch events into ui.Event, and on every vsync clears the screen -// to a color that advances on each tap. +// to a color that advances on each tap. Each tap also toggles the +// on-screen keyboard so safe_insets.bottom can be observed growing / +// shrinking under it. // // Build + run: // sx build --target ios-sim examples/66-uikit-platform.sx \ @@ -21,6 +23,7 @@ #import "modules/platform/uikit.sx"; g_color_index : s64 = 0; +g_keyboard_up : bool = false; tap_frame :: () { fc := g_uikit_plat.begin_frame(); @@ -30,7 +33,16 @@ tap_frame :: () { while i < events.len { ev := events.ptr[i]; if ev == { - case .mouse_down: { g_color_index += 1; } + case .mouse_down: { + g_color_index += 1; + if g_keyboard_up { + g_uikit_plat.hide_keyboard(); + g_keyboard_up = false; + } else { + g_uikit_plat.show_keyboard(); + g_keyboard_up = true; + } + } } i += 1; } diff --git a/library/modules/platform/uikit.sx b/library/modules/platform/uikit.sx index a71a2bf..d89a366 100644 --- a/library/modules/platform/uikit.sx +++ b/library/modules/platform/uikit.sx @@ -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);