Files
sx/library/modules/platform/uikit.sx
agra 78288b98ac ffi M3.3 + M3.4 + M3.5: SxGLView/SxMetalView migrated; uikit_register_classes deleted
Three slices in one commit since they're tightly coupled (the
M3.5 deletion only makes sense after M3.3 and M3.4):

M3.3 — SxGLView migrated to declarative '#objc_class("SxGLView")':
  - '#extends UIView' for the view-hierarchy + responder chain.
  - 'layerClass :: *void = objc_getClass("CAEAGLLayer".ptr);' uses
    the M2.1(a) class-level constant form. Registered on the
    metaclass; UIView's +layerClass override dispatches here so
    EAGL gets the right backing layer.
  - Six instance methods (sxTick, layoutSubviews, four touch
    selectors) forward to existing legacy IMP free functions.

M3.4 — SxMetalView migrated, same shape as SxGLView; differs only
  in the 'layerClass' constant returning CAMetalLayer instead of
  CAEAGLLayer. The five shared IMPs (sxTick/layoutSubviews/4 touch
  handlers) reach the same free functions — they already branch on
  plat.gpu_mode for GL-specific renderbuffer code.

M3.5 — uikit_register_classes() and the two helper registration
  functions are deleted outright. Every sx-defined Obj-C class in
  this module now goes through the compiler's M1.2 / M2.1(a)
  synthesis path at module init. The call site inside
  UIKitPlatform.init is gone too — just a comment marking the
  migration point.

Chess on iOS-sim: board renders, scene-delegate connection still
fires, GL/Metal layer setup intact, touch dispatch routes through
the synthesized IMP trampolines. 183 example tests + zig build
test green.

End of M3. The platform layer's Obj-C-runtime wiring is fully
declarative.

Remaining: M4 (autoreleasepool + ARC ops), M5 (closure↔block),
M6 (auto-import + production hardening). M1.1.b (Class(T)
parameterization + instancetype) is still deferred — none of
the migrated uikit code needed it.
2026-05-26 07:41:07 +03:00

