From 03452860b667f85ac576d251b56e88d2245eb296 Mon Sep 17 00:00:00 2001 From: Jeff Hodges Date: Sat, 11 Jul 2026 14:24:46 -0700 Subject: [PATCH] Add GPU surface magnify input --- build.zig | 7 +++++++ changelog.d/gpu-surface-magnify.md | 1 + docs/src/app/platform-support/page.mdx | 4 ++-- src/embed/host.zig | 2 +- src/embed/tests.zig | 25 +++++++++++++++++++++++- src/platform/linux/root.zig | 11 +++++++++++ src/platform/macos/appkit_host.h | 3 +++ src/platform/macos/appkit_host.m | 12 ++++++++++++ src/platform/macos/root.zig | 27 ++++++++++++++++++++++++++ src/platform/types.zig | 7 +++++++ src/platform/windows/root.zig | 11 +++++++++++ src/runtime/canvas_frame_helpers.zig | 17 ++++++++++++++++ 12 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 changelog.d/gpu-surface-magnify.md diff --git a/build.zig b/build.zig index 829e6f10..a8de7caf 100644 --- a/build.zig +++ b/build.zig @@ -692,6 +692,13 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "_surfaceCursor = cursor ?: [NSCursor arrowCursor]" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSCursor pointingHandCursor" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-magnify-input", "Verify AppKit GPU surfaces forward trackpad magnify gestures", &.{ + .{ .path = "src/platform/macos/appkit_host.h", .pattern = "NATIVE_SDK_APPKIT_GPU_INPUT_MAGNIFY = 12" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (void)magnifyWithEvent:(NSEvent *)event" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NATIVE_SDK_APPKIT_GPU_INPUT_MAGNIFY" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "deltaY:event.magnification" }, + .{ .path = "src/platform/macos/root.zig", .pattern = "12 => .magnify" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-accessibility-actions", "Verify AppKit GPU widget accessibility actions route to the runtime", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "accessibilityPerformPress" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "emitWidgetAccessibilityActionWithId" }, diff --git a/changelog.d/gpu-surface-magnify.md b/changelog.d/gpu-surface-magnify.md new file mode 100644 index 00000000..ac3a6e72 --- /dev/null +++ b/changelog.d/gpu-surface-magnify.md @@ -0,0 +1 @@ +feature: **macOS trackpad magnify on GPU surfaces**: pinch gestures arrive as `GpuSurfaceInputKind.magnify` with cursor-local coordinates and incremental `NSEvent.magnification` in `delta_y` (positive zooms in). diff --git a/docs/src/app/platform-support/page.mdx b/docs/src/app/platform-support/page.mdx index bf10dfc7..49bb741d 100644 --- a/docs/src/app/platform-support/page.mdx +++ b/docs/src/app/platform-support/page.mdx @@ -40,7 +40,7 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts Pointer, keyboard & IME input - + @@ -391,7 +391,7 @@ Mobile hosts own safe areas, orientation, keyboard avoidance, back gestures, and Canvas-scene apps also run through a hand-written embed host: `addMobileLib` compiles a `Model`/`Msg`/`update`/`view` app into the embed static library with a single `gpu_surface` scene, frames render through the deterministic CPU reference renderer, and the host shim blits the presented pixels (`native_sdk_app_render_pixels`) into its own surface. The `examples/mobile-canvas` iOS shim — the same architecture the toolkit's own UIKit host uses — is exercised on the simulator: rendering, safe-area layout, real touch/keyboard/IME input, and accessibility snapshots. The Android `NativeActivity` shim cross-compiles for both Android arches from the same library build. -`gpu_surface` is implemented for the macOS system-WebView host as a Metal-backed child surface and for the Linux and Windows system-WebView hosts as a software-rendered (CPU reference renderer + GTK pixel blit on Linux, GDI DIB blit on Windows) child surface; Linux and Windows frame events report `backend=software`, and a manifest that declares another backend (for example `gpu_backend = "metal"`) falls back to software on those hosts instead of erroring. Windows maps native IME composition (`WM_IME_COMPOSITION`) onto the same shared IME events the macOS and Linux hosts emit — inline preedit, cursor position, and the commit contract — with real-hardware IME verification still pending. Other current hosts report unsupported operations for that view kind and `runtime.supports(.gpu_surfaces)` / `window.zero.platform.supports("gpuSurfaces")` return `false`. +`gpu_surface` is implemented for the macOS system-WebView host as a Metal-backed child surface and for the Linux and Windows system-WebView hosts as a software-rendered (CPU reference renderer + GTK pixel blit on Linux, GDI DIB blit on Windows) child surface; Linux and Windows frame events report `backend=software`, and a manifest that declares another backend (for example `gpu_backend = "metal"`) falls back to software on those hosts instead of erroring. Windows maps native IME composition (`WM_IME_COMPOSITION`) onto the same shared IME events the macOS and Linux hosts emit — inline preedit, cursor position, and the commit contract — with real-hardware IME verification still pending. On macOS, trackpad pinch gestures arrive as `GpuSurfaceInputKind.magnify` with cursor-local coordinates and incremental `NSEvent.magnification` in `delta_y` (positive zooms in); other hosts do not synthesize magnify. Other current hosts report unsupported operations for that view kind and `runtime.supports(.gpu_surfaces)` / `window.zero.platform.supports("gpuSurfaces")` return `false`. ## Related Docs diff --git a/src/embed/host.zig b/src/embed/host.zig index f25608b6..cd78e027 100644 --- a/src/embed/host.zig +++ b/src/embed/host.zig @@ -843,7 +843,7 @@ pub const MobileHostApp = struct { self.last_input_composition_cursor = input.composition_cursor; self.last_input_modifiers = input.modifiers; switch (input.kind) { - .pointer_down, .pointer_up, .pointer_cancel, .pointer_move, .pointer_drag, .scroll => { + .pointer_down, .pointer_up, .pointer_cancel, .pointer_move, .pointer_drag, .scroll, .magnify => { self.touch_count += 1; self.last_touch_id = input.pointer_id; self.last_touch_kind = input.kind; diff --git a/src/embed/tests.zig b/src/embed/tests.zig index 58363fc7..fa2257b0 100644 --- a/src/embed/tests.zig +++ b/src/embed/tests.zig @@ -220,8 +220,31 @@ test "mobile C ABI forwards surface resize and touch input" { try std.testing.expectEqual(@as(f32, 0), self.last_touch_pressure); try std.testing.expectEqualStrings("", std.mem.span(native_sdk_app_last_error_name(app))); + // Magnify is a desktop (macOS) kind; the embed host still records it + // when the runtime delivers one so shared GpuSurfaceInputKind handling + // stays exhaustive across hosts. + try self.embedded.runtime.dispatchPlatformEvent(self.embedded.app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = mobile_gpu_surface_label, + .kind = .magnify, + .timestamp_ns = 9_000_000, + .pointer_id = 7, + .x = 40, + .y = 50, + .delta_y = 0.08, + } }); + try std.testing.expectEqual(@as(usize, 5), self.touch_count); + try std.testing.expectEqual(@as(usize, 5), self.input_count); + try std.testing.expectEqual(platform.GpuSurfaceInputKind.magnify, self.last_touch_kind); + try std.testing.expectEqual(platform.GpuSurfaceInputKind.magnify, self.last_input_kind); + try std.testing.expectEqual(@as(u64, 7), self.last_touch_id); + try std.testing.expectEqual(@as(f32, 40), self.last_touch_x); + try std.testing.expectEqual(@as(f32, 50), self.last_touch_y); + try std.testing.expectEqual(@as(f32, 0), self.last_touch_delta_x); + try std.testing.expectEqual(@as(f32, 0.08), self.last_touch_delta_y); + native_sdk_app_touch(app, 42, 99, 13, 25, 0); - try std.testing.expectEqual(@as(usize, 4), self.touch_count); + try std.testing.expectEqual(@as(usize, 5), self.touch_count); try std.testing.expectEqualStrings("InvalidTouchPhase", std.mem.span(native_sdk_app_last_error_name(app))); } diff --git a/src/platform/linux/root.zig b/src/platform/linux/root.zig index d4f87ebd..3ca42af6 100644 --- a/src/platform/linux/root.zig +++ b/src/platform/linux/root.zig @@ -571,6 +571,7 @@ fn gpuSurfaceInputKindFromInt(value: c_int) platform_mod.GpuSurfaceInputKind { 9 => .ime_commit_composition, 10 => .ime_cancel_composition, 11 => .pointer_cancel, + 12 => .magnify, // macOS-synthesized; Linux does not emit magnify else => .pointer_move, }; } @@ -1409,6 +1410,16 @@ test "linux gpu surface input maps pointer cancel" { try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pointer_cancel, gpuSurfaceInputEventFromGtkEvent(&event).kind); } +test "linux gpu surface input maps magnify abi ordinal without synthesizing" { + // Shared ABI ordinal 12 (macOS magnify); Linux does not emit it. + var event = std.mem.zeroes(GtkEvent); + event.input_kind = 12; + event.delta_y = -0.02; + const input = gpuSurfaceInputEventFromGtkEvent(&event); + try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.magnify, input.kind); + try std.testing.expectEqual(@as(f32, -0.02), input.delta_y); +} + test "linux gpu surface input maps ime text and composition events" { const preedit = "e\xcc\x81"; // "é" as e + combining acute var set_event = std.mem.zeroes(GtkEvent); diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index f004aa90..5e033785 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -76,6 +76,9 @@ typedef enum { NATIVE_SDK_APPKIT_GPU_INPUT_IME_COMMIT_COMPOSITION = 9, NATIVE_SDK_APPKIT_GPU_INPUT_IME_CANCEL_COMPOSITION = 10, NATIVE_SDK_APPKIT_GPU_INPUT_POINTER_CANCEL = 11, + /* Trackpad pinch: cursor-local x/y, incremental NSEvent.magnification + * in delta_y (positive = zoom in). Appended so existing ordinals stay. */ + NATIVE_SDK_APPKIT_GPU_INPUT_MAGNIFY = 12, } native_sdk_appkit_gpu_input_kind_t; typedef enum { diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index f28452a3..5b28d186 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -5530,6 +5530,18 @@ - (void)scrollWheel:(NSEvent *)event { [self queueScrollInputEvent:event deltaX:-event.scrollingDeltaX deltaY:-event.scrollingDeltaY]; } +- (void)magnifyWithEvent:(NSEvent *)event { + // Pinch-to-zoom: forward cursor-local coordinates and the incremental + // magnification factor in delta_y (NSEvent.magnification; positive = + // zoom in). delta_x stays 0 — see GpuSurfaceInputEvent.delta_y. + if (event.magnification == 0) return; + [self emitInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_MAGNIFY + event:event + button:0 + deltaX:0 + deltaY:event.magnification]; +} + - (void)keyDown:(NSEvent *)event { if ([self focusedTextAccessibilityElement]) { self.interpretedKeyEventEmittedInput = NO; diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 83b579bb..9a6e819f 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -1813,6 +1813,7 @@ fn gpuSurfaceInputKindFromInt(value: c_int) platform_mod.GpuSurfaceInputKind { 9 => .ime_commit_composition, 10 => .ime_cancel_composition, 11 => .pointer_cancel, + 12 => .magnify, else => .pointer_move, }; } @@ -2242,6 +2243,32 @@ test "mac gpu surface input maps pointer cancel" { try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pointer_cancel, input.kind); } +test "mac gpu surface input maps magnify with incremental magnification in delta_y" { + const label = "canvas"; + var event = std.mem.zeroes(AppKitEvent); + event.window_id = 3; + event.view_label = label.ptr; + event.view_label_len = label.len; + event.input_kind = 12; + event.timestamp_ns = 456_000_000; + event.x = 120; + event.y = 80; + event.delta_x = 0; + event.delta_y = 0.05; + event.shortcut_modifiers = shortcut_modifier_option; + + const input = gpuSurfaceInputEventFromAppKitEvent(&event); + try std.testing.expectEqual(@as(platform_mod.WindowId, 3), input.window_id); + try std.testing.expectEqualStrings("canvas", input.label); + try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.magnify, input.kind); + try std.testing.expectEqual(@as(u64, 456_000_000), input.timestamp_ns); + try std.testing.expectEqual(@as(f32, 120), input.x); + try std.testing.expectEqual(@as(f32, 80), input.y); + try std.testing.expectEqual(@as(f32, 0), input.delta_x); + try std.testing.expectEqual(@as(f32, 0.05), input.delta_y); + try std.testing.expect(input.modifiers.option); +} + test "mac appearance event maps color scheme" { try std.testing.expectEqual(platform_mod.ColorScheme.light, appKitColorScheme(0)); try std.testing.expectEqual(platform_mod.ColorScheme.dark, appKitColorScheme(1)); diff --git a/src/platform/types.zig b/src/platform/types.zig index 69acc8b9..11b38bd3 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1550,6 +1550,9 @@ pub const GpuSurfaceInputKind = enum { ime_set_composition, ime_commit_composition, ime_cancel_composition, + /// Trackpad pinch / magnify gesture (macOS AppKit `-magnifyWithEvent:`). + /// Host ABI ordinal is 12; see `GpuSurfaceInputEvent.delta_y`. + magnify, }; pub const GpuSurfaceInputEvent = struct { @@ -1563,6 +1566,10 @@ pub const GpuSurfaceInputEvent = struct { button: i32 = 0, pressure: f32 = 0, delta_x: f32 = 0, + /// For `.scroll`, the wheel/trackpad scroll delta in canvas points. + /// For `.magnify`, the incremental magnification factor from the host + /// (`NSEvent.magnification` on macOS): positive zooms in, negative + /// zooms out. `delta_x` is 0 for magnify. delta_y: f32 = 0, key: []const u8 = "", text: []const u8 = "", diff --git a/src/platform/windows/root.zig b/src/platform/windows/root.zig index 734ee044..b25dbf3b 100644 --- a/src/platform/windows/root.zig +++ b/src/platform/windows/root.zig @@ -564,6 +564,7 @@ fn gpuSurfaceInputKindFromInt(value: c_int) platform_mod.GpuSurfaceInputKind { 9 => .ime_commit_composition, 10 => .ime_cancel_composition, 11 => .pointer_cancel, + 12 => .magnify, // macOS-synthesized; Windows does not emit magnify else => .pointer_move, }; } @@ -1385,6 +1386,16 @@ test "windows gpu surface input maps pointer cancel" { try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pointer_cancel, gpuSurfaceInputEventFromWindowsEvent(&event).kind); } +test "windows gpu surface input maps magnify abi ordinal without synthesizing" { + // Shared ABI ordinal 12 (macOS magnify); Windows does not emit it. + var event = std.mem.zeroes(WindowsEvent); + event.input_kind = 12; + event.delta_y = 0.1; + const input = gpuSurfaceInputEventFromWindowsEvent(&event); + try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.magnify, input.kind); + try std.testing.expectEqual(@as(f32, 0.1), input.delta_y); +} + test "windows gpu surface input maps ime text and composition events" { // A GCS_COMPSTR preedit update: kind 8 carries the full preedit text // plus a UTF-8 byte cursor (the host converts GCS_CURSORPOS UTF-16 diff --git a/src/runtime/canvas_frame_helpers.zig b/src/runtime/canvas_frame_helpers.zig index e17bcfd0..6d599af0 100644 --- a/src/runtime/canvas_frame_helpers.zig +++ b/src/runtime/canvas_frame_helpers.zig @@ -140,6 +140,7 @@ pub fn canvasWidgetPointerEventFromGpuInput(input_event: GpuSurfaceInputEvent) ? .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, + .magnify, => return null, }; return .{ @@ -163,6 +164,7 @@ pub fn canvasWidgetInputBatchesDisplayListRefresh(kind: platform.GpuSurfaceInput .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, + .magnify, => true, }; } @@ -181,6 +183,7 @@ pub fn canvasWidgetKeyboardEventFromGpuInput(input_event: GpuSurfaceInputEvent, .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, + .magnify, => return null, }; return .{ @@ -221,6 +224,7 @@ fn canvasWidgetTextEditEventFromGpuInput(input_event: GpuSurfaceInputEvent) ?can .pointer_move, .pointer_drag, .scroll, + .magnify, => null, }; } @@ -412,3 +416,16 @@ pub fn gpuSurfaceFrameEventFromGpuFrame(frame: platform.GpuFrame) platform.GpuSu .widget_semantics_count = frame.widget_semantics_count, }; } + +test "magnify gpu input is not a widget pointer or keyboard event" { + const input = GpuSurfaceInputEvent{ + .label = "canvas", + .kind = .magnify, + .x = 10, + .y = 20, + .delta_y = 0.05, + }; + try std.testing.expect(canvasWidgetPointerEventFromGpuInput(input) == null); + try std.testing.expect(canvasWidgetKeyboardEventFromGpuInput(input, 1) == null); + try std.testing.expect(canvasWidgetInputBatchesDisplayListRefresh(.magnify)); +}