*)touches withEvent:(UIEvent *)event {
+ [self touchesEnded:touches withEvent:event];
+}
+
+@end
+
+// =============================================================================
+// ZcatguiViewController Implementation
+// =============================================================================
+
+@implementation ZcatguiViewController {
+ CADisplayLink *_displayLink;
+}
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ self.zcatguiView = [[ZcatguiView alloc] initWithFrame:self.view.bounds];
+ self.zcatguiView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ [self.view addSubview:self.zcatguiView];
+
+ g_view = self.zcatguiView;
+ g_width = (uint32_t)self.view.bounds.size.width;
+ g_height = (uint32_t)self.view.bounds.size.height;
+
+ self.running = YES;
+}
+
+- (void)viewDidLayoutSubviews {
+ [super viewDidLayoutSubviews];
+
+ uint32_t newWidth = (uint32_t)self.view.bounds.size.width;
+ uint32_t newHeight = (uint32_t)self.view.bounds.size.height;
+
+ if (newWidth != g_width || newHeight != g_height) {
+ g_width = newWidth;
+ g_height = newHeight;
+
+ // Queue resize event
+ ZcatguiEventWrapper *evt = [[ZcatguiEventWrapper alloc] init];
+ evt.type = ZcatguiEventResize;
+ memcpy(&evt.data[0], &newWidth, 4);
+ memcpy(&evt.data[4], &newHeight, 4);
+
+ @synchronized(g_eventQueue) {
+ [g_eventQueue addObject:evt];
+ }
+ }
+}
+
+- (void)startRenderLoop {
+ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(renderFrame:)];
+ [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
+}
+
+- (void)stopRenderLoop {
+ [_displayLink invalidate];
+ _displayLink = nil;
+}
+
+- (void)renderFrame:(CADisplayLink *)displayLink {
+ // Override this in your subclass to call your Zig frame function
+}
+
+- (BOOL)prefersStatusBarHidden {
+ return YES;
+}
+
+@end
+
+// =============================================================================
+// Bridge Functions (called by Zig)
+// =============================================================================
+
+void ios_view_init(uint32_t width, uint32_t height) {
+ g_width = width;
+ g_height = height;
+
+ // Initialize timebase for timing
+ mach_timebase_info(&g_timebaseInfo);
+
+ NSLog(@"[zcatgui] ios_view_init: %ux%u", width, height);
+}
+
+uint32_t ios_view_get_width(void) {
+ return g_width;
+}
+
+uint32_t ios_view_get_height(void) {
+ return g_height;
+}
+
+void ios_view_present(const uint32_t *pixels, uint32_t width, uint32_t height) {
+ if (g_view) {
+ [g_view presentPixels:pixels width:width height:height];
+ }
+}
+
+uint32_t ios_poll_event(uint8_t *buffer) {
+ ZcatguiEventWrapper *evt = nil;
+
+ @synchronized(g_eventQueue) {
+ if (g_eventQueue.count > 0) {
+ evt = g_eventQueue.firstObject;
+ [g_eventQueue removeObjectAtIndex:0];
+ }
+ }
+
+ if (!evt) {
+ return ZcatguiEventNone;
+ }
+
+ memcpy(buffer, evt.data, 64);
+ return evt.type;
+}
+
+void ios_log(const uint8_t *ptr, size_t len) {
+ NSString *msg = [[NSString alloc] initWithBytes:ptr length:len encoding:NSUTF8StringEncoding];
+ NSLog(@"[zcatgui] %@", msg);
+}
+
+uint64_t ios_get_time_ms(void) {
+ uint64_t time = mach_absolute_time();
+ uint64_t nanos = time * g_timebaseInfo.numer / g_timebaseInfo.denom;
+ return nanos / 1000000; // Convert to milliseconds
+}
diff --git a/src/backend/android.zig b/src/backend/android.zig
new file mode 100644
index 0000000..c1c9f28
--- /dev/null
+++ b/src/backend/android.zig
@@ -0,0 +1,492 @@
+//! Android Backend - ANativeActivity based backend
+//!
+//! Provides window/event handling for Android using the native activity API.
+//! Uses extern functions implemented via android_native_app_glue or direct NDK bindings.
+//!
+//! Build target: aarch64-linux-android or x86_64-linux-android
+//!
+//! Requirements:
+//! - Android NDK installed (set ANDROID_NDK_HOME)
+//! - Build with: zig build android
+//!
+//! The resulting .so file should be placed in your Android project's
+//! jniLibs/arm64-v8a/ or jniLibs/x86_64/ directory.
+
+const std = @import("std");
+const Backend = @import("backend.zig").Backend;
+const Event = @import("backend.zig").Event;
+const Input = @import("../core/input.zig");
+const Framebuffer = @import("../render/framebuffer.zig").Framebuffer;
+
+// =============================================================================
+// Android NDK Types (declared manually to avoid @cImport dependency)
+// =============================================================================
+
+// Opaque types
+pub const ANativeActivity = opaque {};
+pub const ANativeWindow = opaque {};
+pub const AInputQueue = opaque {};
+pub const AInputEvent = opaque {};
+pub const ALooper = opaque {};
+
+// Window buffer for direct pixel access
+pub const ANativeWindow_Buffer = extern struct {
+ width: i32,
+ height: i32,
+ stride: i32,
+ format: i32,
+ bits: ?*anyopaque,
+ reserved: [6]u32,
+};
+
+pub const ARect = extern struct {
+ left: i32,
+ top: i32,
+ right: i32,
+ bottom: i32,
+};
+
+// Constants
+pub const ANDROID_LOG_INFO: c_int = 4;
+pub const AINPUT_EVENT_TYPE_KEY: i32 = 1;
+pub const AINPUT_EVENT_TYPE_MOTION: i32 = 2;
+pub const AMOTION_EVENT_ACTION_MASK: i32 = 0xff;
+pub const AMOTION_EVENT_ACTION_DOWN: i32 = 0;
+pub const AMOTION_EVENT_ACTION_UP: i32 = 1;
+pub const AMOTION_EVENT_ACTION_MOVE: i32 = 2;
+pub const AKEY_EVENT_ACTION_DOWN: i32 = 0;
+pub const AKEY_EVENT_ACTION_UP: i32 = 1;
+pub const AMETA_CTRL_ON: i32 = 0x1000;
+pub const AMETA_SHIFT_ON: i32 = 0x1;
+pub const AMETA_ALT_ON: i32 = 0x2;
+pub const AKEYCODE_BACK: i32 = 4;
+pub const AKEYCODE_DEL: i32 = 67;
+pub const AKEYCODE_TAB: i32 = 61;
+pub const AKEYCODE_ENTER: i32 = 66;
+pub const AKEYCODE_ESCAPE: i32 = 111;
+pub const AKEYCODE_SPACE: i32 = 62;
+pub const AKEYCODE_DPAD_LEFT: i32 = 21;
+pub const AKEYCODE_DPAD_UP: i32 = 19;
+pub const AKEYCODE_DPAD_RIGHT: i32 = 22;
+pub const AKEYCODE_DPAD_DOWN: i32 = 20;
+pub const AKEYCODE_FORWARD_DEL: i32 = 112;
+pub const AKEYCODE_MOVE_HOME: i32 = 122;
+pub const AKEYCODE_MOVE_END: i32 = 123;
+pub const AKEYCODE_PAGE_UP: i32 = 92;
+pub const AKEYCODE_PAGE_DOWN: i32 = 93;
+pub const AKEYCODE_INSERT: i32 = 124;
+
+// =============================================================================
+// Android NDK extern functions
+// =============================================================================
+
+extern "android" fn ANativeWindow_getWidth(window: *ANativeWindow) i32;
+extern "android" fn ANativeWindow_getHeight(window: *ANativeWindow) i32;
+extern "android" fn ANativeWindow_lock(window: *ANativeWindow, outBuffer: *ANativeWindow_Buffer, inOutDirtyBounds: ?*ARect) i32;
+extern "android" fn ANativeWindow_unlockAndPost(window: *ANativeWindow) i32;
+
+extern "android" fn ALooper_forThread() ?*ALooper;
+extern "android" fn AInputQueue_attachLooper(queue: *AInputQueue, looper: *ALooper, ident: c_int, callback: ?*anyopaque, data: ?*anyopaque) void;
+extern "android" fn AInputQueue_detachLooper(queue: *AInputQueue) void;
+extern "android" fn AInputQueue_getEvent(queue: *AInputQueue, outEvent: *?*AInputEvent) i32;
+extern "android" fn AInputQueue_preDispatchEvent(queue: *AInputQueue, event: *AInputEvent) i32;
+extern "android" fn AInputQueue_finishEvent(queue: *AInputQueue, event: *AInputEvent, handled: c_int) void;
+
+extern "android" fn AInputEvent_getType(event: *AInputEvent) i32;
+extern "android" fn AMotionEvent_getAction(event: *AInputEvent) i32;
+extern "android" fn AMotionEvent_getX(event: *AInputEvent, pointer_index: usize) f32;
+extern "android" fn AMotionEvent_getY(event: *AInputEvent, pointer_index: usize) f32;
+extern "android" fn AKeyEvent_getAction(event: *AInputEvent) i32;
+extern "android" fn AKeyEvent_getKeyCode(event: *AInputEvent) i32;
+extern "android" fn AKeyEvent_getMetaState(event: *AInputEvent) i32;
+
+extern "log" fn __android_log_write(prio: c_int, tag: [*:0]const u8, text: [*:0]const u8) c_int;
+
+// =============================================================================
+// Logging
+// =============================================================================
+
+pub fn log(comptime fmt: []const u8, args: anytype) void {
+ var buf: [1024]u8 = undefined;
+ const msg = std.fmt.bufPrint(&buf, fmt, args) catch return;
+ // Null-terminate for C
+ if (msg.len < buf.len) {
+ buf[msg.len] = 0;
+ _ = __android_log_write(ANDROID_LOG_INFO, "zcatgui", @ptrCast(&buf));
+ }
+}
+
+// =============================================================================
+// Android Backend Implementation
+// =============================================================================
+
+pub const AndroidBackend = struct {
+ window: ?*ANativeWindow,
+ looper: ?*ALooper,
+ input_queue: ?*AInputQueue,
+ width: u32,
+ height: u32,
+ running: bool,
+
+ // Touch state
+ touch_x: i32,
+ touch_y: i32,
+ touch_down: bool,
+
+ // Event queue (for buffering)
+ event_queue: [64]Event,
+ event_read: usize,
+ event_write: usize,
+
+ const Self = @This();
+
+ /// Initialize the Android backend
+ pub fn init() !Self {
+ log("AndroidBackend.init", .{});
+
+ return Self{
+ .window = null,
+ .looper = ALooper_forThread(),
+ .input_queue = null,
+ .width = 0,
+ .height = 0,
+ .running = true,
+ .touch_x = 0,
+ .touch_y = 0,
+ .touch_down = false,
+ .event_queue = undefined,
+ .event_read = 0,
+ .event_write = 0,
+ };
+ }
+
+ /// Set the native window (called when window is created)
+ pub fn setWindow(self: *Self, window: *ANativeWindow) void {
+ self.window = window;
+ self.width = @intCast(ANativeWindow_getWidth(window));
+ self.height = @intCast(ANativeWindow_getHeight(window));
+ log("Window set: {}x{}", .{ self.width, self.height });
+ }
+
+ /// Clear the native window (called when window is destroyed)
+ pub fn clearWindow(self: *Self) void {
+ self.window = null;
+ self.width = 0;
+ self.height = 0;
+ }
+
+ /// Set the input queue
+ pub fn setInputQueue(self: *Self, queue: *AInputQueue) void {
+ self.input_queue = queue;
+ if (self.looper) |looper| {
+ AInputQueue_attachLooper(queue, looper, 1, null, null);
+ }
+ }
+
+ /// Clear the input queue
+ pub fn clearInputQueue(self: *Self) void {
+ if (self.input_queue) |queue| {
+ AInputQueue_detachLooper(queue);
+ }
+ self.input_queue = null;
+ }
+
+ /// Get as abstract Backend interface
+ pub fn backend(self: *Self) Backend {
+ return .{
+ .ptr = self,
+ .vtable = &vtable,
+ };
+ }
+
+ /// Deinitialize
+ pub fn deinit(self: *Self) void {
+ self.running = false;
+ self.clearInputQueue();
+ self.clearWindow();
+ }
+
+ /// Queue an event
+ fn queueEvent(self: *Self, event: Event) void {
+ const next_write = (self.event_write + 1) % self.event_queue.len;
+ if (next_write != self.event_read) {
+ self.event_queue[self.event_write] = event;
+ self.event_write = next_write;
+ }
+ }
+
+ /// Dequeue an event
+ fn dequeueEvent(self: *Self) ?Event {
+ if (self.event_read == self.event_write) {
+ return null;
+ }
+ const event = self.event_queue[self.event_read];
+ self.event_read = (self.event_read + 1) % self.event_queue.len;
+ return event;
+ }
+
+ /// Process input events from Android
+ fn processInputEvents(self: *Self) void {
+ const queue = self.input_queue orelse return;
+
+ var event: ?*AInputEvent = null;
+ while (AInputQueue_getEvent(queue, &event) >= 0) {
+ if (event) |e| {
+ if (AInputQueue_preDispatchEvent(queue, e) != 0) {
+ continue;
+ }
+
+ const handled = self.handleInputEvent(e);
+ AInputQueue_finishEvent(queue, e, if (handled) 1 else 0);
+ }
+ }
+ }
+
+ /// Handle a single input event
+ fn handleInputEvent(self: *Self, event: *AInputEvent) bool {
+ const event_type = AInputEvent_getType(event);
+
+ if (event_type == AINPUT_EVENT_TYPE_MOTION) {
+ return self.handleMotionEvent(event);
+ } else if (event_type == AINPUT_EVENT_TYPE_KEY) {
+ return self.handleKeyEvent(event);
+ }
+ return false;
+ }
+
+ /// Handle touch/motion events
+ fn handleMotionEvent(self: *Self, event: *AInputEvent) bool {
+ const action = AMotionEvent_getAction(event) & AMOTION_EVENT_ACTION_MASK;
+ const x: i32 = @intFromFloat(AMotionEvent_getX(event, 0));
+ const y: i32 = @intFromFloat(AMotionEvent_getY(event, 0));
+
+ if (action == AMOTION_EVENT_ACTION_DOWN) {
+ self.touch_x = x;
+ self.touch_y = y;
+ self.touch_down = true;
+ self.queueEvent(.{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = .left,
+ .pressed = true,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ });
+ return true;
+ } else if (action == AMOTION_EVENT_ACTION_UP) {
+ self.touch_x = x;
+ self.touch_y = y;
+ self.touch_down = false;
+ self.queueEvent(.{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = .left,
+ .pressed = false,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ });
+ return true;
+ } else if (action == AMOTION_EVENT_ACTION_MOVE) {
+ self.touch_x = x;
+ self.touch_y = y;
+ self.queueEvent(.{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = null,
+ .pressed = false,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ });
+ return true;
+ }
+ return false;
+ }
+
+ /// Handle key events
+ fn handleKeyEvent(self: *Self, event: *AInputEvent) bool {
+ const action = AKeyEvent_getAction(event);
+ const key_code = AKeyEvent_getKeyCode(event);
+ const meta_state = AKeyEvent_getMetaState(event);
+
+ const pressed = action == AKEY_EVENT_ACTION_DOWN;
+ const key = mapAndroidKeyCode(key_code);
+
+ self.queueEvent(.{
+ .key = .{
+ .key = key,
+ .pressed = pressed,
+ .modifiers = .{
+ .ctrl = (meta_state & AMETA_CTRL_ON) != 0,
+ .shift = (meta_state & AMETA_SHIFT_ON) != 0,
+ .alt = (meta_state & AMETA_ALT_ON) != 0,
+ },
+ },
+ });
+
+ // Handle back button specially
+ if (key_code == AKEYCODE_BACK) {
+ if (action == AKEY_EVENT_ACTION_UP) {
+ self.queueEvent(.{ .quit = {} });
+ }
+ return true;
+ }
+
+ return true;
+ }
+
+ // VTable implementation
+ const vtable = Backend.VTable{
+ .pollEvent = pollEventImpl,
+ .present = presentImpl,
+ .getSize = getSizeImpl,
+ .deinit = deinitImpl,
+ };
+
+ fn pollEventImpl(ptr: *anyopaque) ?Event {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+
+ // First, process any pending Android input events
+ self.processInputEvents();
+
+ // Then return queued events
+ return self.dequeueEvent();
+ }
+
+ fn presentImpl(ptr: *anyopaque, fb: *const Framebuffer) void {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ const window = self.window orelse return;
+
+ // Lock the window buffer
+ var buffer: ANativeWindow_Buffer = undefined;
+ if (ANativeWindow_lock(window, &buffer, null) < 0) {
+ return;
+ }
+ defer _ = ANativeWindow_unlockAndPost(window);
+
+ // Copy framebuffer to window
+ // ANativeWindow uses RGBA_8888 format by default
+ const dst_pitch = @as(usize, @intCast(buffer.stride)) * 4;
+ const src_pitch = fb.width * 4;
+ const copy_width = @min(fb.width, @as(u32, @intCast(buffer.width))) * 4;
+ const copy_height = @min(fb.height, @as(u32, @intCast(buffer.height)));
+
+ const dst_base: [*]u8 = @ptrCast(buffer.bits);
+ const src_base: [*]const u8 = @ptrCast(fb.pixels);
+
+ var y: usize = 0;
+ while (y < copy_height) : (y += 1) {
+ const dst_row = dst_base + y * dst_pitch;
+ const src_row = src_base + y * src_pitch;
+ @memcpy(dst_row[0..copy_width], src_row[0..copy_width]);
+ }
+ }
+
+ fn getSizeImpl(ptr: *anyopaque) Backend.SizeResult {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ return .{ .width = self.width, .height = self.height };
+ }
+
+ fn deinitImpl(ptr: *anyopaque) void {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ self.deinit();
+ }
+};
+
+// =============================================================================
+// Key Code Mapping (Android AKEYCODE to our Key enum)
+// =============================================================================
+
+fn mapAndroidKeyCode(code: i32) Input.Key {
+ // Letters (AKEYCODE_A = 29, AKEYCODE_Z = 54)
+ if (code >= 29 and code <= 54) {
+ return @enumFromInt(@as(u8, @intCast(code - 29))); // a-z
+ }
+
+ // Numbers (AKEYCODE_0 = 7, AKEYCODE_9 = 16)
+ if (code >= 7 and code <= 16) {
+ return @enumFromInt(@as(u8, @intCast(26 + (code - 7)))); // 0-9
+ }
+
+ // Function keys (AKEYCODE_F1 = 131, AKEYCODE_F12 = 142)
+ if (code >= 131 and code <= 142) {
+ return @enumFromInt(@as(u8, @intCast(36 + (code - 131)))); // F1-F12
+ }
+
+ // Special keys
+ if (code == AKEYCODE_DEL) return .backspace;
+ if (code == AKEYCODE_TAB) return .tab;
+ if (code == AKEYCODE_ENTER) return .enter;
+ if (code == AKEYCODE_ESCAPE) return .escape;
+ if (code == AKEYCODE_SPACE) return .space;
+ if (code == AKEYCODE_DPAD_LEFT) return .left;
+ if (code == AKEYCODE_DPAD_UP) return .up;
+ if (code == AKEYCODE_DPAD_RIGHT) return .right;
+ if (code == AKEYCODE_DPAD_DOWN) return .down;
+ if (code == AKEYCODE_FORWARD_DEL) return .delete;
+ if (code == AKEYCODE_MOVE_HOME) return .home;
+ if (code == AKEYCODE_MOVE_END) return .end;
+ if (code == AKEYCODE_PAGE_UP) return .page_up;
+ if (code == AKEYCODE_PAGE_DOWN) return .page_down;
+ if (code == AKEYCODE_INSERT) return .insert;
+
+ return .unknown;
+}
+
+// =============================================================================
+// Global State
+// =============================================================================
+
+/// Global state (Android native activities are single-instance)
+var g_backend: ?*AndroidBackend = null;
+var g_allocator: std.mem.Allocator = undefined;
+
+// =============================================================================
+// Public API for app code
+// =============================================================================
+
+/// Initialize the global backend (call from ANativeActivity_onCreate)
+pub fn initGlobal(allocator: std.mem.Allocator) !*AndroidBackend {
+ g_allocator = allocator;
+
+ const be = try allocator.create(AndroidBackend);
+ be.* = try AndroidBackend.init();
+ g_backend = be;
+
+ return be;
+}
+
+/// Deinitialize the global backend
+pub fn deinitGlobal() void {
+ if (g_backend) |be| {
+ be.deinit();
+ g_allocator.destroy(be);
+ g_backend = null;
+ }
+}
+
+/// Get the global backend instance (for use by app code)
+pub fn getBackend() ?*AndroidBackend {
+ return g_backend;
+}
+
+/// Check if we should continue running
+pub fn isRunning() bool {
+ if (g_backend) |be| {
+ return be.running and be.window != null;
+ }
+ return false;
+}
+
+/// Get current window size
+pub fn getWindowSize() struct { width: u32, height: u32 } {
+ if (g_backend) |be| {
+ return .{ .width = be.width, .height = be.height };
+ }
+ return .{ .width = 0, .height = 0 };
+}
diff --git a/src/backend/backend.zig b/src/backend/backend.zig
index 76a9aa7..293fab8 100644
--- a/src/backend/backend.zig
+++ b/src/backend/backend.zig
@@ -42,6 +42,9 @@ pub const Backend = struct {
ptr: *anyopaque,
vtable: *const VTable,
+ /// Size result type (named for consistency across backends)
+ pub const SizeResult = struct { width: u32, height: u32 };
+
pub const VTable = struct {
/// Poll for events (non-blocking)
pollEvent: *const fn (ptr: *anyopaque) ?Event,
@@ -50,7 +53,7 @@ pub const Backend = struct {
present: *const fn (ptr: *anyopaque, fb: *const Framebuffer) void,
/// Get window dimensions
- getSize: *const fn (ptr: *anyopaque) struct { width: u32, height: u32 },
+ getSize: *const fn (ptr: *anyopaque) SizeResult,
/// Clean up
deinit: *const fn (ptr: *anyopaque) void,
@@ -67,7 +70,7 @@ pub const Backend = struct {
}
/// Get window size
- pub fn getSize(self: Backend) struct { width: u32, height: u32 } {
+ pub fn getSize(self: Backend) SizeResult {
return self.vtable.getSize(self.ptr);
}
diff --git a/src/backend/ios.zig b/src/backend/ios.zig
new file mode 100644
index 0000000..fde1cbd
--- /dev/null
+++ b/src/backend/ios.zig
@@ -0,0 +1,390 @@
+//! iOS Backend - UIKit based backend for iOS/iPadOS
+//!
+//! Provides window/event handling for iOS using UIKit.
+//! Uses extern functions that bridge to Objective-C/Swift code.
+//!
+//! Build target: aarch64-ios or aarch64-ios-simulator
+//!
+//! Requirements:
+//! - macOS with Xcode installed
+//! - Build with: zig build ios (creates .a static library)
+//!
+//! Integration:
+//! The resulting .a file should be linked into your Xcode iOS project.
+//! You'll need to implement the Objective-C bridge (ZcatguiBridge.m).
+
+const std = @import("std");
+const Backend = @import("backend.zig").Backend;
+const Event = @import("backend.zig").Event;
+const Input = @import("../core/input.zig");
+const Framebuffer = @import("../render/framebuffer.zig").Framebuffer;
+
+// =============================================================================
+// iOS Types
+// =============================================================================
+
+// Opaque types representing iOS objects
+pub const UIView = opaque {};
+pub const CAMetalLayer = opaque {};
+pub const UITouch = opaque {};
+pub const UIEvent = opaque {};
+
+// =============================================================================
+// Bridge Functions (implemented in Objective-C)
+// =============================================================================
+
+// These functions must be implemented in the iOS app's Objective-C bridge code.
+// They provide the connection between Zig and UIKit.
+
+/// Initialize the rendering view with given dimensions
+extern "c" fn ios_view_init(width: u32, height: u32) void;
+
+/// Get current view width
+extern "c" fn ios_view_get_width() u32;
+
+/// Get current view height
+extern "c" fn ios_view_get_height() u32;
+
+/// Present framebuffer to the view (copies RGBA pixels)
+extern "c" fn ios_view_present(pixels: [*]const u32, width: u32, height: u32) void;
+
+/// Poll for next event (returns event type, fills buffer)
+extern "c" fn ios_poll_event(buffer: [*]u8) u32;
+
+/// Log message to NSLog
+extern "c" fn ios_log(ptr: [*]const u8, len: usize) void;
+
+/// Get current time in milliseconds
+extern "c" fn ios_get_time_ms() u64;
+
+// =============================================================================
+// Event Types (must match Objective-C bridge)
+// =============================================================================
+
+pub const IOS_EVENT_NONE: u32 = 0;
+pub const IOS_EVENT_TOUCH_DOWN: u32 = 1;
+pub const IOS_EVENT_TOUCH_UP: u32 = 2;
+pub const IOS_EVENT_TOUCH_MOVE: u32 = 3;
+pub const IOS_EVENT_KEY_DOWN: u32 = 4;
+pub const IOS_EVENT_KEY_UP: u32 = 5;
+pub const IOS_EVENT_RESIZE: u32 = 6;
+pub const IOS_EVENT_QUIT: u32 = 7;
+
+// =============================================================================
+// Logging
+// =============================================================================
+
+pub fn log(comptime fmt: []const u8, args: anytype) void {
+ var buf: [1024]u8 = undefined;
+ const msg = std.fmt.bufPrint(&buf, fmt, args) catch return;
+ ios_log(msg.ptr, msg.len);
+}
+
+// =============================================================================
+// iOS Backend Implementation
+// =============================================================================
+
+pub const IosBackend = struct {
+ width: u32,
+ height: u32,
+ running: bool,
+ event_buffer: [64]u8,
+
+ // Touch state
+ touch_x: i32,
+ touch_y: i32,
+ touch_down: bool,
+
+ const Self = @This();
+
+ /// Initialize the iOS backend
+ pub fn init(width: u32, height: u32) !Self {
+ log("IosBackend.init: {}x{}", .{ width, height });
+ ios_view_init(width, height);
+
+ return Self{
+ .width = width,
+ .height = height,
+ .running = true,
+ .event_buffer = undefined,
+ .touch_x = 0,
+ .touch_y = 0,
+ .touch_down = false,
+ };
+ }
+
+ /// Get as abstract Backend interface
+ pub fn backend(self: *Self) Backend {
+ return .{
+ .ptr = self,
+ .vtable = &vtable,
+ };
+ }
+
+ /// Deinitialize
+ pub fn deinit(self: *Self) void {
+ self.running = false;
+ }
+
+ // VTable implementation
+ const vtable = Backend.VTable{
+ .pollEvent = pollEventImpl,
+ .present = presentImpl,
+ .getSize = getSizeImpl,
+ .deinit = deinitImpl,
+ };
+
+ fn pollEventImpl(ptr: *anyopaque) ?Event {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+
+ const event_type = ios_poll_event(&self.event_buffer);
+
+ return switch (event_type) {
+ IOS_EVENT_NONE => null,
+
+ IOS_EVENT_TOUCH_DOWN => blk: {
+ const x = std.mem.readInt(i32, self.event_buffer[0..4], .little);
+ const y = std.mem.readInt(i32, self.event_buffer[4..8], .little);
+ self.touch_x = x;
+ self.touch_y = y;
+ self.touch_down = true;
+ break :blk Event{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = .left,
+ .pressed = true,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ };
+ },
+
+ IOS_EVENT_TOUCH_UP => blk: {
+ const x = std.mem.readInt(i32, self.event_buffer[0..4], .little);
+ const y = std.mem.readInt(i32, self.event_buffer[4..8], .little);
+ self.touch_x = x;
+ self.touch_y = y;
+ self.touch_down = false;
+ break :blk Event{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = .left,
+ .pressed = false,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ };
+ },
+
+ IOS_EVENT_TOUCH_MOVE => blk: {
+ const x = std.mem.readInt(i32, self.event_buffer[0..4], .little);
+ const y = std.mem.readInt(i32, self.event_buffer[4..8], .little);
+ self.touch_x = x;
+ self.touch_y = y;
+ break :blk Event{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = null,
+ .pressed = false,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ };
+ },
+
+ IOS_EVENT_KEY_DOWN => blk: {
+ const key_code = self.event_buffer[0];
+ const modifiers = self.event_buffer[1];
+ const key = mapIosKeyCode(key_code);
+ break :blk Event{
+ .key = .{
+ .key = key,
+ .pressed = true,
+ .modifiers = .{
+ .ctrl = (modifiers & 1) != 0,
+ .shift = (modifiers & 2) != 0,
+ .alt = (modifiers & 4) != 0,
+ },
+ },
+ };
+ },
+
+ IOS_EVENT_KEY_UP => blk: {
+ const key_code = self.event_buffer[0];
+ const modifiers = self.event_buffer[1];
+ const key = mapIosKeyCode(key_code);
+ break :blk Event{
+ .key = .{
+ .key = key,
+ .pressed = false,
+ .modifiers = .{
+ .ctrl = (modifiers & 1) != 0,
+ .shift = (modifiers & 2) != 0,
+ .alt = (modifiers & 4) != 0,
+ },
+ },
+ };
+ },
+
+ IOS_EVENT_RESIZE => blk: {
+ const width = std.mem.readInt(u32, self.event_buffer[0..4], .little);
+ const height = std.mem.readInt(u32, self.event_buffer[4..8], .little);
+ self.width = width;
+ self.height = height;
+ break :blk Event{
+ .resize = .{
+ .width = width,
+ .height = height,
+ },
+ };
+ },
+
+ IOS_EVENT_QUIT => Event{ .quit = {} },
+
+ else => null,
+ };
+ }
+
+ fn presentImpl(ptr: *anyopaque, fb: *const Framebuffer) void {
+ _ = ptr;
+ ios_view_present(fb.pixels, fb.width, fb.height);
+ }
+
+ fn getSizeImpl(ptr: *anyopaque) Backend.SizeResult {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ // Update from iOS in case view was resized
+ self.width = ios_view_get_width();
+ self.height = ios_view_get_height();
+ return .{ .width = self.width, .height = self.height };
+ }
+
+ fn deinitImpl(ptr: *anyopaque) void {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ self.deinit();
+ }
+};
+
+// =============================================================================
+// Key Code Mapping
+// =============================================================================
+
+// iOS uses UIKeyboardHID codes for hardware keyboards
+// For on-screen keyboard, we typically get text input events instead
+
+fn mapIosKeyCode(code: u8) Input.Key {
+ return switch (code) {
+ // Letters (USB HID codes: a=4, z=29)
+ 4...29 => @enumFromInt(code - 4), // a-z
+
+ // Numbers (USB HID codes: 1=30, 0=39)
+ 30...38 => @enumFromInt(@as(u8, 26 + code - 29)), // 1-9
+ 39 => .@"0",
+
+ // Function keys (F1=58, F12=69)
+ 58...69 => @enumFromInt(@as(u8, 36 + (code - 58))), // F1-F12
+
+ // Special keys
+ 42 => .backspace,
+ 43 => .tab,
+ 40 => .enter,
+ 41 => .escape,
+ 44 => .space,
+ 80 => .left,
+ 82 => .up,
+ 79 => .right,
+ 81 => .down,
+ 76 => .delete,
+ 74 => .home,
+ 77 => .end,
+ 75 => .page_up,
+ 78 => .page_down,
+ 73 => .insert,
+
+ else => .unknown,
+ };
+}
+
+// =============================================================================
+// Global State
+// =============================================================================
+
+var g_backend: ?*IosBackend = null;
+var g_allocator: std.mem.Allocator = undefined;
+
+// =============================================================================
+// Public API
+// =============================================================================
+
+/// Initialize the global backend
+pub fn initGlobal(allocator: std.mem.Allocator, width: u32, height: u32) !*IosBackend {
+ g_allocator = allocator;
+
+ const be = try allocator.create(IosBackend);
+ be.* = try IosBackend.init(width, height);
+ g_backend = be;
+
+ return be;
+}
+
+/// Deinitialize the global backend
+pub fn deinitGlobal() void {
+ if (g_backend) |be| {
+ be.deinit();
+ g_allocator.destroy(be);
+ g_backend = null;
+ }
+}
+
+/// Get the global backend instance
+pub fn getBackend() ?*IosBackend {
+ return g_backend;
+}
+
+/// Check if we should continue running
+pub fn isRunning() bool {
+ if (g_backend) |be| {
+ return be.running;
+ }
+ return false;
+}
+
+/// Get current view size
+pub fn getViewSize() struct { width: u32, height: u32 } {
+ if (g_backend) |be| {
+ return .{ .width = be.width, .height = be.height };
+ }
+ return .{ .width = 0, .height = 0 };
+}
+
+// =============================================================================
+// Exported functions for Objective-C bridge to call
+// =============================================================================
+
+/// Called from Objective-C when the app starts
+export fn zcatgui_ios_init(width: u32, height: u32) bool {
+ const be = initGlobal(std.heap.page_allocator, width, height) catch {
+ log("Failed to init backend", .{});
+ return false;
+ };
+ _ = be;
+ return true;
+}
+
+/// Called from Objective-C to deinitialize
+export fn zcatgui_ios_deinit() void {
+ deinitGlobal();
+}
+
+/// Called from Objective-C each frame
+export fn zcatgui_ios_frame() void {
+ // This is a hook for the app to call its own frame function
+ // The actual frame logic should be in the app code
+}
+
+/// Get time in milliseconds
+pub fn getTimeMs() u64 {
+ return ios_get_time_ms();
+}
diff --git a/src/backend/sdl2.zig b/src/backend/sdl2.zig
index bc89552..d46ce39 100644
--- a/src/backend/sdl2.zig
+++ b/src/backend/sdl2.zig
@@ -208,7 +208,7 @@ pub const Sdl2Backend = struct {
}
/// Get window size
- pub fn getSize(self: *Self) struct { width: u32, height: u32 } {
+ pub fn getSize(self: *Self) Backend.Backend.SizeResult {
var w: c_int = 0;
var h: c_int = 0;
c.SDL_GetWindowSize(self.window, &w, &h);
diff --git a/src/backend/wasm.zig b/src/backend/wasm.zig
new file mode 100644
index 0000000..bab3ed2
--- /dev/null
+++ b/src/backend/wasm.zig
@@ -0,0 +1,300 @@
+//! WASM Backend - WebAssembly/Browser backend
+//!
+//! Provides window/event handling for web browsers via Canvas API.
+//! Uses extern functions that are implemented in JavaScript.
+
+const std = @import("std");
+const Backend = @import("backend.zig").Backend;
+const Event = @import("backend.zig").Event;
+const Input = @import("../core/input.zig");
+const Framebuffer = @import("../render/framebuffer.zig").Framebuffer;
+
+// =============================================================================
+// JavaScript imports (implemented in JS glue code)
+// =============================================================================
+
+extern "env" fn js_canvas_init(width: u32, height: u32) void;
+extern "env" fn js_canvas_present(pixels: [*]const u32, width: u32, height: u32) void;
+extern "env" fn js_get_canvas_width() u32;
+extern "env" fn js_get_canvas_height() u32;
+extern "env" fn js_console_log(ptr: [*]const u8, len: usize) void;
+extern "env" fn js_get_time_ms() u64;
+
+// Event queue (filled by JS)
+extern "env" fn js_poll_event(event_buffer: [*]u8) u32;
+
+// =============================================================================
+// WASM Backend Implementation
+// =============================================================================
+
+pub const WasmBackend = struct {
+ width: u32,
+ height: u32,
+ event_buffer: [64]u8 = undefined,
+
+ const Self = @This();
+
+ /// Initialize the WASM backend
+ pub fn init(width: u32, height: u32) !Self {
+ js_canvas_init(width, height);
+
+ return Self{
+ .width = width,
+ .height = height,
+ };
+ }
+
+ /// Get as abstract Backend interface
+ pub fn backend(self: *Self) Backend {
+ return .{
+ .ptr = self,
+ .vtable = &vtable,
+ };
+ }
+
+ /// Deinitialize
+ pub fn deinit(self: *Self) void {
+ _ = self;
+ // Nothing to clean up in WASM
+ }
+
+ // VTable implementation
+ const vtable = Backend.VTable{
+ .pollEvent = pollEventImpl,
+ .present = presentImpl,
+ .getSize = getSizeImpl,
+ .deinit = deinitImpl,
+ };
+
+ fn pollEventImpl(ptr: *anyopaque) ?Event {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+
+ // Poll event from JS
+ const event_type = js_poll_event(&self.event_buffer);
+
+ return switch (event_type) {
+ 0 => null, // No event
+ 1 => parseKeyEvent(&self.event_buffer, true), // Key down
+ 2 => parseKeyEvent(&self.event_buffer, false), // Key up
+ 3 => parseMouseMove(&self.event_buffer), // Mouse move
+ 4 => parseMouseButton(&self.event_buffer, true), // Mouse down
+ 5 => parseMouseButton(&self.event_buffer, false), // Mouse up
+ 6 => parseMouseWheel(&self.event_buffer), // Mouse wheel
+ 7 => parseResize(&self.event_buffer), // Resize
+ 8 => Event{ .quit = {} }, // Quit/close
+ 9 => parseTextInput(&self.event_buffer), // Text input
+ else => null,
+ };
+ }
+
+ fn presentImpl(ptr: *anyopaque, fb: *const Framebuffer) void {
+ _ = ptr;
+ js_canvas_present(fb.pixels.ptr, fb.width, fb.height);
+ }
+
+ fn getSizeImpl(ptr: *anyopaque) Backend.SizeResult {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ // Update from JS in case canvas was resized
+ self.width = js_get_canvas_width();
+ self.height = js_get_canvas_height();
+ return .{ .width = self.width, .height = self.height };
+ }
+
+ fn deinitImpl(ptr: *anyopaque) void {
+ const self: *Self = @ptrCast(@alignCast(ptr));
+ self.deinit();
+ }
+};
+
+// =============================================================================
+// Event Parsing Helpers
+// =============================================================================
+
+fn parseKeyEvent(buffer: []const u8, pressed: bool) ?Event {
+ // Buffer format: [key_code: u8, modifiers: u8]
+ const key_code = buffer[0];
+ const modifiers_byte = buffer[1];
+
+ const key = mapKeyCode(key_code) orelse return null;
+
+ return Event{
+ .key = .{
+ .key = key,
+ .pressed = pressed,
+ .modifiers = .{
+ .ctrl = (modifiers_byte & 1) != 0,
+ .shift = (modifiers_byte & 2) != 0,
+ .alt = (modifiers_byte & 4) != 0,
+ },
+ },
+ };
+}
+
+fn parseMouseMove(buffer: []const u8) ?Event {
+ // Buffer format: [x: i32 (4 bytes), y: i32 (4 bytes)]
+ const x = std.mem.readInt(i32, buffer[0..4], .little);
+ const y = std.mem.readInt(i32, buffer[4..8], .little);
+
+ return Event{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = null,
+ .pressed = false,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ };
+}
+
+fn parseMouseButton(buffer: []const u8, pressed: bool) ?Event {
+ // Buffer format: [x: i32, y: i32, button: u8]
+ const x = std.mem.readInt(i32, buffer[0..4], .little);
+ const y = std.mem.readInt(i32, buffer[4..8], .little);
+ const button_code = buffer[8];
+
+ const button: Input.MouseButton = switch (button_code) {
+ 0 => .left,
+ 1 => .middle,
+ 2 => .right,
+ else => .left,
+ };
+
+ return Event{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = button,
+ .pressed = pressed,
+ .scroll_x = 0,
+ .scroll_y = 0,
+ },
+ };
+}
+
+fn parseMouseWheel(buffer: []const u8) ?Event {
+ // Buffer format: [x: i32, y: i32, delta_x: i32, delta_y: i32]
+ const x = std.mem.readInt(i32, buffer[0..4], .little);
+ const y = std.mem.readInt(i32, buffer[4..8], .little);
+ const delta_x = std.mem.readInt(i32, buffer[8..12], .little);
+ const delta_y = std.mem.readInt(i32, buffer[12..16], .little);
+
+ return Event{
+ .mouse = .{
+ .x = x,
+ .y = y,
+ .button = null,
+ .pressed = false,
+ .scroll_x = delta_x,
+ .scroll_y = delta_y,
+ },
+ };
+}
+
+fn parseResize(buffer: []const u8) ?Event {
+ // Buffer format: [width: u32, height: u32]
+ const width = std.mem.readInt(u32, buffer[0..4], .little);
+ const height = std.mem.readInt(u32, buffer[4..8], .little);
+
+ return Event{
+ .resize = .{
+ .width = width,
+ .height = height,
+ },
+ };
+}
+
+fn parseTextInput(buffer: []const u8) ?Event {
+ // Buffer format: [len: u8, text: up to 31 bytes]
+ const len = buffer[0];
+ if (len == 0 or len > 31) return null;
+
+ var event = Event{
+ .text_input = .{
+ .text = undefined,
+ .len = len,
+ },
+ };
+
+ @memcpy(event.text_input.text[0..len], buffer[1 .. 1 + len]);
+ return event;
+}
+
+// =============================================================================
+// Key Code Mapping (JS keyCode to our Key enum)
+// =============================================================================
+
+fn mapKeyCode(code: u8) ?Input.Key {
+ return switch (code) {
+ // Letters
+ 65...90 => |c| @enumFromInt(c - 65), // A-Z -> a-z
+ // Numbers
+ 48...57 => |c| @enumFromInt(26 + (c - 48)), // 0-9
+ // Function keys
+ 112...123 => |c| @enumFromInt(36 + (c - 112)), // F1-F12
+ // Special keys
+ 8 => .backspace,
+ 9 => .tab,
+ 13 => .enter,
+ 27 => .escape,
+ 32 => .space,
+ 37 => .left,
+ 38 => .up,
+ 39 => .right,
+ 40 => .down,
+ 46 => .delete,
+ 36 => .home,
+ 35 => .end,
+ 33 => .page_up,
+ 34 => .page_down,
+ 45 => .insert,
+ else => null,
+ };
+}
+
+// =============================================================================
+// WASM Exports (called from JS)
+// =============================================================================
+
+/// Allocate memory (for JS to write event data)
+export fn wasm_alloc(size: usize) ?[*]u8 {
+ const slice = std.heap.wasm_allocator.alloc(u8, size) catch return null;
+ return slice.ptr;
+}
+
+/// Free memory
+export fn wasm_free(ptr: [*]u8, size: usize) void {
+ std.heap.wasm_allocator.free(ptr[0..size]);
+}
+
+/// Get memory for framebuffer (called once at init)
+var framebuffer_memory: ?[]u8 = null;
+
+export fn wasm_get_framebuffer_ptr(width: u32, height: u32) ?[*]u8 {
+ const size = width * height * 4;
+
+ if (framebuffer_memory) |mem| {
+ std.heap.wasm_allocator.free(mem);
+ }
+
+ framebuffer_memory = std.heap.wasm_allocator.alloc(u8, size) catch return null;
+ return framebuffer_memory.?.ptr;
+}
+
+// =============================================================================
+// Logging helper
+// =============================================================================
+
+pub fn log(comptime fmt: []const u8, args: anytype) void {
+ var buf: [1024]u8 = undefined;
+ const msg = std.fmt.bufPrint(&buf, fmt, args) catch return;
+ js_console_log(msg.ptr, msg.len);
+}
+
+// =============================================================================
+// Time helper
+// =============================================================================
+
+pub fn getTimeMs() u64 {
+ return js_get_time_ms();
+}
diff --git a/src/zcatgui.zig b/src/zcatgui.zig
index 9ce0792..d8f1a4e 100644
--- a/src/zcatgui.zig
+++ b/src/zcatgui.zig
@@ -118,9 +118,64 @@ pub const drawPolygonAA = render.antialiasing.drawPolygonAA;
// =============================================================================
// Backend
// =============================================================================
+const builtin = @import("builtin");
+
pub const backend = struct {
pub const Backend = @import("backend/backend.zig").Backend;
- pub const Sdl2Backend = @import("backend/sdl2.zig").Sdl2Backend;
+
+ // SDL2 backend (desktop only - not WASM, not Android)
+ pub const Sdl2Backend = if (builtin.cpu.arch == .wasm32 or builtin.cpu.arch == .wasm64 or builtin.os.tag == .linux and builtin.abi == .android)
+ void
+ else
+ @import("backend/sdl2.zig").Sdl2Backend;
+
+ // WASM backend
+ pub const wasm = if (builtin.cpu.arch == .wasm32 or builtin.cpu.arch == .wasm64)
+ @import("backend/wasm.zig")
+ else
+ struct {
+ pub const WasmBackend = void;
+ pub fn log(comptime fmt: []const u8, args: anytype) void {
+ _ = fmt;
+ _ = args;
+ }
+ };
+
+ // Android backend
+ pub const android = if (builtin.os.tag == .linux and builtin.abi == .android)
+ @import("backend/android.zig")
+ else
+ struct {
+ pub const AndroidBackend = void;
+ pub fn log(comptime fmt: []const u8, args: anytype) void {
+ _ = fmt;
+ _ = args;
+ }
+ pub fn getBackend() ?*AndroidBackend {
+ return null;
+ }
+ pub fn isRunning() bool {
+ return false;
+ }
+ };
+
+ // iOS backend
+ pub const ios = if (builtin.os.tag == .ios)
+ @import("backend/ios.zig")
+ else
+ struct {
+ pub const IosBackend = void;
+ pub fn log(comptime fmt: []const u8, args: anytype) void {
+ _ = fmt;
+ _ = args;
+ }
+ pub fn getBackend() ?*IosBackend {
+ return null;
+ }
+ pub fn isRunning() bool {
+ return false;
+ }
+ };
};
// =============================================================================
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..c8f6e5c
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+ zcatgui - WASM Demo
+
+
+
+ zcatgui WASM Demo
+
+
+
+
+ Click canvas to focus. Use Tab to navigate, Enter to activate.
+
+
+
+
+
+
diff --git a/web/zcatgui-demo.wasm b/web/zcatgui-demo.wasm
new file mode 100755
index 0000000..1c362f2
Binary files /dev/null and b/web/zcatgui-demo.wasm differ
diff --git a/web/zcatgui.js b/web/zcatgui.js
new file mode 100644
index 0000000..2bba949
--- /dev/null
+++ b/web/zcatgui.js
@@ -0,0 +1,325 @@
+/**
+ * zcatgui WASM Glue Code
+ *
+ * Provides the JavaScript side of the WASM backend:
+ * - Canvas management
+ * - Event handling (keyboard, mouse)
+ * - Framebuffer presentation
+ */
+
+class ZcatguiRuntime {
+ constructor(canvasId) {
+ this.canvas = document.getElementById(canvasId);
+ if (!this.canvas) {
+ throw new Error(`Canvas with id '${canvasId}' not found`);
+ }
+ this.ctx = this.canvas.getContext('2d');
+ this.imageData = null;
+ this.wasm = null;
+ this.memory = null;
+
+ // Event queue
+ this.eventQueue = [];
+ this.maxEvents = 256;
+
+ // Setup event listeners
+ this.setupEventListeners();
+ }
+
+ /**
+ * Load and initialize the WASM module
+ */
+ async load(wasmPath) {
+ const importObject = {
+ env: {
+ js_canvas_init: (width, height) => this.canvasInit(width, height),
+ js_canvas_present: (pixelsPtr, width, height) => this.canvasPresent(pixelsPtr, width, height),
+ js_get_canvas_width: () => this.canvas.width,
+ js_get_canvas_height: () => this.canvas.height,
+ js_console_log: (ptr, len) => this.consoleLog(ptr, len),
+ js_get_time_ms: () => BigInt(performance.now() | 0),
+ js_poll_event: (bufferPtr) => this.pollEvent(bufferPtr),
+ },
+ };
+
+ const response = await fetch(wasmPath);
+ const bytes = await response.arrayBuffer();
+ const result = await WebAssembly.instantiate(bytes, importObject);
+
+ this.wasm = result.instance;
+ this.memory = this.wasm.exports.memory;
+
+ return this.wasm;
+ }
+
+ /**
+ * Initialize canvas
+ */
+ canvasInit(width, height) {
+ this.canvas.width = width;
+ this.canvas.height = height;
+ this.imageData = this.ctx.createImageData(width, height);
+ console.log(`zcatgui: Canvas initialized ${width}x${height}`);
+ }
+
+ /**
+ * Present framebuffer to canvas
+ */
+ canvasPresent(pixelsPtr, width, height) {
+ if (!this.imageData || this.imageData.width !== width || this.imageData.height !== height) {
+ this.imageData = this.ctx.createImageData(width, height);
+ }
+
+ // Copy RGBA pixels from WASM memory to ImageData
+ // Pixels are stored as u32 (RGBA packed), need to read as Uint32Array then convert
+ const pixels32 = new Uint32Array(this.memory.buffer, pixelsPtr, width * height);
+ const pixels8 = this.imageData.data;
+
+ // Convert from RGBA u32 to separate R, G, B, A bytes
+ for (let i = 0; i < pixels32.length; i++) {
+ const pixel = pixels32[i];
+ const offset = i * 4;
+ // RGBA format (assuming little-endian)
+ pixels8[offset + 0] = pixel & 0xFF; // R
+ pixels8[offset + 1] = (pixel >> 8) & 0xFF; // G
+ pixels8[offset + 2] = (pixel >> 16) & 0xFF; // B
+ pixels8[offset + 3] = (pixel >> 24) & 0xFF; // A
+ }
+
+ this.ctx.putImageData(this.imageData, 0, 0);
+ }
+
+ /**
+ * Log to console from WASM
+ */
+ consoleLog(ptr, len) {
+ const bytes = new Uint8Array(this.memory.buffer, ptr, len);
+ const text = new TextDecoder().decode(bytes);
+ console.log(`[zcatgui] ${text}`);
+ }
+
+ /**
+ * Poll event from queue
+ * Returns event type and writes data to buffer
+ */
+ pollEvent(bufferPtr) {
+ if (this.eventQueue.length === 0) {
+ return 0; // No event
+ }
+
+ const event = this.eventQueue.shift();
+ const buffer = new Uint8Array(this.memory.buffer, bufferPtr, 64);
+
+ switch (event.type) {
+ case 'keydown':
+ buffer[0] = event.keyCode;
+ buffer[1] = this.getModifiers(event);
+ return 1;
+
+ case 'keyup':
+ buffer[0] = event.keyCode;
+ buffer[1] = this.getModifiers(event);
+ return 2;
+
+ case 'mousemove':
+ this.writeI32(buffer, 0, event.x);
+ this.writeI32(buffer, 4, event.y);
+ return 3;
+
+ case 'mousedown':
+ this.writeI32(buffer, 0, event.x);
+ this.writeI32(buffer, 4, event.y);
+ buffer[8] = event.button;
+ return 4;
+
+ case 'mouseup':
+ this.writeI32(buffer, 0, event.x);
+ this.writeI32(buffer, 4, event.y);
+ buffer[8] = event.button;
+ return 5;
+
+ case 'wheel':
+ this.writeI32(buffer, 0, event.x);
+ this.writeI32(buffer, 4, event.y);
+ this.writeI32(buffer, 8, event.deltaX);
+ this.writeI32(buffer, 12, event.deltaY);
+ return 6;
+
+ case 'resize':
+ this.writeU32(buffer, 0, event.width);
+ this.writeU32(buffer, 4, event.height);
+ return 7;
+
+ case 'quit':
+ return 8;
+
+ case 'textinput':
+ const encoded = new TextEncoder().encode(event.text);
+ buffer[0] = Math.min(encoded.length, 31);
+ buffer.set(encoded.slice(0, 31), 1);
+ return 9;
+
+ default:
+ return 0;
+ }
+ }
+
+ /**
+ * Setup DOM event listeners
+ */
+ setupEventListeners() {
+ // Keyboard events
+ document.addEventListener('keydown', (e) => {
+ if (this.shouldCaptureKey(e)) {
+ e.preventDefault();
+ this.queueEvent({
+ type: 'keydown',
+ keyCode: e.keyCode,
+ ctrlKey: e.ctrlKey,
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ });
+
+ // Also queue text input for printable characters
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey) {
+ this.queueEvent({
+ type: 'textinput',
+ text: e.key,
+ });
+ }
+ }
+ });
+
+ document.addEventListener('keyup', (e) => {
+ if (this.shouldCaptureKey(e)) {
+ e.preventDefault();
+ this.queueEvent({
+ type: 'keyup',
+ keyCode: e.keyCode,
+ ctrlKey: e.ctrlKey,
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ });
+ }
+ });
+
+ // Mouse events
+ this.canvas.addEventListener('mousemove', (e) => {
+ const rect = this.canvas.getBoundingClientRect();
+ this.queueEvent({
+ type: 'mousemove',
+ x: Math.floor(e.clientX - rect.left),
+ y: Math.floor(e.clientY - rect.top),
+ });
+ });
+
+ this.canvas.addEventListener('mousedown', (e) => {
+ const rect = this.canvas.getBoundingClientRect();
+ this.queueEvent({
+ type: 'mousedown',
+ x: Math.floor(e.clientX - rect.left),
+ y: Math.floor(e.clientY - rect.top),
+ button: e.button,
+ });
+ });
+
+ this.canvas.addEventListener('mouseup', (e) => {
+ const rect = this.canvas.getBoundingClientRect();
+ this.queueEvent({
+ type: 'mouseup',
+ x: Math.floor(e.clientX - rect.left),
+ y: Math.floor(e.clientY - rect.top),
+ button: e.button,
+ });
+ });
+
+ this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const rect = this.canvas.getBoundingClientRect();
+ this.queueEvent({
+ type: 'wheel',
+ x: Math.floor(e.clientX - rect.left),
+ y: Math.floor(e.clientY - rect.top),
+ deltaX: Math.sign(e.deltaX) * 3,
+ deltaY: Math.sign(e.deltaY) * 3,
+ });
+ }, { passive: false });
+
+ // Resize observer
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ if (entry.target === this.canvas) {
+ this.queueEvent({
+ type: 'resize',
+ width: entry.contentRect.width,
+ height: entry.contentRect.height,
+ });
+ }
+ }
+ });
+ resizeObserver.observe(this.canvas);
+
+ // Prevent context menu
+ this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+
+ // Focus canvas for keyboard input
+ this.canvas.tabIndex = 0;
+ this.canvas.focus();
+ }
+
+ /**
+ * Check if we should capture this key event
+ */
+ shouldCaptureKey(e) {
+ // Always capture when canvas is focused
+ if (document.activeElement === this.canvas) {
+ return true;
+ }
+ // Capture arrow keys, space, etc. even if not focused
+ const captureKeys = [37, 38, 39, 40, 32, 9, 27]; // arrows, space, tab, escape
+ return captureKeys.includes(e.keyCode);
+ }
+
+ /**
+ * Get modifier flags
+ */
+ getModifiers(e) {
+ let mods = 0;
+ if (e.ctrlKey) mods |= 1;
+ if (e.shiftKey) mods |= 2;
+ if (e.altKey) mods |= 4;
+ return mods;
+ }
+
+ /**
+ * Queue an event
+ */
+ queueEvent(event) {
+ if (this.eventQueue.length < this.maxEvents) {
+ this.eventQueue.push(event);
+ }
+ }
+
+ /**
+ * Write i32 to buffer (little-endian)
+ */
+ writeI32(buffer, offset, value) {
+ const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4);
+ view.setInt32(0, value, true);
+ }
+
+ /**
+ * Write u32 to buffer (little-endian)
+ */
+ writeU32(buffer, offset, value) {
+ const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4);
+ view.setUint32(0, value, true);
+ }
+}
+
+// Export for use
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { ZcatguiRuntime };
+} else {
+ window.ZcatguiRuntime = ZcatguiRuntime;
+}