958 lines
36 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;
}
// ── Foundation utility classes (Phase 3.2 C1) ─────────────────────────
// Declarative `#objc_class` bindings replace the previous
// `objc_getClass(...) + #objc_call(T)(...)` pattern. Each method's sx-
// side name maps to its Obj-C selector via the default mangling rule
// (split on `_`; each piece becomes a keyword with `:`).
NSValue :: #foreign #objc_class("NSValue") {
// CGRect unboxing — returns by value via the sret/HFA path.
CGRectValue :: (self: *Self) -> CGRect;
}
NSNumber :: #foreign #objc_class("NSNumber") {
// Class method (no `self: *Self` first param → static dispatch).
numberWithBool :: (b: s8) -> *NSNumber;
// Instance value extractors.
doubleValue :: (self: *Self) -> f64;
unsignedLongValue :: (self: *Self) -> u64;
}
NSDictionary :: #foreign #objc_class("NSDictionary") {
objectForKey :: (self: *Self, key: *void) -> *void;
}
NSMutableDictionary :: #foreign #objc_class("NSMutableDictionary") {
dictionary :: () -> *NSMutableDictionary;
setObject_forKey :: (self: *Self, obj: *void, key: *void);
}
NSSet :: #foreign #objc_class("NSSet") {
anyObject :: (self: *Self) -> *void;
}
// ── Notifications + Bundle (Phase 3.2 C2) ──────────────────────────────
NSNotification :: #foreign #objc_class("NSNotification") {
userInfo :: (self: *Self) -> *NSDictionary;
}
NSBundle :: #foreign #objc_class("NSBundle") {
mainBundle :: () -> *NSBundle;
resourcePath :: (self: *Self) -> *void;
}
NSNotificationCenter :: #foreign #objc_class("NSNotificationCenter") {
defaultCenter :: () -> *NSNotificationCenter;
addObserver_selector_name_object :: (self: *Self, observer: *void, sel: *void, name: *void, obj: *void);
}
// ── RunLoop + display timing (Phase 3.2 C3) ────────────────────────────
NSRunLoop :: #foreign #objc_class("NSRunLoop") {
currentRunLoop :: () -> *NSRunLoop;
}
CADisplayLink :: #foreign #objc_class("CADisplayLink") {
displayLinkWithTarget_selector :: (target: *void, sel: *void) -> *CADisplayLink;
addToRunLoop_forMode :: (self: *Self, runloop: *NSRunLoop, mode: *void);
targetTimestamp :: (self: *Self) -> f64;
duration :: (self: *Self) -> f64;
}
// ── View tree + GL drawables (Phase 3.2 C5) ────────────────────────────
// (Declared before UIView so `layer :: (...) -> *CALayer` can reference it.)
CALayer :: #foreign #objc_class("CALayer") {
setOpaque :: (self: *Self, opaque: s8);
}
CAEAGLLayer :: #foreign #objc_class("CAEAGLLayer") {
setDrawableProperties :: (self: *Self, props: *void);
}
EAGLContext :: #foreign #objc_class("EAGLContext") {
alloc :: () -> *EAGLContext;
initWithAPI :: (self: *Self, api: s32) -> *EAGLContext;
setCurrentContext :: (ctx: *EAGLContext);
renderbufferStorage_fromDrawable :: (self: *Self, target: u32, drawable: *void) -> s8;
presentRenderbuffer :: (self: *Self, target: u32) -> s8;
}
// ── UIKit chrome (Phase 3.2 C4) ────────────────────────────────────────
UIScreen :: #foreign #objc_class("UIScreen") {
mainScreen :: () -> *UIScreen;
nativeScale :: (self: *Self) -> f64;
bounds :: (self: *Self) -> CGRect;
}
// UIResponder is the root for keyboard / touch / focus dispatch.
// Most UIKit classes inherit from it; sx-defined classes that
// participate in lifecycle callbacks (delegates, scene delegates)
// extend it so the runtime picks up the responder-chain behavior.
UIResponder :: #foreign #objc_class("UIResponder") {
becomeFirstResponder :: (self: *Self) -> s8;
resignFirstResponder :: (self: *Self) -> s8;
}
UIView :: #foreign #objc_class("UIView") {
#extends UIResponder;
safeAreaInsets :: (self: *Self) -> UIEdgeInsets;
addSubview :: (self: *Self, view: *void);
layer :: (self: *Self) -> *CALayer;
setContentScaleFactor :: (self: *Self, scale: f64);
}
UIWindow :: #foreign #objc_class("UIWindow") {
alloc :: () -> *UIWindow;
initWithWindowScene :: (self: *Self, scene: *void) -> *UIWindow;
setRootViewController :: (self: *Self, vc: *void);
makeKeyAndVisible :: (self: *Self);
screen :: (self: *Self) -> *UIScreen;
}
UIViewController :: #foreign #objc_class("UIViewController") {
alloc :: () -> *UIViewController;
init :: (self: *Self) -> *UIViewController;
setView :: (self: *Self, view: *void);
}
UITextField :: #foreign #objc_class("UITextField") {
#extends UIResponder;
alloc :: () -> *UITextField;
init :: (self: *Self) -> *UITextField;
}
// SxAppDelegate — UIApplicationMain's principal class. Replaces the
// M3.1 hand-rolled objc_allocateClassPair + class_addMethod sequence
// in uikit_register_classes. The method bodies forward to the
// existing legacy IMP free functions so we don't have to inline 70+
// lines of keyboard-frame logic here.
SxAppDelegate :: #objc_class("SxAppDelegate") {
#extends UIResponder;
application_didFinishLaunchingWithOptions :: (self: *Self, app: *void, opts: *void) -> BOOL {
return xx uikit_did_finish_launching(xx self, xx 0, app, opts);
}
sxKeyboardWillChangeFrame :: (self: *Self, notification: *void) {
uikit_keyboard_will_change_frame(xx self, xx 0, notification);
}
alloc :: () -> *SxAppDelegate;
init :: (self: *SxAppDelegate) -> *SxAppDelegate;
}
// SxSceneDelegate — iOS 13+ scene-based lifecycle delegate.
// UIApplicationSceneManifest names this in Info.plist; iOS
// instantiates it via scene-session connection. Replaces the M3.2
// hand-rolled registration. Two `#implements` declarations
// formally conform to the scene-delegate protocols — iOS rejects
// the class otherwise.
SxSceneDelegate :: #objc_class("SxSceneDelegate") {
#extends UIResponder;
#implements UISceneDelegate;
#implements UIWindowSceneDelegate;
scene_willConnectToSession_options :: (self: *Self, scene: *void, session: *void, options: *void) {
uikit_scene_will_connect(xx self, xx 0, scene, session, options);
}
window :: (self: *Self) -> *void {
return uikit_window_getter(xx self, xx 0);
}
setWindow :: (self: *Self, w: *void) {
uikit_window_setter(xx self, xx 0, w);
}
}
// 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();
// M3.5 — uikit_register_classes was deleted. Every
// sx-defined Obj-C class in this module is now declarative;
// the compiler synthesises class-pair init at module init.
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; }
tf : *UITextField = xx self.text_field;
tf.becomeFirstResponder();
}
}
hide_keyboard :: (self: *UIKitPlatform) {
inline if OS == .ios {
if self.text_field == null { return; }
tf : *UITextField = xx self.text_field;
tf.resignFirstResponder();
}
}
stop :: (self: *UIKitPlatform) { }
shutdown :: (self: *UIKitPlatform) { }
}
// dlsym(RTLD_DEFAULT, name) — Apple platforms. RTLD_DEFAULT is (void*)-2.
// callconv(.c) so this is callable from `load_gl`'s C-conv proc-loader slot.
ios_gl_proc :: (name: [*]u8) -> *void callconv(.c) {
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; }
gl_view : *UIView = xx plat.gl_view;
i := 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; }
bundle := NSBundle.mainBundle();
rsrc := bundle.resourcePath();
if rsrc == null { return; }
chdir(c_string(rsrc));
}
// uikit_register_classes — deleted (M3.5). Every sx-defined
// Obj-C class in this module (SxAppDelegate, SxSceneDelegate,
// SxGLView, SxMetalView) is now a declarative `#objc_class(...)`
// block — the compiler synthesises their IMPs, class-pair
// registration, ivar wiring, +alloc / -dealloc trampolines, and
// `#implements` protocol conformances at module init.
// 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; }
screen := UIScreen.mainScreen();
plat.dpi_scale = xx screen.nativeScale();
}
// 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;
notif : *NSNotification = xx notification;
user_info := notif.userInfo();
if user_info == null { return; }
end_value_raw := user_info.objectForKey(ns_string("UIKeyboardFrameEndUserInfoKey".ptr));
if end_value_raw == null { return; }
end_value : *NSValue = xx end_value_raw;
end_rect := end_value.CGRectValue();
dur_value_raw := user_info.objectForKey(ns_string("UIKeyboardAnimationDurationUserInfoKey".ptr));
anim_dur : f64 = 0.0;
if dur_value_raw != null {
dur_value : *NSNumber = xx dur_value_raw;
anim_dur = dur_value.doubleValue();
}
curve_value_raw := user_info.objectForKey(ns_string("UIKeyboardAnimationCurveUserInfoKey".ptr));
curve_int : u64 = 0;
if curve_value_raw != null {
curve_value : *NSNumber = xx curve_value_raw;
curve_int = curve_value.unsignedLongValue();
}
// Screen height in points. The window lives on the connected scene's screen.
if plat.window == null { return; }
win : *UIWindow = xx plat.window;
win_screen := win.screen();
screen_bounds := 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; }
// Read the screen scale up-front so callers can size font caches and
// textures with the right DPI before the window even exists.
screen := UIScreen.mainScreen();
plat.dpi_scale = xx screen.nativeScale();
ctx := EAGLContext.alloc().initWithAPI(EAGL_API_GLES3);
plat.gl_ctx = xx ctx;
EAGLContext.setCurrentContext(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; }
center := NSNotificationCenter.defaultCenter();
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;
SxGLView := objc_getClass("SxGLView".ptr);
SxMetalView := objc_getClass("SxMetalView".ptr);
// UIWindow / UIViewController / CADisplayLink / NSRunLoop class
// slots come from the declarative #objc_class bindings.
win := UIWindow.alloc().initWithWindowScene(scene);
plat.window = xx win;
// 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 := UIViewController.alloc().init();
plat.root_vc = xx vc;
// 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");
vc.setView(plat.gl_view);
win.setRootViewController(plat.root_vc);
gl_view : *UIView = xx plat.gl_view;
gl_layer := gl_view.layer();
plat.gl_layer = xx gl_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).
gl_layer.setOpaque(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.
ns_no := NSNumber.numberWithBool(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 := NSMutableDictionary.dictionary();
dict.setObject_forKey(xx ns_no, retained_key);
dict.setObject_forKey(rgba8_value, colorformat_key);
eagl_layer : *CAEAGLLayer = xx gl_layer;
eagl_layer.setDrawableProperties(xx 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.
screen2 := win.screen();
scale := screen2.nativeScale();
plat.dpi_scale = xx scale;
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.
win.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.
tf := UITextField.alloc().init();
plat.text_field = xx tf;
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);
link := CADisplayLink.displayLinkWithTarget_selector(plat.gl_view, sel_tick);
plat.display_link = xx link;
runloop := NSRunLoop.currentRunLoop();
mode_ns := ns_string("kCFRunLoopDefaultMode".ptr);
link.addToRunLoop_forMode(runloop, mode_ns);
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);
gl_ctx : *EAGLContext = xx plat.gl_ctx;
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);
gl_ctx : *EAGLContext = xx self.gl_ctx;
gl_ctx.presentRenderbuffer(GL_RENDERBUFFER);
}
// ── SxGLView class ─────────────────────────────────────────────────────────
// UIView subclass overriding `+layerClass` to return [CAEAGLLayer class].
// Instance method `sxTick:` is what CADisplayLink calls.
//
// M3.3 — migrated to declarative `#objc_class` form. The compiler
// synthesises class-pair init, metaclass `+layerClass`, and the
// instance-method IMP trampolines at module init.
SxGLView :: #objc_class("SxGLView") {
#extends UIView;
// Class-level constant — `+layerClass` returns [CAEAGLLayer class].
layerClass :: *void = objc_getClass("CAEAGLLayer".ptr);
sxTick :: (self: *Self, link: *void) {
uikit_gl_view_tick(xx self, xx 0, link);
}
layoutSubviews :: (self: *Self) {
uikit_gl_view_layout(xx self, xx 0);
}
touchesBegan_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_began(xx self, xx 0, touches, event);
}
touchesMoved_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_moved(xx self, xx 0, touches, event);
}
touchesEnded_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_ended(xx self, xx 0, touches, event);
}
touchesCancelled_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_ended(xx self, xx 0, touches, event);
}
}
uikit_gl_view_tick :: (self: *void, _cmd: *void, link_raw: *void) callconv(.c) {
link : *CADisplayLink = xx link_raw;
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 := 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 := 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 = 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 {
touches_set : *NSSet = xx touches;
touches_set.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 — deleted (M3.3). SxGLView is now
// declarative; the compiler synthesises everything at module init.
// 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.
//
// M3.4 — migrated to declarative `#objc_class`. Only `+layerClass`
// differs from SxGLView (returns CAMetalLayer instead of CAEAGLLayer).
SxMetalView :: #objc_class("SxMetalView") {
#extends UIView;
layerClass :: *void = objc_getClass("CAMetalLayer".ptr);
sxTick :: (self: *Self, link: *void) {
uikit_gl_view_tick(xx self, xx 0, link);
}
layoutSubviews :: (self: *Self) {
uikit_gl_view_layout(xx self, xx 0);
}
touchesBegan_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_began(xx self, xx 0, touches, event);
}
touchesMoved_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_moved(xx self, xx 0, touches, event);
}
touchesEnded_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_ended(xx self, xx 0, touches, event);
}
touchesCancelled_withEvent :: (self: *Self, touches: *void, event: *void) {
uikit_gl_view_touches_ended(xx self, xx 0, touches, event);
}
}
// uikit_register_metal_view_class — deleted (M3.4). Replaced by
// the declarative SxMetalView form above.