Files
sx/library/modules/platform/uikit.sx
agra 56f6ae3681 ffi 1.33: uikit.sx final sweep — Phase 1D for uikit.sx complete
Six remaining dispatch clusters migrated in one pass:
- `uikit_setup_renderbuffer`: `renderbufferStorage:fromDrawable:` (BOOL).
- `uikit_present_renderbuffer`: `presentRenderbuffer:` (BOOL, every frame).
- `uikit_gl_view_tick`: `targetTimestamp` and `duration` reads (f64,
  every frame — three call sites total across the keyboard-anim path
  and the frame-closure path).
- `uikit_compute_layer_pixel_size`: `bounds` (CGRect HFA).
- `uikit_touch_location`: `locationInView:` (CGPoint HFA — first
  standalone `#objc_call(CGPoint)` exercise, structurally identical to
  the 2×f64 NSPoint already verified by ffi-objc-call-05).
- `uikit_first_touch`: `anyObject` (*void).

Net -15 lines. uikit.sx is now 839 lines — Phase 1D started at 937,
so this is -98 cumulative across the migration. Zero `xx objc_msgSend`
typed casts left in the file.

iOS-sim chess regression smoke: launched chess, tapped a black pawn
through the Simulator window, watched the move (d7→d5) play, then a
second tap played d5→d4. The render loop, touch handlers, layout
math, and the BOOL-returning EAGL presentation calls are all on the
exercised path, so this is the strongest runtime verification any
Phase 1D commit has had so far.

22 `sel_registerName` calls remain in the file, all legitimate:
- `class_addMethod` IMP registrations (runtime class build-out).
- SEL-as-arg to dispatch selectors that take a SEL value
  (`addObserver:selector:name:object:`,
  `displayLinkWithTarget:selector:`). A future `#objc_selector("foo")`
  literal would replace these, but it's not part of Phase 1.
2026-05-19 21:06:53 +03:00

