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));
+}