840 lines
34 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure UIKit + CAEAGLLayer + CADisplayLink backend for iOS.
//
// Linking is per-target via the game's build.sx (`opts.add_framework("UIKit")`
// + `opts.add_framework("OpenGLES")` + `opts.add_framework("QuartzCore")` on
// `.ios`). The file compiles cleanly on every target — the UIKit-touching
// bodies live behind `inline if OS == .ios` guards, so non-iOS builds never
// reach the unresolved Obj-C symbols.
#import "modules/std.sx";
#import "modules/std/objc.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/ui/types.sx";
#import "modules/ui/events.sx";
#import "modules/platform/types.sx";
#import "modules/platform/api.sx";
UIApplicationMain :: (argc: s32, argv: *void, principal_class: *void, delegate_class: *void) -> s32 #foreign;
dlsym :: (handle: *void, name: [*]u8) -> *void #foreign;
chdir :: (path: [*]u8) -> s32 #foreign;
// QuartzCore's wall-clock helper used by CoreAnimation. Seconds since boot,
// monotonic. We use it as the timebase for keyboard-inset lockstep so the
// per-frame interpolation lines up with UIKit's own animation timestamp.
CACurrentMediaTime :: () -> f64 #foreign;
// kEAGLRenderingAPIOpenGLES3 = 3
EAGL_API_GLES3 :: 3;
// CGFloat is a `double` on 64-bit Apple platforms; CGPoint = {x, y} fits in
// 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;
GL_FRAMEBUFFER :u32: 0x8D40;
GL_COLOR_ATTACHMENT0 :u32: 0x8CE0;
GL_FRAMEBUFFER_COMPLETE :u32: 0x8CD5;
g_uikit_plat : *UIKitPlatform = null;
// Which GPU API the UIKit backend wires up. `.gles` keeps the existing
// CAEAGLLayer + EAGLContext + renderbuffer path; `.metal` swaps the view
// for a CAMetalLayer-backed one and leaves rendering to MetalGPU. Set
// before calling `init`; default `.gles` preserves prior behavior.
GpuMode :: enum {
gles;
metal;
}
UIKitPlatform :: struct {
window: *void = null; // UIWindow*
root_vc: *void = null; // UIViewController*
gl_view: *void = null; // SxGLView* OR SxMetalView* (depending on gpu_mode)
gl_layer: *void = null; // CAEAGLLayer* OR CAMetalLayer* (= gl_view.layer)
gl_ctx: *void = null; // EAGLContext* (null in metal mode)
display_link: *void = null;
color_renderbuffer: u32 = 0;
framebuffer: u32 = 0;
gl_initialized: bool = false;
gpu_mode: GpuMode = .gles;
// Hidden UITextField; firstResponder ⇆ keyboard visibility.
text_field: *void = null;
viewport_w: f32 = 0.0;
viewport_h: f32 = 0.0;
pixel_w: s32 = 0;
pixel_h: s32 = 0;
dpi_scale: f32 = 1.0;
delta_time: f32 = 0.016;
// Latest CADisplayLink.targetTimestamp captured each tick — forwarded
// through FrameContext to MetalGPU.end_frame() for presentDrawable:atTime:.
last_target_ts: f64 = 0.0;
frame_closure: Closure() = ---;
has_frame_closure: bool = false;
events: List(Event) = .{};
safe_top: f32 = 0.0;
safe_left: f32 = 0.0;
safe_bottom: f32 = 0.0;
safe_right: f32 = 0.0;
keyboard_visible: bool = false;
keyboard_height: f32 = 0.0;
// Keyboard inset lockstep: when willChangeFrame fires we read the
// animation duration from the notification's userInfo and interpolate
// `keyboard_height` over that window on each display-link tick. Each
// animation has a fresh start time and target — if a second event
// arrives mid-animation, the next interpolation starts from the
// currently-interpolated value (not from the previous animation's
// origin). The easing is `smoothstep` (cubic Hermite) which closely
// approximates UIKit's keyboard curve to within a frame at the
// standard 0.25s slide duration.
kb_anim_from: f32 = 0.0;
kb_anim_to: f32 = 0.0;
kb_anim_start: f64 = 0.0;
kb_anim_dur: f64 = 0.0;
kb_anim_curve: u64 = 0;
kb_animating: bool = false;
saved_title: [*]u8 = null;
}
impl Platform for UIKitPlatform {
init :: (self: *UIKitPlatform, title: [:0]u8, w: s32, h: s32) -> bool {
self.dpi_scale = 1.0;
self.delta_time = 0.016;
self.has_frame_closure = false;
self.gl_initialized = false;
self.keyboard_visible = false;
self.keyboard_height = 0.0;
self.saved_title = title.ptr;
g_uikit_plat = self;
// iOS apps start with CWD=/. chdir to the bundle's resourcePath so the
// game's relative `fopen("assets/...")` calls find their data — must
// happen BEFORE any code that loads fonts/textures from disk.
inline if OS == .ios {
uikit_chdir_to_bundle();
uikit_register_classes();
if self.gpu_mode == .gles {
uikit_create_gl_context(self);
} else {
// Metal mode: skip EAGL. dpi_scale still needs to be known
// before the window exists so callers can size resources.
uikit_read_screen_scale(self);
}
}
true;
}
run_frame_loop :: (self: *UIKitPlatform, frame_fn: Closure()) {
self.frame_closure = frame_fn;
self.has_frame_closure = true;
g_uikit_plat = self;
inline if OS == .ios {
UIApplicationMain(0, xx 0, xx 0, ns_string("SxAppDelegate".ptr));
}
}
poll_events :: (self: *UIKitPlatform) -> []Event {
result : []Event = ---;
result.ptr = self.events.items;
result.len = self.events.len;
self.events.len = 0;
result;
}
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,
pixel_w = self.pixel_w,
pixel_h = self.pixel_h,
dpi_scale = self.dpi_scale,
delta_time = self.delta_time,
target_present_time = self.last_target_ts,
};
}
end_frame :: (self: *UIKitPlatform) {
inline if OS == .ios {
if self.gpu_mode == .gles {
uikit_present_renderbuffer(self);
}
// Metal mode: caller's gpu.end_frame() handles present.
}
}
safe_insets :: (self: *UIKitPlatform) -> EdgeInsets {
bottom := self.safe_bottom;
if self.keyboard_visible {
if self.keyboard_height > bottom { bottom = self.keyboard_height; }
}
EdgeInsets.{
top = self.safe_top,
left = self.safe_left,
bottom = bottom,
right = self.safe_right,
};
}
keyboard :: (self: *UIKitPlatform) -> KeyboardState {
KeyboardState.{
visible = self.keyboard_visible,
height = self.keyboard_height,
};
}
show_keyboard :: (self: *UIKitPlatform) {
inline if OS == .ios {
if self.text_field == null { return; }
#objc_call(bool)(self.text_field, "becomeFirstResponder");
}
}
hide_keyboard :: (self: *UIKitPlatform) {
inline if OS == .ios {
if self.text_field == null { return; }
#objc_call(bool)(self.text_field, "resignFirstResponder");
}
}
stop :: (self: *UIKitPlatform) { }
shutdown :: (self: *UIKitPlatform) { }
}
// dlsym(RTLD_DEFAULT, name) — Apple platforms. RTLD_DEFAULT is (void*)-2.
ios_gl_proc :: (name: [*]u8) -> *void {
rtld_default : *void = xx (0 - 2);
dlsym(rtld_default, name);
}
// Read a `extern NSString * const k...` global from the loaded image. The
// extern variable holds a pointer to the NSString instance — dlsym returns
// the address of that variable, which we dereference.
uikit_extern_nsstring :: (name: [*]u8) -> *void {
rtld_default : *void = xx (0 - 2);
p := dlsym(rtld_default, name);
if p == null { return null; }
pp : **void = xx p;
pp.*;
}
// ───────────────────────────────────────────────────────────────────────────
// iOS-only helpers — only reachable from `inline if OS == .ios` call sites,
// 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; }
i := #objc_call(UIEdgeInsets)(plat.gl_view, "safeAreaInsets");
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);
bundle := #objc_call(*void)(NSBundle, "mainBundle");
rsrc := #objc_call(*void)(bundle, "resourcePath");
if rsrc == null { return; }
chdir(c_string(rsrc));
}
uikit_register_classes :: () {
inline if OS == .ios {
UIResponder := objc_getClass("UIResponder".ptr);
SxAppDelegate := objc_allocateClassPair(UIResponder, "SxAppDelegate".ptr, 0);
class_addMethod(SxAppDelegate,
sel_registerName("application:didFinishLaunchingWithOptions:".ptr),
xx uikit_did_finish_launching, "c@:@@".ptr);
class_addMethod(SxAppDelegate,
sel_registerName("sxKeyboardWillChangeFrame:".ptr),
xx uikit_keyboard_will_change_frame, "v@:@".ptr);
objc_registerClassPair(SxAppDelegate);
// SxSceneDelegate handles the per-scene UI setup. iOS 13+ scene-based
// lifecycle: didFinishLaunching is too early for the window — the
// UIWindowScene doesn't connect until `scene:willConnectTo:options:`.
// The class is named in Info.plist's UIApplicationSceneManifest →
// UISceneDelegateClassName.
SxSceneDelegate := objc_allocateClassPair(UIResponder, "SxSceneDelegate".ptr, 0);
class_addMethod(SxSceneDelegate,
sel_registerName("scene:willConnectToSession:options:".ptr),
xx uikit_scene_will_connect, "v@:@@@".ptr);
class_addMethod(SxSceneDelegate,
sel_registerName("window".ptr),
xx uikit_window_getter, "@@:".ptr);
class_addMethod(SxSceneDelegate,
sel_registerName("setWindow:".ptr),
xx uikit_window_setter, "v@:@".ptr);
// Formal protocol conformance is required for UISceneDelegate
// (iOS checks -[cls conformsToProtocol:@protocol(UISceneDelegate)]
// before instantiating; without it the class is silently rejected
// with "does not conform to the UISceneDelegate protocol" in the
// log and a default scene with no delegate is created instead).
// Add the protocol BEFORE registerClassPair — the runtime locks
// the class layout after registration.
UISceneDelegateProto := objc_getProtocol("UISceneDelegate".ptr);
UIWindowSceneDelegateProto := objc_getProtocol("UIWindowSceneDelegate".ptr);
if UISceneDelegateProto != null {
class_addProtocol(SxSceneDelegate, UISceneDelegateProto);
} else {
NSLog(ns_string("[sx] WARN: UISceneDelegate protocol not found (dead-stripped)\n".ptr));
}
if UIWindowSceneDelegateProto != null {
class_addProtocol(SxSceneDelegate, UIWindowSceneDelegateProto);
}
objc_registerClassPair(SxSceneDelegate);
uikit_register_gl_view_class();
uikit_register_metal_view_class();
}
}
// Read [UIScreen mainScreen].nativeScale into plat.dpi_scale. Used by the
// metal-mode init path which doesn't go through uikit_create_gl_context
// (that's where the gles path picks the scale up).
uikit_read_screen_scale :: (plat: *UIKitPlatform) {
inline if OS != .ios { return; }
UIScreen := objc_getClass("UIScreen".ptr);
screen := #objc_call(*void)(UIScreen, "mainScreen");
scale_d := #objc_call(f64)(screen, "nativeScale");
plat.dpi_scale = xx scale_d;
}
// 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;
user_info := #objc_call(*void)(notification, "userInfo");
if user_info == null { return; }
end_value := #objc_call(*void)(user_info, "objectForKey:",
ns_string("UIKeyboardFrameEndUserInfoKey".ptr));
if end_value == null { return; }
end_rect := #objc_call(CGRect)(end_value, "CGRectValue");
dur_value := #objc_call(*void)(user_info, "objectForKey:",
ns_string("UIKeyboardAnimationDurationUserInfoKey".ptr));
anim_dur : f64 = 0.0;
if dur_value != null { anim_dur = #objc_call(f64)(dur_value, "doubleValue"); }
curve_value := #objc_call(*void)(user_info, "objectForKey:",
ns_string("UIKeyboardAnimationCurveUserInfoKey".ptr));
curve_int : u64 = 0;
if curve_value != null { curve_int = #objc_call(u64)(curve_value, "unsignedLongValue"); }
// Screen height in points. The window lives on the connected scene's screen.
if plat.window == null { return; }
win_screen := #objc_call(*void)(plat.window, "screen");
screen_bounds := #objc_call(CGRect)(win_screen, "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; }
target_h : f32 = xx h;
plat.keyboard_visible = h > 0.5;
if anim_dur <= 0.0 {
// No animation window — snap.
plat.keyboard_height = target_h;
plat.kb_animating = false;
return;
}
// Capture the animation params for the sx-side per-tick interpolation
// that drives `keyboard_height` (consumers like the chess UI's safe-area
// calc read it each frame). The interpolation uses cubic ease-out as a
// close approximation of UIKit's keyboard curve. For perfect lockstep
// on a UIView consumer in user code, drive a property via
// `[UIView animateWithDuration:plat.kb_anim_dur delay:0
// options:(plat.kb_anim_curve << 16) | 4
// animations:^{ ... }
// completion:nil]`
// — UIKit's internal options-to-CAMediaTimingFunction table handles
// even the private keyboard curve 7 correctly when packed this way.
plat.kb_anim_from = plat.keyboard_height;
plat.kb_anim_to = target_h;
plat.kb_anim_start = CACurrentMediaTime();
plat.kb_anim_dur = anim_dur;
plat.kb_anim_curve = curve_int;
plat.kb_animating = true;
}
uikit_create_gl_context :: (plat: *UIKitPlatform) {
inline if OS != .ios { return; }
EAGLContext := objc_getClass("EAGLContext".ptr);
UIScreen := objc_getClass("UIScreen".ptr);
// Read the screen scale up-front so callers can size font caches and
// textures with the right DPI before the window even exists.
screen := #objc_call(*void)(UIScreen, "mainScreen");
scale_d := #objc_call(f64)(screen, "nativeScale");
plat.dpi_scale = xx scale_d;
ctx_raw := #objc_call(*void)(EAGLContext, "alloc");
plat.gl_ctx = #objc_call(*void)(ctx_raw, "initWithAPI:", EAGL_API_GLES3);
#objc_call(void)(EAGLContext, "setCurrentContext:", plat.gl_ctx);
load_gl(@ios_gl_proc);
}
uikit_window_getter :: (self: *void, _cmd: *void) -> *void callconv(.c) {
if g_uikit_plat == null { return xx 0; }
g_uikit_plat.window;
}
uikit_window_setter :: (self: *void, _cmd: *void, w: *void) callconv(.c) {
if g_uikit_plat == null { return; }
g_uikit_plat.window = w;
}
uikit_did_finish_launching :: (self: *void, _cmd: *void, app: *void, opts: *void) -> u8 callconv(.c) {
inline if OS == .ios {
if g_uikit_plat != null {
uikit_subscribe_keyboard_notifications(self);
}
}
1;
}
uikit_subscribe_keyboard_notifications :: (delegate: *void) {
inline if OS != .ios { return; }
NSNotificationCenter := objc_getClass("NSNotificationCenter".ptr);
center := #objc_call(*void)(NSNotificationCenter, "defaultCenter");
#objc_call(void)(center, "addObserver:selector:name:object:",
delegate,
sel_registerName("sxKeyboardWillChangeFrame:".ptr),
ns_string("UIKeyboardWillChangeFrameNotification".ptr),
xx 0);
}
uikit_scene_will_connect :: (self: *void, _cmd: *void, scene: *void, session: *void, options: *void) callconv(.c) {
inline if OS == .ios {
uikit_scene_will_connect_ios(self, scene);
}
}
uikit_scene_will_connect_ios :: (delegate: *void, scene: *void) {
if g_uikit_plat == null {
NSLog(ns_string("[sx] no platform\n".ptr));
return;
}
plat := g_uikit_plat;
UIWindow := objc_getClass("UIWindow".ptr);
UIViewController := objc_getClass("UIViewController".ptr);
SxGLView := objc_getClass("SxGLView".ptr);
SxMetalView := objc_getClass("SxMetalView".ptr);
CADisplayLink := objc_getClass("CADisplayLink".ptr);
NSRunLoop := objc_getClass("NSRunLoop".ptr);
win_raw := #objc_call(*void)(UIWindow, "alloc");
plat.window = #objc_call(*void)(win_raw, "initWithWindowScene:", scene);
// Make the scene delegate own the window so iOS retains it. Per the
// scene-based lifecycle, the scene delegate is expected to provide the
// UIWindow via -window/-setWindow:.
#objc_call(void)(delegate, "setWindow:", plat.window);
vc_raw := #objc_call(*void)(UIViewController, "alloc");
plat.root_vc = #objc_call(*void)(vc_raw, "init");
// Allocate either SxGLView or SxMetalView based on gpu_mode and install
// it as the VC's view. The view's +layerClass override gives us the
// right CAEAGLLayer / CAMetalLayer subclass. Setting it BEFORE
// setRootViewController avoids the VC lazy-loading a default view.
view_class := if plat.gpu_mode == .gles then SxGLView else SxMetalView;
glv_raw := #objc_call(*void)(view_class, "alloc");
plat.gl_view = #objc_call(*void)(glv_raw, "init");
#objc_call(void)(plat.root_vc, "setView:", plat.gl_view);
#objc_call(void)(plat.window, "setRootViewController:", plat.root_vc);
plat.gl_layer = #objc_call(*void)(plat.gl_view, "layer");
// Mark the layer opaque (no compositor blend). Required for EAGL +
// recommended for Metal (CAMetalLayer.opaque defaults to YES but doesn't
// hurt to be explicit).
#objc_call(void)(plat.gl_layer, "setOpaque:", xx 1);
if plat.gpu_mode == .gles {
// EAGL drawable properties dict required by
// EAGLContext.renderbufferStorage:fromDrawable: (color format,
// non-retained backing). Without this dict the renderbuffer
// allocation silently fails and the framebuffer reports INCOMPLETE.
NSMutableDictionary := objc_getClass("NSMutableDictionary".ptr);
NSNumber := objc_getClass("NSNumber".ptr);
ns_no := #objc_call(*void)(NSNumber, "numberWithBool:", xx 0);
// The EAGL dict keys/values must be the framework-provided NSString
// constants (pointer identity is checked) — dlsym them from OpenGLES.
retained_key := uikit_extern_nsstring("kEAGLDrawablePropertyRetainedBacking".ptr);
colorformat_key := uikit_extern_nsstring("kEAGLDrawablePropertyColorFormat".ptr);
rgba8_value := uikit_extern_nsstring("kEAGLColorFormatRGBA8".ptr);
dict := #objc_call(*void)(NSMutableDictionary, "dictionary");
#objc_call(void)(dict, "setObject:forKey:", ns_no, retained_key);
#objc_call(void)(dict, "setObject:forKey:", rgba8_value, colorformat_key);
#objc_call(void)(plat.gl_layer, "setDrawableProperties:", dict);
}
// EAGLContext + load_gl were already done in uikit_create_gl_context()
// back when the game's main called plat.init() — so shaders/textures
// built before the window exists already work.
// Match the layer's drawable scale to the screen's native scale so we get
// pixel-accurate rendering on retina displays. CGFloat is `double` on
// 64-bit Apple platforms; reading as f32 would clobber the value.
screen := #objc_call(*void)(plat.window, "screen");
scale := #objc_call(f64)(screen, "nativeScale");
plat.dpi_scale = xx scale;
#objc_call(void)(plat.gl_view, "setContentScaleFactor:", scale);
// Renderbuffer is allocated lazily in -[SxGLView layoutSubviews] once
// the layer has its real on-screen bounds. makeKeyAndVisible triggers
// a layout pass; layoutSubviews calls uikit_setup_renderbuffer.
#objc_call(void)(plat.window, "makeKeyAndVisible");
// 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);
tf_raw := #objc_call(*void)(UITextField, "alloc");
plat.text_field = #objc_call(*void)(tf_raw, "init");
#objc_call(void)(plat.gl_view, "addSubview:", plat.text_field);
// (Keyboard observer is registered in didFinishLaunching via
// uikit_subscribe_keyboard_notifications — it's app-level, not scene-
// level, so it doesn't belong here.)
// CADisplayLink: vsync-driven tick into our SxGLView. The second arg is
// a SEL value (not a dispatch selector), so it still goes through
// sel_registerName.
sel_tick := sel_registerName("sxTick:".ptr);
plat.display_link = #objc_call(*void)(CADisplayLink, "displayLinkWithTarget:selector:", plat.gl_view, sel_tick);
runloop := #objc_call(*void)(NSRunLoop, "currentRunLoop");
mode := ns_string("kCFRunLoopDefaultMode".ptr);
#objc_call(void)(plat.display_link, "addToRunLoop:forMode:", runloop, mode);
NSLog(ns_string("[sx] UIKitPlatform booted\n".ptr));
}
// Allocate the color renderbuffer + framebuffer and bind them. The renderbuffer
// gets its pixel storage from the CAEAGLLayer via
// `[ctx renderbufferStorage:GL_RENDERBUFFER fromDrawable:layer]`, so the layer
// must already be in the view hierarchy at the right size when this runs.
uikit_setup_renderbuffer :: (plat: *UIKitPlatform) {
inline if OS != .ios { return; }
glGenFramebuffers(1, @plat.framebuffer);
glGenRenderbuffers(1, @plat.color_renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, plat.color_renderbuffer);
#objc_call(bool)(plat.gl_ctx, "renderbufferStorage:fromDrawable:", GL_RENDERBUFFER, plat.gl_layer);
glBindFramebuffer(GL_FRAMEBUFFER, plat.framebuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, plat.color_renderbuffer);
// Query the actual pixel dimensions from the renderbuffer.
pw : s32 = 0;
ph : s32 = 0;
GL_RENDERBUFFER_WIDTH :u32: 0x8D42;
GL_RENDERBUFFER_HEIGHT :u32: 0x8D43;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, @pw);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, @ph);
plat.pixel_w = pw;
plat.pixel_h = ph;
plat.viewport_w = xx pw;
plat.viewport_h = xx ph;
if plat.dpi_scale > 0.0 {
plat.viewport_w = plat.viewport_w / plat.dpi_scale;
plat.viewport_h = plat.viewport_h / plat.dpi_scale;
}
glViewport(0, 0, pw, ph);
status := glCheckFramebufferStatus(GL_FRAMEBUFFER);
if status != GL_FRAMEBUFFER_COMPLETE {
NSLog(ns_string("[sx] framebuffer incomplete after renderbuffer setup\n".ptr));
}
}
uikit_present_renderbuffer :: (self: *UIKitPlatform) {
inline if OS != .ios { return; }
glBindRenderbuffer(GL_RENDERBUFFER, self.color_renderbuffer);
#objc_call(bool)(self.gl_ctx, "presentRenderbuffer:", GL_RENDERBUFFER);
}
// ── SxGLView class ─────────────────────────────────────────────────────────
// UIView subclass overriding `+layerClass` to return [CAEAGLLayer class].
// Instance method `sxTick:` is what CADisplayLink calls.
uikit_gl_view_layer_class :: (cls: *void, _cmd: *void) -> *void callconv(.c) {
objc_getClass("CAEAGLLayer".ptr);
}
uikit_gl_view_tick :: (self: *void, _cmd: *void, link: *void) callconv(.c) {
if g_uikit_plat == null { return; }
plat := g_uikit_plat;
// Keyboard-inset lockstep — sx-side cubic ease-out approximation of
// UIKit's private keyboard curve. Sample targetTimestamp so we
// interpolate at the time this frame will be visible. Lags by ~1
// frame behind UIKit because UIKit's keyboard is rendered in a
// separate process (UIRemoteKeyboardWindow) and we can't perfectly
// sync to it from outside that scene. Refinements tried:
// CATransaction.flush, CABasicAnimation, presentationLayer reading,
// and keyboardLayoutGuide — none eliminated the lag without
// cascade-breaking the GL view's frame.
if plat.kb_animating {
target_ts := #objc_call(f64)(link, "targetTimestamp");
elapsed := target_ts - plat.kb_anim_start;
// Negative elapsed can happen if the just-fired willChangeFrame
// set kb_anim_start to a wall time AFTER the tick already
// captured its targetTimestamp this frame. Without the clamp,
// t < 0 makes the cubic ease-out *overshoot* in the opposite
// direction (visible as the indicator briefly jumping past the
// keyboard on close, then animating back).
if elapsed < 0.0 { elapsed = 0.0; }
if elapsed >= plat.kb_anim_dur or plat.kb_anim_dur <= 0.0 {
plat.keyboard_height = plat.kb_anim_to;
plat.kb_animating = false;
} else {
t : f32 = xx (elapsed / plat.kb_anim_dur);
inv := 1.0 - t;
eased := 1.0 - inv * inv * inv;
plat.keyboard_height = plat.kb_anim_from + (plat.kb_anim_to - plat.kb_anim_from) * eased;
}
}
// Indicator's position is driven by UIView.animateWithDuration kicked
// off from willChangeFrame — it animates in lockstep with UIKit's
// keyboard using the same curve+duration. No per-tick setFrame here.
if !plat.has_frame_closure { return; }
if !plat.gl_initialized { return; }
dur_d := #objc_call(f64)(link, "duration");
plat.delta_time = xx dur_d;
// Stash the targetTimestamp so begin_frame can hand it down to the
// game in FrameContext for Metal presentDrawable:atTime:.
plat.last_target_ts = #objc_call(f64)(link, "targetTimestamp");
fn := plat.frame_closure;
fn();
}
uikit_gl_view_layout :: (self: *void, _cmd: *void) callconv(.c) {
// Call super first via objc_msgSendSuper would be cleaner, but UIView's
// default layoutSubviews is a no-op anyway.
if g_uikit_plat == null { return; }
plat := g_uikit_plat;
if plat.gl_initialized { return; }
if plat.gpu_mode == .gles {
uikit_setup_renderbuffer(plat);
} else {
uikit_compute_layer_pixel_size(plat);
}
plat.gl_initialized = true;
}
// Metal mode equivalent of uikit_setup_renderbuffer's "tell me how big the
// drawable is in pixels". Reads the layer's bounds in points and scales to
// pixels via dpi_scale. CAMetalLayer.drawableSize is set by MetalGPU.init
// based on these dims.
uikit_compute_layer_pixel_size :: (plat: *UIKitPlatform) {
inline if OS != .ios { return; }
if plat.gl_view == null { return; }
b := #objc_call(CGRect)(plat.gl_view, "bounds");
w_pts : f64 = b.width;
h_pts : f64 = b.height;
plat.viewport_w = xx w_pts;
plat.viewport_h = xx h_pts;
scale64 : f64 = xx plat.dpi_scale;
pw : f64 = w_pts * scale64;
ph : f64 = h_pts * scale64;
plat.pixel_w = xx pw;
plat.pixel_h = xx ph;
}
// Touch IMPs — UIKit fires touchesBegan/Moved/Ended/Cancelled with an
// NSSet<UITouch *> + UIEvent. We take the first touch (single-touch model
// matching the chess game's drag-and-tap UX) and push the resulting
// Event into the platform's queue for the next poll_events drain.
uikit_touch_location :: (touch: *void, view: *void) -> Point {
p := #objc_call(CGPoint)(touch, "locationInView:", view);
Point.{ x = xx p.x, y = xx p.y };
}
uikit_first_touch :: (touches: *void) -> *void {
#objc_call(*void)(touches, "anyObject");
}
uikit_gl_view_touches_began :: (self: *void, _cmd: *void, touches: *void, event: *void) callconv(.c) {
if g_uikit_plat == null { return; }
touch := uikit_first_touch(touches);
if touch == null { return; }
pos := uikit_touch_location(touch, self);
g_uikit_plat.events.append(.mouse_down(.{ position = pos, button = .left }));
}
uikit_gl_view_touches_moved :: (self: *void, _cmd: *void, touches: *void, event: *void) callconv(.c) {
if g_uikit_plat == null { return; }
touch := uikit_first_touch(touches);
if touch == null { return; }
pos := uikit_touch_location(touch, self);
g_uikit_plat.events.append(.mouse_moved(.{ position = pos, delta = Point.zero() }));
}
uikit_gl_view_touches_ended :: (self: *void, _cmd: *void, touches: *void, event: *void) callconv(.c) {
if g_uikit_plat == null { return; }
touch := uikit_first_touch(touches);
if touch == null { return; }
pos := uikit_touch_location(touch, self);
g_uikit_plat.events.append(.mouse_up(.{ position = pos, button = .left }));
}
uikit_register_gl_view_class :: () {
inline if OS == .ios {
UIView := objc_getClass("UIView".ptr);
SxGLView := objc_allocateClassPair(UIView, "SxGLView".ptr, 0);
// +layerClass is a CLASS method — registered on the metaclass.
metaclass := object_getClass(SxGLView);
class_addMethod(metaclass,
sel_registerName("layerClass".ptr),
xx uikit_gl_view_layer_class, "#@:".ptr);
// -sxTick: is the CADisplayLink callback. -layoutSubviews allocates
// the renderbuffer when the layer first gets non-zero bounds.
class_addMethod(SxGLView,
sel_registerName("sxTick:".ptr),
xx uikit_gl_view_tick, "v@:@".ptr);
class_addMethod(SxGLView,
sel_registerName("layoutSubviews".ptr),
xx uikit_gl_view_layout, "v@:".ptr);
// Touch dispatch.
class_addMethod(SxGLView,
sel_registerName("touchesBegan:withEvent:".ptr),
xx uikit_gl_view_touches_began, "v@:@@".ptr);
class_addMethod(SxGLView,
sel_registerName("touchesMoved:withEvent:".ptr),
xx uikit_gl_view_touches_moved, "v@:@@".ptr);
class_addMethod(SxGLView,
sel_registerName("touchesEnded:withEvent:".ptr),
xx uikit_gl_view_touches_ended, "v@:@@".ptr);
class_addMethod(SxGLView,
sel_registerName("touchesCancelled:withEvent:".ptr),
xx uikit_gl_view_touches_ended, "v@:@@".ptr);
objc_registerClassPair(SxGLView);
}
}
// +layerClass IMP for SxMetalView. Class method, signature "#@:".
uikit_metal_view_layer_class :: (cls: *void, _cmd: *void) -> *void callconv(.c) {
objc_getClass("CAMetalLayer".ptr);
}
// SxMetalView reuses the same tick/layout/touch IMPs as SxGLView. The IMPs
// already branch on `plat.gpu_mode` for the GL-specific bits (renderbuffer
// setup, etc.), so a single set of IMPs serves both view classes.
uikit_register_metal_view_class :: () {
inline if OS == .ios {
UIView := objc_getClass("UIView".ptr);
SxMetalView := objc_allocateClassPair(UIView, "SxMetalView".ptr, 0);
metaclass := object_getClass(SxMetalView);
class_addMethod(metaclass,
sel_registerName("layerClass".ptr),
xx uikit_metal_view_layer_class, "#@:".ptr);
class_addMethod(SxMetalView,
sel_registerName("sxTick:".ptr),
xx uikit_gl_view_tick, "v@:@".ptr);
class_addMethod(SxMetalView,
sel_registerName("layoutSubviews".ptr),
xx uikit_gl_view_layout, "v@:".ptr);
class_addMethod(SxMetalView,
sel_registerName("touchesBegan:withEvent:".ptr),
xx uikit_gl_view_touches_began, "v@:@@".ptr);
class_addMethod(SxMetalView,
sel_registerName("touchesMoved:withEvent:".ptr),
xx uikit_gl_view_touches_moved, "v@:@@".ptr);
class_addMethod(SxMetalView,
sel_registerName("touchesEnded:withEvent:".ptr),
xx uikit_gl_view_touches_ended, "v@:@@".ptr);
class_addMethod(SxMetalView,
sel_registerName("touchesCancelled:withEvent:".ptr),
xx uikit_gl_view_touches_ended, "v@:@@".ptr);
objc_registerClassPair(SxMetalView);
}
}