From 018a67aa8d8dbe6276d048ae0f472d80be65adb5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 00:56:19 -0500 Subject: [PATCH 01/14] Add trackpad pinch input vocabulary, macOS host emission, and journal v5 - GpuSurfaceInputKind gains phase-explicit pinch_begin/pinch_change/pinch_end and GpuSurfaceInputEvent a scale field carrying the per-event magnification delta (NSEvent.magnification semantics: cumulative scale is the product of 1 + delta); Windows/GTK sources are staged follow-ups noted at the vocabulary. - The AppKit host implements magnifyWithEvent: with begin/changed mapping directly, cancelled folding into pinch_end (deltas are applied as they arrive, so there is no transient to roll back), and change deltas emitted uncoalesced because summing coalesced deltas would drift from the product. - Bump the session journal to v5: gpu-surface input records gain the scale field (a layout change a v4 reader would misparse) plus the pinch kind codes 12-14, with the round-trip test pinning both. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- src/platform/macos/appkit_host.h | 7 +++ src/platform/macos/appkit_host.m | 67 ++++++++++++++++++++++++++++ src/platform/macos/root.zig | 6 +++ src/platform/types.zig | 44 ++++++++++++++++++ src/runtime/canvas_frame_helpers.zig | 15 +++++++ src/runtime/session_journal.zig | 33 ++++++++++++-- 6 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index f004aa90..ab423d81 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -76,6 +76,13 @@ 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 phases (magnifyWithEvent:). The event's `scale` + * field carries the per-event magnification DELTA on PINCH_CHANGE + * (NSEvent.magnification semantics; cumulative gesture scale is the + * product of (1 + delta)); 0 on begin/end. Centroid rides x/y. */ + NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN = 12, + NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE = 13, + NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END = 14, } 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..fb28bd7b 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -569,6 +569,7 @@ @interface NativeSdkMetalSurfaceView : NSView @property(nonatomic, assign) double pendingScrollDriverOffsetY; @property(nonatomic, assign) uint64_t scrollDriverEventLastEmitNs; @property(nonatomic, assign) BOOL controlClickActive; +@property(nonatomic, assign) BOOL pinchGestureActive; - (void)configureWithHost:(NativeSdkAppKitHost *)host windowId:(uint64_t)windowId label:(NSString *)label; - (BOOL)isAvailable; - (void)updateDrawableSize; @@ -613,6 +614,7 @@ - (void)queuePointerMotionInputEvent:(NSEvent *)event kind:(NSInteger)kind butto - (void)emitQueuedPointerMotionInputEvent; - (void)queueScrollInputEvent:(NSEvent *)event deltaX:(double)deltaX deltaY:(double)deltaY; - (void)emitQueuedScrollInputEvent; +- (void)emitPinchInputEventWithKind:(NSInteger)kind event:(NSEvent *)event magnification:(double)magnification; - (void)emitInputEventWithKind:(NSInteger)kind point:(NSPoint)point timestampNs:(uint64_t)timestampNs modifiers:(uint32_t)modifiers keyText:(NSString *)keyText inputText:(NSString *)inputText button:(NSInteger)button deltaX:(double)deltaX deltaY:(double)deltaY; - (void)emitSyntheticKeyDownWithKey:(NSString *)key modifiers:(uint32_t)modifiers; - (void)updateSurfaceTrackingArea; @@ -5530,6 +5532,47 @@ - (void)scrollWheel:(NSEvent *)event { [self queueScrollInputEvent:event deltaX:-event.scrollingDeltaX deltaY:-event.scrollingDeltaY]; } +- (void)magnifyWithEvent:(NSEvent *)event { + // Trackpad pinch, phase-explicit. A cancelled gesture folds into + // PINCH_END: pinch delivers incremental deltas the app applies as + // they arrive, so there is no un-committed transient to roll back + // the way pointer_cancel rolls back an in-flight press — + // cancellation just means "the delta stream stops here". + // + // Change deltas emit uncoalesced (no per-frame queue like scroll): + // the cumulative gesture scale is the PRODUCT of (1 + delta), and + // summing coalesced deltas would drift from what the fingers did. + const NSEventPhase phase = event.phase; + if (phase & NSEventPhaseBegan) { + [self emitQueuedPointerMotionInputEvent]; + self.pinchGestureActive = YES; + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; + if (event.magnification != 0) { + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:event.magnification]; + } + return; + } + if (phase & NSEventPhaseChanged) { + if (!self.pinchGestureActive) { + // A gesture already in flight when this view became the + // hit target skips Began; open the stream cleanly anyway. + self.pinchGestureActive = YES; + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; + } + if (event.magnification != 0) { + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:event.magnification]; + } + return; + } + if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) { + if (!self.pinchGestureActive) return; + self.pinchGestureActive = NO; + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0]; + return; + } + // NSEventPhaseMayBegin / stationary phases carry no gesture fact. +} + - (void)keyDown:(NSEvent *)event { if ([self focusedTextAccessibilityElement]) { self.interpretedKeyEventEmittedInput = NO; @@ -5710,6 +5753,30 @@ - (void)emitQueuedScrollInputEvent { deltaY:deltaY]; } +// Pinch input: the shared input-emit shape (converted point, top-left +// origin flip, host timestamp, modifier flags) with the magnification +// delta riding the event's `scale` field — unused (zero) on every other +// input kind, so the runtime reads it unconditionally. +- (void)emitPinchInputEventWithKind:(NSInteger)kind event:(NSEvent *)event magnification:(double)magnification { + if (!self.host || self.surfaceLabel.length == 0 || !event) return; + NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; + CGFloat y = self.bounds.size.height - point.y; + const char *labelBytes = self.surfaceLabel.UTF8String ?: ""; + [self.host emitEvent:(native_sdk_appkit_event_t){ + .kind = NATIVE_SDK_APPKIT_EVENT_GPU_SURFACE_INPUT, + .window_id = self.windowId, + .timestamp_ns = NativeSdkTimestampNanoseconds(), + .x = point.x, + .y = y, + .scale = magnification, + .view_label = labelBytes, + .view_label_len = [self.surfaceLabel lengthOfBytesUsingEncoding:NSUTF8StringEncoding], + .shortcut_modifiers = NativeSdkModifierFlagsForEvent(event), + .input_kind = (int)kind, + }]; + [self requestRetainedCanvasFrame]; +} + // --- Native scroll drivers --------------------------------------------- // Each scrollable canvas region gets an invisible NSScrollView subview // sized to the region and backed by a flipped document view sized to the diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 83b579bb..5adf1573 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -906,6 +906,9 @@ fn gpuSurfaceInputEventFromAppKitEvent(event: *const AppKitEvent) platform_mod.G .text = event.input_text[0..event.input_text_len], .composition_cursor = if (event.has_composition_cursor != 0) event.composition_cursor else null, .modifiers = shortcutModifiersFromFlags(event.shortcut_modifiers), + // The pinch magnification delta rides the ABI event's `scale` + // field (zero on every non-pinch input emission). + .scale = @floatCast(event.scale), }; } @@ -1813,6 +1816,9 @@ fn gpuSurfaceInputKindFromInt(value: c_int) platform_mod.GpuSurfaceInputKind { 9 => .ime_commit_composition, 10 => .ime_cancel_composition, 11 => .pointer_cancel, + 12 => .pinch_begin, + 13 => .pinch_change, + 14 => .pinch_end, else => .pointer_move, }; } diff --git a/src/platform/types.zig b/src/platform/types.zig index 8fbd0fa8..e5185998 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1550,6 +1550,16 @@ pub const GpuSurfaceInputKind = enum { ime_set_composition, ime_commit_composition, ime_cancel_composition, + // Trackpad gestures are phase-explicit (every source models phases: + // NSEvent.phase on macOS, GTK GestureZoom begin/scale-changed/end, + // Windows precision-touchpad gesture messages), and the family stays + // open for rotate/smart-zoom later. Only the macOS host emits these + // today; Windows (precision touchpad) and Linux (GTK GestureZoom) + // are staged follow-ups — on those platforms the kinds simply never + // arrive. + pinch_begin, + pinch_change, + pinch_end, }; pub const GpuSurfaceInputEvent = struct { @@ -1568,6 +1578,40 @@ pub const GpuSurfaceInputEvent = struct { text: []const u8 = "", composition_cursor: ?usize = null, modifiers: ShortcutModifiers = .{}, + /// Pinch magnification DELTA for this event (NSEvent.magnification + /// semantics): nonzero only on `pinch_change`, 0 on begin/end. The + /// cumulative gesture scale is the running product of `(1 + scale)` + /// across the gesture's change events. The gesture centroid rides + /// `x`/`y` (view-local, same space as pointer events). + scale: f32 = 0, +}; + +/// Pinch gesture phase for the app-level pinch channel (`Options.on_pinch` +/// / the TS core's `pinchMsg` export). Every source is phase-explicit +/// (NSEvent phases, GTK GestureZoom, Windows precision-touchpad gestures); +/// a host-cancelled gesture folds into `.end` — pinch delivers incremental +/// deltas the app applies as they arrive, so there is no transient state +/// to roll back the way `pointer_cancel` rolls back an in-flight press. +pub const PinchPhase = enum { + begin, + change, + end, +}; + +/// The pinch channel's app-facing record, derived from the raw +/// `gpu_surface_input` pinch events. Pinch bypasses the widget pipeline +/// deliberately: it is a view-global gesture (timeline/canvas zoom), the +/// `on_key`-fallback shape rather than a widget-targeted event. +pub const PinchEvent = struct { + phase: PinchPhase, + /// Magnification DELTA for this event (NSEvent.magnification + /// semantics): nonzero only on `.change`, 0 on begin/end. The + /// cumulative gesture scale is the running product of `(1 + scale)` + /// across the gesture's change events. + scale: f32 = 0, + /// Gesture centroid, view-local canvas points (the pointer-event space). + x: f32 = 0, + y: f32 = 0, }; /// Upper bound on native scroll drivers per gpu-surface view (one per diff --git a/src/runtime/canvas_frame_helpers.zig b/src/runtime/canvas_frame_helpers.zig index e17bcfd0..076619f1 100644 --- a/src/runtime/canvas_frame_helpers.zig +++ b/src/runtime/canvas_frame_helpers.zig @@ -140,6 +140,12 @@ pub fn canvasWidgetPointerEventFromGpuInput(input_event: GpuSurfaceInputEvent) ? .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, + // Pinch is not a widget pointer gesture: it reaches the app as + // the raw `gpu_surface_input` event (timeline/canvas zoom is an + // app-level concern, not a widget press/scroll). + .pinch_begin, + .pinch_change, + .pinch_end, => return null, }; return .{ @@ -163,6 +169,9 @@ pub fn canvasWidgetInputBatchesDisplayListRefresh(kind: platform.GpuSurfaceInput .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, + .pinch_begin, + .pinch_change, + .pinch_end, => true, }; } @@ -181,6 +190,9 @@ pub fn canvasWidgetKeyboardEventFromGpuInput(input_event: GpuSurfaceInputEvent, .ime_set_composition, .ime_commit_composition, .ime_cancel_composition, + .pinch_begin, + .pinch_change, + .pinch_end, => return null, }; return .{ @@ -221,6 +233,9 @@ fn canvasWidgetTextEditEventFromGpuInput(input_event: GpuSurfaceInputEvent) ?can .pointer_move, .pointer_drag, .scroll, + .pinch_begin, + .pinch_change, + .pinch_end, => null, }; } diff --git a/src/runtime/session_journal.zig b/src/runtime/session_journal.zig index acff69ad..e5934a9d 100644 --- a/src/runtime/session_journal.zig +++ b/src/runtime/session_journal.zig @@ -46,7 +46,7 @@ //! //! Coverage note: automation driving is fully journaled. Verbs that //! synthesize PLATFORM events (widget-click/hold/context-press/drag/ -//! wheel/key, resize, menu-command, shortcut, tray-action) journal +//! wheel/key/pinch, resize, menu-command, shortcut, tray-action) journal //! those events; every widget-action verb — including the direct- //! surface ones (focus, select, set_selection, set_text, and the //! composition edits) — journals as the outer-wins @@ -75,8 +75,11 @@ pub const magic = "NSDKSJNL"; /// composition verbs (`set_composition`/`commit_composition`/ /// `cancel_composition`, codes 11-13) to the accessibility-action /// record's verb enum — a v3 reader hitting one of those codes would -/// have reported the file corrupt instead of refusing the skew. -pub const format_version: u32 = 4; +/// have reported the file corrupt instead of refusing the skew; v5 +/// added the pinch magnification `scale` field to gpu-surface input +/// records (a layout change every v4 reader would misparse) plus the +/// `pinch_begin`/`pinch_change`/`pinch_end` input kinds (codes 12-14). +pub const format_version: u32 = 5; // ------------------------------------------------------------- budgets // @@ -530,6 +533,8 @@ pub fn encodeEvent(event: platform.Event, buffer: []u8) JournalError![]const u8 try cursor.writeInt(u64, @intCast(composition_cursor)); } try writeModifiers(&cursor, input.modifiers); + // v5: the pinch magnification delta (0 on non-pinch kinds). + try cursor.writeF32(input.scale); }, .gpu_surface_scroll_driver => |driver| { try cursor.writeEnum(EventTag.gpu_surface_scroll_driver); @@ -756,6 +761,8 @@ pub fn decodeEvent(bytes: []const u8, storage: *EventDecodeStorage) JournalError .text = text, .composition_cursor = composition_cursor, .modifiers = try readModifiers(&cursor), + // v5: the pinch magnification delta (0 on non-pinch kinds). + .scale = try cursor.readF32(), } }; }, .gpu_surface_scroll_driver => blk: { @@ -1230,6 +1237,26 @@ test "event codec round-trips every payload variant" { try testing.expectEqualStrings("7", decoded.gpu_surface_input.text); try testing.expectEqual(@as(?usize, 3), decoded.gpu_surface_input.composition_cursor); try testing.expect(decoded.gpu_surface_input.modifiers.control); + try testing.expectEqual(@as(f32, 0), decoded.gpu_surface_input.scale); + } + { + // v5: pinch records carry the magnification delta and the + // gesture kinds appended at codes 12-14. + const decoded = try roundTripEvent(.{ .gpu_surface_input = .{ + .label = "timeline-canvas", + .kind = .pinch_change, + .timestamp_ns = 6, + .x = 160, + .y = 120, + .scale = 0.25, + } }); + try testing.expectEqual(platform.GpuSurfaceInputKind.pinch_change, decoded.gpu_surface_input.kind); + try testing.expectEqual(@as(f32, 0.25), decoded.gpu_surface_input.scale); + try testing.expectEqual(@as(f32, 160), decoded.gpu_surface_input.x); + const begin = try roundTripEvent(.{ .gpu_surface_input = .{ .label = "timeline-canvas", .kind = .pinch_begin } }); + try testing.expectEqual(platform.GpuSurfaceInputKind.pinch_begin, begin.gpu_surface_input.kind); + const end = try roundTripEvent(.{ .gpu_surface_input = .{ .label = "timeline-canvas", .kind = .pinch_end } }); + try testing.expectEqual(platform.GpuSurfaceInputKind.pinch_end, end.gpu_surface_input.kind); } { const decoded = try roundTripEvent(.{ .widget_accessibility_action = .{ From c37759462ea5e0c79dc88ea5506241a4e6c0cb4a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 00:56:31 -0500 Subject: [PATCH 02/14] Deliver pinch to app cores through the on_pinch/pinchMsg channel - Zig cores gain Options.on_pinch (the on_key shape): pinch is view-global, so the raw journaled gpu_surface_input pinch kinds surface as a phase-explicit PinchEvent with the change delta and view-local centroid, and the Msg dispatch replays from the same journaled event. - TS cores gain the pinchMsg(pinch) export: the wiring adapter maps the named begin/change/end phase alias by member name and widens the numbers, with the record shape validated at transpile time (NS1033) and mirrored in @native-sdk/core/events as PinchPhase/PinchEvent. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- packages/core/sdk/events.ts | 26 +++++++++++++++++++-- packages/core/src/checker.ts | 2 +- packages/core/src/emitter.ts | 38 +++++++++++++++++++++++++++--- src/platform/root.zig | 2 ++ src/root.zig | 2 ++ src/runtime/ts_ui_app.zig | 45 ++++++++++++++++++++++++++++++++++++ src/runtime/ui_app.zig | 41 ++++++++++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 6 deletions(-) diff --git a/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index 8b6cbe76..61a13eb8 100644 --- a/packages/core/sdk/events.ts +++ b/packages/core/sdk/events.ts @@ -26,8 +26,9 @@ // - `TextInputEvent`, `ScrollState`: Msg-arm PAYLOAD fields — // `{ kind: "draft_edit"; edit: TextInputEvent }`, // `{ kind: "scrolled"; scroll: ScrollState }`. -// - `FrameEvent`, `KeyEvent`: the wiring channels' parameter records — -// `frameMsg(model, frame: FrameEvent)`, `keyMsg(key: KeyEvent)`. +// - `FrameEvent`, `KeyEvent`, `PinchEvent`: the wiring channels' +// parameter records — `frameMsg(model, frame: FrameEvent)`, +// `keyMsg(key: KeyEvent)`, `pinchMsg(pinch: PinchEvent)`. // - `ColorScheme`, `ChromeInsets`, `ChromeButtons`, `AudioState`: field // types INSIDE the arm records the `appearanceMsg`/`chromeMsg`/audio // routes name (the arms themselves stay inline unions of `kind` plus @@ -72,6 +73,27 @@ export interface KeyEvent { readonly super: boolean; } +/// The pinch phase vocabulary — a NAMED begin/change/end alias (the host +/// matches enum members by name, so this is the `phase` field's type in +/// `pinchMsg`'s parameter record). A host-cancelled gesture folds into +/// "end": pinch delivers incremental deltas the app applies as they +/// arrive, so there is no transient state to roll back. +export type PinchPhase = "begin" | "change" | "end"; + +/// The pinch channel's record (`pinchMsg(pinch)`): the trackpad pinch +/// gesture, phase-explicit. `scale` is the magnification DELTA for this +/// event (nonzero only on "change"; the cumulative gesture scale is the +/// running product of `1 + scale`), and `x`/`y` is the gesture centroid +/// in view-local canvas points. Pinch is a view-global gesture — it never +/// routes through widgets — so this is the honest home for timeline and +/// canvas zoom. Only hosts with a pinch source emit it (macOS today). +export interface PinchEvent { + readonly phase: PinchPhase; + readonly scale: number; + readonly x: number; + readonly y: number; +} + /// The appearance vocabulary — a NAMED light/dark alias (the host matches /// enum members by name, so this is the `colorScheme` field's type in the /// arm `appearanceMsg` names). diff --git a/packages/core/src/checker.ts b/packages/core/src/checker.ts index e2646028..800621d0 100644 --- a/packages/core/src/checker.ts +++ b/packages/core/src/checker.ts @@ -795,7 +795,7 @@ export class SubsetChecker { /// entry points, but the exports themselves live in the entry module. private static readonly entryOnlyExports = new Set([ "update", "initialModel", "subscriptions", - "commandMsg", "keyMsg", "frameMsg", "appearanceMsg", "chromeMsg", "envMsgs", + "commandMsg", "keyMsg", "frameMsg", "pinchMsg", "appearanceMsg", "chromeMsg", "envMsgs", "viewUnbound", ]); diff --git a/packages/core/src/emitter.ts b/packages/core/src/emitter.ts index adaa2da9..e495818a 100644 --- a/packages/core/src/emitter.ts +++ b/packages/core/src/emitter.ts @@ -1171,7 +1171,7 @@ export class Emitter { } /// Validate the generated-wiring channel exports (`appearanceMsg`, - /// `chromeMsg`, `frameMsg`, `keyMsg`; `envMsgs` validates during its own + /// `chromeMsg`, `frameMsg`, `keyMsg`, `pinchMsg`; `envMsgs` validates during its own /// emission and `commandMsg` needs only its fn form): the wiring builds /// these host events structurally from the declarations, so a wrong /// shape must be a teaching NS1033 at check time — transpile-clean @@ -1252,7 +1252,7 @@ export class Emitter { } if (ts.isFunctionDeclaration(stmt) && stmt.name && this.isExportedDecl(stmt)) { const name = stmt.name.text; - if (name !== "frameMsg" && name !== "keyMsg") continue; + if (name !== "frameMsg" && name !== "keyMsg" && name !== "pinchMsg") continue; const returnsMsgOrNull = ((): boolean => { if (!stmt.type) return false; const t = this.table.resolveTypeNode(stmt.type); @@ -1281,7 +1281,7 @@ export class Emitter { "NS1033", ); } - } else { + } else if (name === "keyMsg") { const paramT = stmt.parameters.length === 1 && stmt.parameters[0].type ? this.table.resolveTypeNode(stmt.parameters[0].type!) : null; @@ -1300,6 +1300,38 @@ export class Emitter { "NS1033", ); } + } else { + // pinchMsg: { phase: , + // scale, x, y } — matched by NAME (the wiring maps the phase + // enum by member name and widens the numbers). + const paramT = stmt.parameters.length === 1 && stmt.parameters[0].type + ? this.table.resolveTypeNode(stmt.parameters[0].type!) + : null; + const fields = paramT ? recordFields(paramT) : null; + const phase = fields ? fieldNamed(fields, "phase") : undefined; + const phaseEnum = phase && phase.type.k === "enum" ? this.table.enums.get(phase.type.name) : null; + const phaseOk = + phaseEnum !== null && + phaseEnum !== undefined && + phaseEnum.members.length === 3 && + phaseEnum.members.includes("begin") && + phaseEnum.members.includes("change") && + phaseEnum.members.includes("end"); + const ok = + fields !== null && + fields.length === 4 && + phaseOk && + (["scale", "x", "y"] as const).every((n) => { + const f = fieldNamed(fields, n); + return f !== undefined && isNum(f.type.k); + }); + if (!ok) { + this.fail( + stmt, + '`pinchMsg` takes one `PinchEvent` parameter, exactly `{ phase: PinchPhase; scale: number; x: number; y: number }` where PinchPhase is a named "begin" | "change" | "end" alias', + "NS1033", + ); + } } } } diff --git a/src/platform/root.zig b/src/platform/root.zig index c8bdad32..26ae6c68 100644 --- a/src/platform/root.zig +++ b/src/platform/root.zig @@ -139,6 +139,8 @@ pub const GpuSurfaceFrameEvent = types.GpuSurfaceFrameEvent; pub const GpuSurfaceResizeEvent = types.GpuSurfaceResizeEvent; pub const GpuSurfaceInputKind = types.GpuSurfaceInputKind; pub const GpuSurfaceInputEvent = types.GpuSurfaceInputEvent; +pub const PinchPhase = types.PinchPhase; +pub const PinchEvent = types.PinchEvent; pub const max_gpu_surface_scroll_drivers = types.max_gpu_surface_scroll_drivers; pub const GpuSurfaceScrollDriver = types.GpuSurfaceScrollDriver; pub const GpuSurfaceScrollDriverEvent = types.GpuSurfaceScrollDriverEvent; diff --git a/src/root.zig b/src/root.zig index 8b815c10..6d000481 100644 --- a/src/root.zig +++ b/src/root.zig @@ -147,6 +147,8 @@ pub const GpuSurfaceFrameEvent = platform.GpuSurfaceFrameEvent; pub const GpuSurfaceResizeEvent = platform.GpuSurfaceResizeEvent; pub const GpuSurfaceInputKind = platform.GpuSurfaceInputKind; pub const GpuSurfaceInputEvent = platform.GpuSurfaceInputEvent; +pub const PinchPhase = platform.PinchPhase; +pub const PinchEvent = platform.PinchEvent; pub const FileFilter = platform.FileFilter; pub const OpenDialogOptions = platform.OpenDialogOptions; pub const OpenDialogResult = platform.OpenDialogResult; diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index 8eddc80d..a4b0782d 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -193,6 +193,12 @@ pub fn TsUiApp(comptime core: type) type { } stamped.on_key = keyMsgAdapter; } + if (comptime @hasDecl(core, "pinchMsg")) { + if (options.on_pinch != null) { + @panic("TsUiApp wires on_pinch from the core's pinchMsg export - remove the wiring's on_pinch"); + } + stamped.on_pinch = pinchMsgAdapter; + } if (comptime @hasDecl(core, "appearanceMsg")) { if (options.on_appearance != null) { @panic("TsUiApp wires on_appearance from the core's appearanceMsg export - remove the wiring's on_appearance"); @@ -360,6 +366,45 @@ pub fn TsUiApp(comptime core: type) type { return core.keyMsg(arg); } + /// `Options.on_pinch` over the core's `pinchMsg(pinch)` export: + /// the emitted PinchEvent record — `phase` (the declared + /// begin/change/end alias, matched by member name), `scale` (the + /// magnification DELTA on "change"; cumulative gesture scale is + /// the product of `1 + scale`), and the `x`/`y` centroid in + /// view-local canvas points. The core's return gates the channel + /// exactly like a Zig `on_pinch` (null drops the event). + fn pinchMsgAdapter(pinch: platform.PinchEvent) ?Msg { + const params = @typeInfo(@TypeOf(core.pinchMsg)).@"fn".params; + if (comptime params.len != 1) { + @compileError("TsUiApp: pinchMsg must take one PinchEvent parameter - regenerate the core"); + } + const PinchArg = params[0].type.?; + comptime { + const fields = @typeInfo(PinchArg).@"struct".fields; + if (fields.len != 4 or !@hasField(PinchArg, "phase") or !@hasField(PinchArg, "scale") or + !@hasField(PinchArg, "x") or !@hasField(PinchArg, "y")) + { + @compileError("TsUiApp: pinchMsg's PinchEvent must be exactly { phase: \"begin\" | \"change\" | \"end\"; scale: number; x: number; y: number }"); + } + const Phase = @FieldType(PinchArg, "phase"); + const phase_info = @typeInfo(Phase); + if (phase_info != .@"enum" or phase_info.@"enum".fields.len != 3 or + !@hasField(Phase, "begin") or !@hasField(Phase, "change") or !@hasField(Phase, "end")) + { + @compileError("TsUiApp: pinchMsg's PinchEvent.phase must be the named \"begin\" | \"change\" | \"end\" alias"); + } + } + var arg: PinchArg = undefined; + const Phase = @FieldType(PinchArg, "phase"); + arg.phase = switch (pinch.phase) { + inline else => |phase| @field(Phase, @tagName(phase)), + }; + arg.scale = channelNum(@FieldType(PinchArg, "scale"), pinch.scale); + arg.x = channelNum(@FieldType(PinchArg, "x"), pinch.x); + arg.y = channelNum(@FieldType(PinchArg, "y"), pinch.y); + return core.pinchMsg(arg); + } + /// `Options.on_appearance` over the core's `appearanceMsg` arm /// export: the appearance record — `colorScheme` (a declared /// light/dark enum, matched by member name), `reduceMotion`, diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 004701cc..fa19717f 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -537,6 +537,18 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// can never steal typing — a fallback that yields to every /// consuming widget can carry them safely. on_key: ?*const fn (keyboard: canvas.WidgetKeyboardEvent) ?MsgT = null, + /// Optional mapping from trackpad pinch gestures into + /// messages — the app-level gesture channel (the `on_key` + /// shape). Pinch deliberately bypasses the widget pipeline: + /// it is a view-global gesture (timeline/canvas zoom is an + /// app-level concern), delivered phase-explicit (`begin`, + /// `change`, `end`) with the per-event magnification DELTA + /// on `change` — the cumulative gesture scale is the running + /// product of `(1 + scale)` — and the centroid in view-local + /// canvas points. Only hosts with a pinch source emit these + /// (macOS today); everywhere else the channel simply never + /// fires. + on_pinch: ?*const fn (pinch: platform.PinchEvent) ?MsgT = null, /// Optional mapping from runtime timer events (started via /// `runtime.startTimer`) into messages. Framework-reserved timer /// ids (>= `platform.reserved_timer_id_base`) are handled @@ -2770,6 +2782,10 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .canvas_widget_change => |change_event| try self.handleWidgetChange(runtime, change_event), .window_closed => |closed| try self.handleWindowClosed(runtime, closed), .automation_provenance => |query| try self.handleProvenanceQuery(runtime, query), + // Raw gpu-surface input stays runtime-internal EXCEPT the + // pinch kinds, which surface through the app-level pinch + // channel (widget routing never claims a pinch). + .gpu_surface_input => |input_event| try self.handlePinch(runtime, input_event), else => {}, } } @@ -3343,6 +3359,31 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe } } + /// Trackpad pinch reaches the app as the view-global gesture + /// channel (`Options.on_pinch`): only the pinch kinds of the raw + /// gpu-surface input surface here — every other raw input kind + /// stays runtime-internal (widgets and the semantic Msg surface + /// already carry it). The Msg dispatch rides the same journaled + /// input event, so a recorded pinch replays to the identical + /// model. + fn handlePinch(self: *Self, runtime: *Runtime, input_event: platform.GpuSurfaceInputEvent) anyerror!void { + const map = self.options.on_pinch orelse return; + const phase: platform.PinchPhase = switch (input_event.kind) { + .pinch_begin => .begin, + .pinch_change => .change, + .pinch_end => .end, + else => return, + }; + if (map(.{ + .phase = phase, + .scale = input_event.scale, + .x = input_event.x, + .y = input_event.y, + })) |msg| { + try self.dispatch(runtime, input_event.window_id, msg); + } + } + /// Split-fraction changes route through the split's `on_resize` /// constructor. The payload is the fraction the runtime already /// applied, so a model that stores it and echoes it back into From c69a99c27dc4751e9690780d9bd48ed8825288db Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 00:56:41 -0500 Subject: [PATCH 03/14] Add the widget-pinch automation verb driving real pinch platform events - widget-pinch [x y] synthesizes pinch_begin, one pinch_change carrying scale - 1 (the product of 1 + delta lands exactly on the commanded cumulative scale), and pinch_end at the given or view-center centroid. - Plain input synthesis per the widget-key discipline: every event journals as itself and replays through the same dispatch - pinch is not a widget verb, so no accessibility-action record. - Bump the automation protocol to v7 for the new verb string. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- src/automation/protocol.zig | 20 +++++++++- src/runtime/automation_commands.zig | 31 +++++++++++++++ src/runtime/automation_widget_dispatch.zig | 45 ++++++++++++++++++++++ src/runtime/flow.zig | 5 +++ tools/native-sdk/automation.zig | 6 +++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/automation/protocol.zig b/src/automation/protocol.zig index 03d69eb5..1cf4400f 100644 --- a/src/automation/protocol.zig +++ b/src/automation/protocol.zig @@ -34,7 +34,10 @@ pub const max_command_bytes: usize = 16 * 1024 + 64; /// never touches (same loud timeout) — and both directions are refused /// up front by this handshake whenever a live snapshot exists. /// Snapshots without a `protocol=` field predate the handshake entirely. -pub const version: u32 = 6; +/// 7 = the `widget-pinch` verb (drive a trackpad pinch gesture as the +/// real begin/change/end platform input events, journaled like every +/// synthesized input). +pub const version: u32 = 7; /// How many commands may sit in the dropbox queue at once. Automation /// drivers are scripts, not firehoses: the app drains one command per @@ -93,6 +96,13 @@ pub const Action = enum { widget_drag, widget_wheel, widget_key, + /// `widget-pinch [ ]`: drive a trackpad + /// pinch gesture against a gpu-surface view — the real + /// `pinch_begin`/`pinch_change`/`pinch_end` platform events, with + /// one change carrying `scale - 1` so the cumulative gesture scale + /// (the product of `1 + delta`) lands exactly on ``. The + /// centroid defaults to the view center. + widget_pinch, menu_command, shortcut, tray_action, @@ -133,6 +143,7 @@ pub const Command = struct { if (std.mem.eql(u8, action_text, "widget-drag") and value.len > 0) return .{ .action = .widget_drag, .value = value }; if (std.mem.eql(u8, action_text, "widget-wheel") and value.len > 0) return .{ .action = .widget_wheel, .value = value }; if (std.mem.eql(u8, action_text, "widget-key") and value.len > 0) return .{ .action = .widget_key, .value = value }; + if (std.mem.eql(u8, action_text, "widget-pinch") and value.len > 0) return .{ .action = .widget_pinch, .value = value }; if (std.mem.eql(u8, action_text, "menu-command") and value.len > 0) return .{ .action = .menu_command, .value = value }; if (std.mem.eql(u8, action_text, "shortcut") and value.len > 0) return .{ .action = .shortcut, .value = value }; if (std.mem.eql(u8, action_text, "tray-action") and value.len > 0) return .{ .action = .tray_action, .value = value }; @@ -226,6 +237,13 @@ test "commands parse reload and wait" { const widget_key = try Command.parse("widget-key canvas tab"); try std.testing.expectEqual(Action.widget_key, widget_key.action); try std.testing.expectEqualStrings("canvas tab", widget_key.value); + const widget_pinch = try Command.parse("widget-pinch canvas 1.5"); + try std.testing.expectEqual(Action.widget_pinch, widget_pinch.action); + try std.testing.expectEqualStrings("canvas 1.5", widget_pinch.value); + const widget_pinch_at = try Command.parse("widget-pinch canvas 0.5 120 80"); + try std.testing.expectEqual(Action.widget_pinch, widget_pinch_at.action); + try std.testing.expectEqualStrings("canvas 0.5 120 80", widget_pinch_at.value); + try std.testing.expectError(error.InvalidCommand, Command.parse("widget-pinch")); const menu_command = try Command.parse("menu-command app.refresh"); try std.testing.expectEqual(Action.menu_command, menu_command.action); const shortcut = try Command.parse("shortcut app.refresh"); diff --git a/src/runtime/automation_commands.zig b/src/runtime/automation_commands.zig index 059d951f..58ebdfae 100644 --- a/src/runtime/automation_commands.zig +++ b/src/runtime/automation_commands.zig @@ -58,6 +58,19 @@ pub const AutomationWidgetKey = struct { modifiers: AutomationKeyModifiers = .{}, }; +/// `widget-pinch [ ]`: a trackpad pinch +/// gesture against a gpu-surface view. `scale` is the CUMULATIVE gesture +/// scale (1.5 zooms in 50%, 0.5 zooms out to half) — the dispatch +/// synthesizes begin, one change carrying `scale - 1`, and end, so the +/// product of `(1 + delta)` lands exactly on `scale`. The optional +/// centroid is view-local canvas points; omitted, it defaults to the +/// view center. +pub const AutomationWidgetPinch = struct { + view_label: []const u8, + scale: f32, + point: ?geometry.PointF = null, +}; + /// Modifier chord for automation key dispatch, mirroring /// `platform.ShortcutModifiers` field-for-field (automation stays /// platform-agnostic; the dispatch layer copies these across). @@ -261,6 +274,24 @@ pub fn parseAutomationWidgetKey(value: []const u8) !AutomationWidgetKey { return .{ .view_label = view.token, .key = chord.key, .text = text, .modifiers = chord.modifiers }; } +pub fn parseAutomationWidgetPinch(value: []const u8) !AutomationWidgetPinch { + const view = takeAutomationToken(value) orelse return error.InvalidCommand; + const scale_part = takeAutomationToken(view.rest) orelse return error.InvalidCommand; + const scale = std.fmt.parseFloat(f32, scale_part.token) catch return error.InvalidCommand; + // A non-positive cumulative scale is not a gesture two fingers can + // perform (the product of 1 + delta stays positive); refuse loudly. + if (!std.math.isFinite(scale) or scale <= 0) return error.InvalidCommand; + const x_part = takeAutomationToken(scale_part.rest) orelse { + return .{ .view_label = view.token, .scale = scale }; + }; + const y_part = takeAutomationToken(x_part.rest) orelse return error.InvalidCommand; + if (takeAutomationToken(y_part.rest) != null) return error.InvalidCommand; + const x = std.fmt.parseFloat(f32, x_part.token) catch return error.InvalidCommand; + const y = std.fmt.parseFloat(f32, y_part.token) catch return error.InvalidCommand; + if (!std.math.isFinite(x) or !std.math.isFinite(y)) return error.InvalidCommand; + return .{ .view_label = view.token, .scale = scale, .point = geometry.PointF.init(x, y) }; +} + pub fn parseAutomationWidgetPointerDrag(value: []const u8) !AutomationWidgetPointerDrag { const view = takeAutomationToken(value) orelse return error.InvalidCommand; const id_part = takeAutomationToken(view.rest) orelse return error.InvalidCommand; diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 777119c3..6846d3f7 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -284,6 +284,51 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { } }); } + /// Drive a trackpad pinch through the real platform-event path: + /// `pinch_begin`, one `pinch_change` carrying `scale - 1` (so + /// the cumulative product of `1 + delta` lands exactly on the + /// commanded scale), and `pinch_end`, all at the same centroid. + /// Plain input synthesis, the `widget-key` discipline: every + /// event journals as itself and replays through the same + /// dispatch — no accessibility-action record, because pinch is + /// not a widget verb (it never routes into the widget tree; the + /// app hears it through the pinch channel). + pub fn dispatchAutomationWidgetPinch(self: *Runtime, app: runtime_api.App(Runtime), pinch: automation_commands.AutomationWidgetPinch) anyerror!void { + const view_index = try automationGpuSurfaceViewIndexByLabel(self, pinch.view_label); + const window_id = self.views[view_index].window_id; + const label = self.views[view_index].label; + const point = pinch.point orelse geometry.PointF.init( + self.views[view_index].gpu_size.width / 2, + self.views[view_index].gpu_size.height / 2, + ); + const timestamp_ns = automationInputTimestampNs(); + try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = window_id, + .label = label, + .kind = .pinch_begin, + .timestamp_ns = timestamp_ns, + .x = point.x, + .y = point.y, + } }); + try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = window_id, + .label = label, + .kind = .pinch_change, + .timestamp_ns = timestamp_ns, + .x = point.x, + .y = point.y, + .scale = pinch.scale - 1, + } }); + try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = window_id, + .label = label, + .kind = .pinch_end, + .timestamp_ns = timestamp_ns, + .x = point.x, + .y = point.y, + } }); + } + pub fn dispatchAutomationWidgetPointerDrag(self: *Runtime, app: runtime_api.App(Runtime), drag: AutomationWidgetPointerDrag) anyerror!void { const view_index = try automationWidgetTargetViewIndex(self, drag.target); const layout = self.views[view_index].widgetLayoutTree(); diff --git a/src/runtime/flow.zig b/src/runtime/flow.zig index 1388d128..7aaa963b 100644 --- a/src/runtime/flow.zig +++ b/src/runtime/flow.zig @@ -39,6 +39,7 @@ const parseAutomationProvenanceTarget = automation_commands.parseAutomationProve const parseAutomationWidgetWheel = automation_commands.parseAutomationWidgetWheel; const parseAutomationWidgetContextMenuItem = automation_commands.parseAutomationWidgetContextMenuItem; const parseAutomationWidgetKey = automation_commands.parseAutomationWidgetKey; +const parseAutomationWidgetPinch = automation_commands.parseAutomationWidgetPinch; const parseAutomationWidgetPointerDrag = automation_commands.parseAutomationWidgetPointerDrag; const parseAutomationResizeCommand = automation_commands.parseAutomationResizeCommand; const parseAutomationTrayItemId = automation_commands.parseAutomationTrayItemId; @@ -816,6 +817,7 @@ pub fn RuntimeFlow(comptime Runtime: type) type { .widget_drag => "automation.widget_drag", .widget_wheel => "automation.widget_wheel", .widget_key => "automation.widget_key", + .widget_pinch => "automation.widget_pinch", .tray_action => "automation.tray_action", .provenance => "automation.provenance", else => "automation.command", @@ -877,6 +879,9 @@ pub fn RuntimeFlow(comptime Runtime: type) type { .widget_key => { try AutomationWidgetMethods().dispatchAutomationWidgetKeyInput(self, app, try parseAutomationWidgetKey(command.value)); }, + .widget_pinch => { + try AutomationWidgetMethods().dispatchAutomationWidgetPinch(self, app, try parseAutomationWidgetPinch(command.value)); + }, .menu_command => { try dispatchPlatformEvent(self, app, .{ .menu_command = .{ .name = try parseAutomationCommandName(command.value), diff --git a/tools/native-sdk/automation.zig b/tools/native-sdk/automation.zig index b8416b47..bcf48aa8 100644 --- a/tools/native-sdk/automation.zig +++ b/tools/native-sdk/automation.zig @@ -99,6 +99,11 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, environ_map: *std.process.E const value = try std.mem.join(allocator, " ", args[1..]); defer allocator.free(value); try sendCommand(allocator, io, "widget-key", value); + } else if (std.mem.eql(u8, command, "widget-pinch")) { + if (args.len != 3 and args.len != 5) return usage(); + const value = try std.mem.join(allocator, " ", args[1..]); + defer allocator.free(value); + try sendCommand(allocator, io, "widget-pinch", value); } else if (std.mem.eql(u8, command, "shortcut")) { if (args.len != 2) return usage(); try sendCommand(allocator, io, "shortcut", args[1]); @@ -447,6 +452,7 @@ fn printUsage() void { \\ widget-drag [start-y-ratio end-y-ratio] \\ widget-wheel \\ widget-key [text] + \\ widget-pinch [x y] (trackpad pinch: cumulative scale, e.g. 1.5 zooms in; centroid defaults to the view center) \\ shortcut \\ tray-action (status-item dropdown row; snapshots print tray-item #id) \\ focus From 4568bfe24b07a0a64c6bc65b678be2b0f4204418 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:32:22 -0500 Subject: [PATCH 04/14] Class the pinch record's fields as boundary floats in the transpiler - Magnification deltas are ~0.01..0.3 per event and the centroid is sub-point, so pinchMsg's parameter record fields are host values, never provable integers: without boundary marking, a core's pinch.scale === 0 comparison int-claimed the slot and every zoom product rounded to whole numbers. - frameMsg/keyMsg records keep their historical by-usage classing; the emitter test pins the f64 emission and the NS1033 shape refusal. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- packages/core/src/infer.ts | 26 ++++++++++++++++++ packages/core/test/emitter.test.ts | 43 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/core/src/infer.ts b/packages/core/src/infer.ts index 7a6d9925..0ef8ba89 100644 --- a/packages/core/src/infer.ts +++ b/packages/core/src/infer.ts @@ -502,6 +502,32 @@ export class IntInference { slot.proven = false; } } + // The pinch channel's parameter record is intrinsically + // fractional: magnification deltas are ~0.01..0.3 per event and + // the centroid is sub-point, so its number fields are HOST + // values, never provable integers. Marking them boundary-fed + // keeps a core's `pinch.scale === 0` comparison from + // int-claiming the slot (which would round every zoom product + // to whole numbers); the integer side of such a comparison + // widens to f64 instead. frameMsg/keyMsg records keep their + // historical by-usage classing. + if (stmt.name?.text === "pinchMsg") { + for (const p of stmt.parameters) { + if (!p.type) continue; + const t = this.table.resolveTypeNode(p.type); + if (t.k !== "struct") continue; + const struct = this.table.structs.get(t.name); + if (!struct) continue; + for (const f of struct.fields) { + const slot = this.slots.get(f.decl); + if (slot) { + slot.external = true; + slot.hostBoundary = true; + slot.proven = false; + } + } + } + } } } } diff --git a/packages/core/test/emitter.test.ts b/packages/core/test/emitter.test.ts index 6774f74f..516d0621 100644 --- a/packages/core/test/emitter.test.ts +++ b/packages/core/test/emitter.test.ts @@ -1706,3 +1706,46 @@ export function update(model: Model, msg: Msg): Model { assert.equal(bad_key.ok, false); assert.ok(bad_key.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(bad_key.diagnostics)); }); + +test("pinchMsg emits with boundary-float record fields and holds its channel contract", () => { + // The good shape: the record's number fields are HOST values classed + // f64 even against an integer-literal comparison (`scale === 0` must + // never int-claim the slot — a rounded magnification delta would zero + // every zoom product), and the zoom chain fed from them floats too. + const zig = emit(` +export type PinchPhase = "begin" | "change" | "end"; +export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; readonly x: number; readonly y: number; } +export interface Model { readonly zoom: number; } +export type Msg = + | { readonly kind: "zoomed"; readonly factor: number } + | { readonly kind: "noop" }; +export function pinchMsg(pinch: PinchEvent): Msg | null { + if (pinch.phase !== "change" || pinch.scale === 0) return null; + return { kind: "zoomed", factor: 1 + pinch.scale }; +} +export function initialModel(): Model { return { zoom: 1 }; } +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { + case "zoomed": return { ...model, zoom: model.zoom * msg.factor }; + case "noop": return model; + } +} +`); + assert.match(zig, /scale: f64/); + assert.match(zig, /zoom: f64/); + assert.match(zig, /pub fn pinchMsg/); + + // The bad shape (missing centroid fields) is a taught NS1033. + const bad_pinch = transpile(` +export type PinchPhase = "begin" | "change" | "end"; +export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; } +export interface Model { readonly w: number; } +export type Msg = { readonly kind: "a" } | { readonly kind: "b" }; +export function pinchMsg(pinch: PinchEvent): Msg | null { return null; } +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { case "a": return model; case "b": return model; } +} +`); + assert.equal(bad_pinch.ok, false); + assert.ok(bad_pinch.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(bad_pinch.diagnostics)); +}); From c721e63c5ea0fa6590e6ebc7665bf1daf2be16c0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:32:34 -0500 Subject: [PATCH 05/14] Test pinch delivery end to end: both core tiers, the verb, and replay - ui_app_tests drives begin/change/change/end plus the widget-pinch verb into a Zig core, pinning product-of-deltas cumulative scale (two +25% steps land on 1.5625, never a sum's 1.45), centroid delivery, default view-center aim, non-pinch isolation, and the non-positive-scale refusal. - The ts-core markup fixture exports pinchMsg (phase-gated on change) and the e2e drives raw events plus the automation verb through the transpiled core. - The record/replay reference session pinches once raw and once through the verb; replay re-derives the identical binary-exact zoom and fingerprints from the journaled scale fields alone. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- src/runtime/session_tests.zig | 67 +++++++++++++ src/runtime/ui_app_tests.zig | 152 +++++++++++++++++++++++++++++ tests/ts-core/markup_e2e_tests.zig | 46 +++++++++ tests/ts-core/markup_fixture.ts | 16 +++ 4 files changed, 281 insertions(+) diff --git a/src/runtime/session_tests.zig b/src/runtime/session_tests.zig index 3516e80f..b22e6fd7 100644 --- a/src/runtime/session_tests.zig +++ b/src/runtime/session_tests.zig @@ -51,6 +51,13 @@ const SessionModel = struct { name_anchor: usize = 0, name_focus: usize = 0, name_edits: u32 = 0, + /// The pinch channel's cumulative zoom (the product of 1 + delta + /// across change events) plus phase counters: the reference session + /// pinches once raw and once through the automation verb, so replay + /// re-deriving the identical product pins the journaled scale field. + zoom: f32 = 1, + pinch_begins: u32 = 0, + pinch_ends: u32 = 0, fn bodyText(self: *const SessionModel) []const u8 { return self.body[0..self.body_len]; @@ -73,6 +80,7 @@ const SessionMsg = union(enum) { start_audio, query_edit: canvas.TextInputEvent, name_edit: canvas.TextInputEvent, + pinch: platform.PinchEvent, fetched: effects_mod.EffectResponse, line: effects_mod.EffectLine, exited: effects_mod.EffectExit, @@ -123,6 +131,11 @@ fn sessionUpdate(model: *SessionModel, msg: SessionMsg, fx: *SessionApp.Effects) }, .query_edit => |edit| applyMirrorEdit(&model.query, &model.query_len, &model.query_anchor, &model.query_focus, &model.query_edits, edit), .name_edit => |edit| applyMirrorEdit(&model.name, &model.name_len, &model.name_anchor, &model.name_focus, &model.name_edits, edit), + .pinch => |pinch| switch (pinch.phase) { + .begin => model.pinch_begins += 1, + .change => model.zoom *= (1 + pinch.scale), + .end => model.pinch_ends += 1, + }, .line => model.line_count += 1, .exited => |exit| model.exit_code = exit.code, .tick => |timer| model.tick_timestamp_ns = timer.timestamp_ns, @@ -168,6 +181,7 @@ fn sessionView(ui: *SessionApp.Ui, model: *const SessionModel) SessionApp.Ui.Nod // sanitized single-line insert both re-derive on replay. ui.el(.textarea, .{ .text = "line one\nline two" }, .{}), ui.text(.{}, ui.fmt("Query {s} ({d}) Name {s} ({d})", .{ model.queryText(), model.query_edits, model.nameText(), model.name_edits })), + ui.text(.{}, ui.fmt("Zoom {d:.4} ({d}/{d})", .{ model.zoom, model.pinch_begins, model.pinch_ends })), ui.button(.{ .on_press = .increment }, "Increment"), }); } @@ -193,6 +207,10 @@ const session_windows = [_]app_manifest.ShellWindow{.{ }}; const session_scene: app_manifest.ShellConfig = .{ .windows = &session_windows }; +fn sessionPinch(pinch: platform.PinchEvent) ?SessionMsg { + return .{ .pinch = pinch }; +} + fn sessionOptions() SessionApp.Options { return .{ .name = "session-demo", @@ -201,6 +219,7 @@ fn sessionOptions() SessionApp.Options { .update_fx = sessionUpdate, .view = sessionView, .on_command = sessionCommand, + .on_pinch = sessionPinch, }; } @@ -389,6 +408,49 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la .modifiers = .{ .primary = true, .command = true }, } }); try std.testing.expectEqualStrings("line oneline two", app_state.model.queryText()); + + // A trackpad pinch, twice over: the raw journaled phase stream (the + // macOS host shape — begin, two deltas, end; the +25% then -25% + // deltas compound as a PRODUCT, 1.25 * 0.75 = 0.9375, never a sum's + // 1.0), then the automation verb's synthesized gesture, which + // journals the same leaf gpu_surface_input events. Replay must + // re-derive the identical cumulative zoom from the journaled scale + // fields alone. Every value here is binary-exact in f32, so the + // model equality below is exact, not approximate. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_begin, + .x = 200, + .y = 150, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 200, + .y = 150, + .scale = 0.25, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 200, + .y = 150, + .scale = -0.25, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_end, + .x = 200, + .y = 150, + } }); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + var pinch_buffer: [64]u8 = undefined; + const pinch_command = try std.fmt.bufPrint(&pinch_buffer, "widget-pinch {s} 1.5 120 80", .{canvas_label}); + try harness.runtime.dispatchAutomationCommand(app, pinch_command); try harness.runtime.dispatchPlatformEvent(app, .frame_requested); recorder.finish(); @@ -448,6 +510,11 @@ test "a recorded session replays to identical model state and fingerprints" { // paste landing STRIPPED of its line breaks (single-line rule). try std.testing.expectEqual(@as(u32, 3), recorded.model.query_edits); try std.testing.expectEqualStrings("line oneline two", recorded.model.queryText()); + // Both pinch gestures reached the model: the raw stream's product + // (1.25 * 0.75 = 0.9375) times the verb's exact 1.5. + try std.testing.expectEqual(@as(u32, 2), recorded.model.pinch_begins); + try std.testing.expectEqual(@as(u32, 2), recorded.model.pinch_ends); + try std.testing.expectEqual(@as(f32, 1.40625), recorded.model.zoom); const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); try std.testing.expect(replayed.report.ok()); diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index a7d18c78..8e302270 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4042,3 +4042,155 @@ test "windowed virtual list scrolls, re-windows, budgets to the viewport, and fi try std.testing.expectEqual(@as(usize, 600), first_index + mounted); try std.testing.expect(mounted >= 25); } + +// ------------------------------------------------------- pinch channel + +const ZoomModel = struct { + /// Cumulative gesture scale: the running product of `1 + delta` + /// across change events — the semantics the channel documents. + scale: f32 = 1, + begins: u32 = 0, + ends: u32 = 0, + centroid_x: f32 = 0, + centroid_y: f32 = 0, +}; + +const ZoomMsg = union(enum) { + pinch: zero_platform.PinchEvent, +}; + +const ZoomApp = ui_app_model.UiApp(ZoomModel, ZoomMsg); + +fn zoomUpdate(model: *ZoomModel, msg: ZoomMsg) void { + switch (msg) { + .pinch => |pinch| switch (pinch.phase) { + .begin => { + model.begins += 1; + model.centroid_x = pinch.x; + model.centroid_y = pinch.y; + }, + .change => model.scale *= (1 + pinch.scale), + .end => model.ends += 1, + }, + } +} + +fn zoomView(ui: *ZoomApp.Ui, model: *const ZoomModel) ZoomApp.Ui.Node { + return ui.column(.{ .gap = 8, .padding = 12 }, .{ + ui.text(.{}, ui.fmt("Zoom {d:.2}", .{model.scale})), + }); +} + +fn zoomPinch(pinch: zero_platform.PinchEvent) ?ZoomMsg { + return .{ .pinch = pinch }; +} + +test "trackpad pinch reaches the app through on_pinch with product-of-deltas scale" { + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(ZoomApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = ZoomApp.init(std.heap.page_allocator, .{}, .{ + .name = "ui-app-zoom", + .scene = counter_scene, + .canvas_label = canvas_label, + .update = zoomUpdate, + .view = zoomView, + .on_pinch = zoomPinch, + }); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 2, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + + // begin -> change -> change -> end, the host's phase stream: the + // model hears every phase, the centroid rides x/y, and the + // cumulative scale is the PRODUCT of (1 + delta) — two +25% steps + // land on 1.5625, never the 1.45 a sum-of-deltas would produce. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_begin, + .x = 120, + .y = 80, + } }); + try std.testing.expectEqual(@as(u32, 1), app_state.model.begins); + try std.testing.expectEqual(@as(f32, 120), app_state.model.centroid_x); + try std.testing.expectEqual(@as(f32, 80), app_state.model.centroid_y); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 120, + .y = 80, + .scale = 0.25, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 120, + .y = 80, + .scale = 0.25, + } }); + try std.testing.expectEqual(@as(f32, 1.5625), app_state.model.scale); + try std.testing.expectEqual(@as(u32, 0), app_state.model.ends); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_end, + .x = 120, + .y = 80, + } }); + try std.testing.expectEqual(@as(u32, 1), app_state.model.ends); + // The dispatched Msgs rebuilt the view from the model. + try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.56")); + + // Non-pinch raw input never leaks into the channel: a scroll leaves + // the model untouched. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .scroll, + .delta_y = 24, + } }); + try std.testing.expectEqual(@as(f32, 1.5625), app_state.model.scale); + try std.testing.expectEqual(@as(u32, 1), app_state.model.begins); + + // The automation verb drives the same real events: one full gesture + // whose change carries scale - 1, centroid defaulting to the view + // center, so tests and users can pinch without a trackpad. + app_state.model = .{}; + var command_buffer: [96]u8 = undefined; + const pinch_default = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 1.5", .{canvas_label}); + try harness.runtime.dispatchAutomationCommand(app, pinch_default); + try std.testing.expectEqual(@as(u32, 1), app_state.model.begins); + try std.testing.expectEqual(@as(u32, 1), app_state.model.ends); + try std.testing.expectEqual(@as(f32, 1.5), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 200), app_state.model.centroid_x); + try std.testing.expectEqual(@as(f32, 150), app_state.model.centroid_y); + + // An explicit centroid rides through; the cumulative scale keeps + // compounding across gestures exactly as deltas compound within one. + const pinch_at = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 0.5 40 60", .{canvas_label}); + try harness.runtime.dispatchAutomationCommand(app, pinch_at); + try std.testing.expectEqual(@as(f32, 0.75), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 40), app_state.model.centroid_x); + try std.testing.expectEqual(@as(f32, 60), app_state.model.centroid_y); + + // A malformed scale is loud driver misuse, not a dispatched gesture + // (the product of 1 + delta can never reach a non-positive scale). + const pinch_bad = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 0", .{canvas_label}); + try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, pinch_bad)); + try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); +} diff --git a/tests/ts-core/markup_e2e_tests.zig b/tests/ts-core/markup_e2e_tests.zig index 2c638ee8..a08ee0b2 100644 --- a/tests/ts-core/markup_e2e_tests.zig +++ b/tests/ts-core/markup_e2e_tests.zig @@ -302,6 +302,7 @@ test "the runtime markup interpreter builds the emitted model exactly like the c .stampMs = -1, .draft = "", .canvasWidth = 0, + .zoom = 1, .dark = false, .chromeTop = 0, }; @@ -478,6 +479,51 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { } }); try std.testing.expect(Bridge.model().filter == .open); try std.testing.expect(h.hasText("filter open")); + + // pinchMsg: the trackpad pinch channel — the phase alias matches by + // member name, begin/end gate to null in the core, and each change + // compounds the zoom by (1 + delta): two +25% deltas land on the + // PRODUCT 1.5625, never a sum's 1.45. + try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoom); + try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_begin, + .x = 120, + .y = 80, + } }); + try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoom); + try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 120, + .y = 80, + .scale = 0.25, + } }); + try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 120, + .y = 80, + .scale = 0.25, + } }); + try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_end, + .x = 120, + .y = 80, + } }); + try std.testing.expectEqual(@as(f64, 1.5625), Bridge.model().zoom); + + // The automation pinch verb dispatches the same real events into the + // transpiled core: one gesture whose change carries scale - 1. + var pinch_buffer: [96]u8 = undefined; + const pinch = try std.fmt.bufPrint(&pinch_buffer, "widget-pinch {s} 2", .{canvas_label}); + try h.harness.runtime.dispatchAutomationCommand(h.app, pinch); + try std.testing.expectEqual(@as(f64, 3.125), Bridge.model().zoom); } test "boot images register and launch env overrides dispatch at install" { diff --git a/tests/ts-core/markup_fixture.ts b/tests/ts-core/markup_fixture.ts index 5fdd1764..92d4bb0b 100644 --- a/tests/ts-core/markup_fixture.ts +++ b/tests/ts-core/markup_fixture.ts @@ -18,6 +18,7 @@ import { type TextInputEvent, type FrameEvent, type KeyEvent, + type PinchEvent, type ColorScheme, type ChromeInsets, type ChromeButtons, @@ -42,6 +43,9 @@ export interface Model { readonly draft: Uint8Array; /// The frame channel's width mirror (the album-grid derivation shape). readonly canvasWidth: number; + /// The pinch channel's cumulative zoom: the product of (1 + delta) + /// across change events — the timeline-zoom derivation shape. + readonly zoom: number; /// The appearance channel's scheme mirror. readonly dark: boolean; /// The chrome channel's titlebar band mirror. @@ -58,6 +62,7 @@ export type Msg = | { readonly kind: "stamped"; readonly at: number } | { readonly kind: "draft_edit"; readonly edit: TextInputEvent } | { readonly kind: "canvas_resized"; readonly width: number } + | { readonly kind: "zoomed"; readonly factor: number } | { readonly kind: "appearance_changed"; readonly colorScheme: ColorScheme; readonly reduceMotion: boolean; readonly highContrast: boolean } | { readonly kind: "chrome_changed"; readonly insets: ChromeInsets; readonly buttons: ChromeButtons; readonly tabsProjected: boolean } | { readonly kind: "banner_set"; readonly value: Uint8Array }; @@ -79,6 +84,14 @@ export function keyMsg(key: KeyEvent): Msg | null { return null; } +/// The pinch channel gates on the change phase (begin/end carry no +/// delta): the model compounds the cumulative zoom as the product of +/// (1 + delta), the documented gesture-scale semantics. +export function pinchMsg(pinch: PinchEvent): Msg | null { + if (pinch.phase !== "change" || pinch.scale === 0) return null; + return { kind: "zoomed", factor: 1 + pinch.scale }; +} + export const appearanceMsg = "appearance_changed"; export const chromeMsg = "chrome_changed"; export const envMsgs = [{ env: "TS_BOARD_BANNER", msg: "banner_set" }] as const; @@ -94,6 +107,7 @@ export function initialModel(): Model { stampMs: -1, draft: new Uint8Array(0), canvasWidth: 0, + zoom: 1, dark: false, chromeTop: 0, }; @@ -158,6 +172,8 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] { return { ...model, draft: applyDraftEdit(model.draft, msg.edit) }; case "canvas_resized": return { ...model, canvasWidth: msg.width }; + case "zoomed": + return { ...model, zoom: model.zoom * msg.factor }; case "appearance_changed": return { ...model, dark: msg.colorScheme === "dark" }; case "chrome_changed": From 4b4fb46461a9b8fef676300a39eb86be57c07501 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:32:45 -0500 Subject: [PATCH 06/14] Document the pinch channel, the widget-pinch verb, and the journal bump - native-ui gains a Trackpad pinch section (both tiers' channel shapes, product-of-deltas semantics, centroid, staged platforms); the automation/cli pages and the automation skill list widget-pinch. - Changelog fragment covers the channel, the verb (protocol v7), and journal v5. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- changelog.d/trackpad-pinch.md | 3 +++ docs/src/app/automation/page.mdx | 4 ++++ docs/src/app/cli/page.mdx | 2 ++ docs/src/app/native-ui/page.mdx | 27 +++++++++++++++++++++++++++ docs/src/app/typescript/page.mdx | 2 +- skill-data/automation/SKILL.md | 11 +++++++---- 6 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 changelog.d/trackpad-pinch.md diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md new file mode 100644 index 00000000..04fdabb4 --- /dev/null +++ b/changelog.d/trackpad-pinch.md @@ -0,0 +1,3 @@ +feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the centroid rides view-local `x`/`y`. Windows precision-touchpad and GTK gesture sources are staged follow-ups. +- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (one change carrying `scale - 1`, centroid defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. +- **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build. diff --git a/docs/src/app/automation/page.mdx b/docs/src/app/automation/page.mdx index 9c70ffee..3a345504 100644 --- a/docs/src/app/automation/page.mdx +++ b/docs/src/app/automation/page.mdx @@ -137,6 +137,10 @@ The runtime watches the command queue and processes these actions: widget-key <view-label> <key> [<text>] Dispatch key input to the focused retained canvas widget + + widget-pinch <view-label> <scale> [<x> <y>] + Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1 so the cumulative gesture scale (the product of 1 + delta) lands exactly on scale (1.5 zooms in 50%, 0.5 zooms out to half). The centroid defaults to the view center + shortcut <id> Dispatch a shortcut command event for the main window diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx index 6d8048da..d7259221 100644 --- a/docs/src/app/cli/page.mdx +++ b/docs/src/app/cli/page.mdx @@ -233,6 +233,8 @@ Interact with the automation server of a running automation-enabled app. See [Au
Dispatch wheel input at a retained canvas widget.
automate widget-key <view-label> <key> [<text>]
Dispatch key input to the focused retained canvas widget.
+
automate widget-pinch <view-label> <scale> [<x> <y>]
+
Dispatch a trackpad pinch gesture at a gpu-surface view (cumulative scale; centroid defaults to the view center).
automate shortcut <id>
Dispatch a shortcut command event.
automate tray-action <item-id>
diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 8537ee9d..e0695e02 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -274,6 +274,33 @@ One rule bridges the registers: **quiet focus on a plain list row routes no keys `examples/soundboard` is the worked example, pinned end-to-end in its tests: Space toggles the transport from anywhere — after clicking a track row (quiet, transparent), from a focused slider or scroll region (space is not one of their keys, so it falls through) — while a tabbed-to row takes Space for select and Enter for play, and the search field takes every key as typing. Automation note: `native automate widget-action press` escalates a plain list row to the ring register before dispatching its synthesized activation key, so a scripted activation reaches the row the way Tab-then-Enter would. +## Trackpad pinch + +Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The gesture centroid rides `x`/`y` in view-local canvas points, so a zoom can anchor under the fingers. + +```zig +// options: .on_pinch = onPinch, +pub fn onPinch(pinch: platform.PinchEvent) ?Msg { + return switch (pinch.phase) { + .change => .{ .zoom = .{ .factor = 1 + pinch.scale, .cx = pinch.x, .cy = pinch.y } }, + .begin, .end => null, + }; +} +``` + +TypeScript cores export the same channel as `pinchMsg` (the `keyMsg` shape), with the record types importable from `@native-sdk/core/events`: + +```ts +import { type PinchEvent } from "@native-sdk/core/events"; + +export function pinchMsg(pinch: PinchEvent): Msg | null { + if (pinch.phase !== "change") return null; + return { kind: "zoomed", factor: 1 + pinch.scale, cx: pinch.x, cy: pinch.y }; +} +``` + +macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — the cumulative scale in one gesture, centroid defaulting to the view center. + ## Native scrolling and context menus On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScrollView`: momentum and the system overlay scrollbar are OS-computed while the engine renders the content. This needs no app code — scroll offsets stay on the widget (`sync`, snapshot offsets, and programmatic scrolls work unchanged), and the engine's drawn scrollbar stands down for natively driven regions. Other platforms keep the engine's wheel physics. diff --git a/docs/src/app/typescript/page.mdx b/docs/src/app/typescript/page.mdx index 47f3325e..4888f3ae 100644 --- a/docs/src/app/typescript/page.mdx +++ b/docs/src/app/typescript/page.mdx @@ -401,7 +401,7 @@ A markup text control (``) ne ## Splitting a core into modules -A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the flat emitted namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — transpiled into your core when imported and absent when not. Everything still emits as ONE readable native module, one section per source file. +A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the flat emitted namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — transpiled into your core when imported and absent when not. Everything still emits as ONE readable native module, one section per source file. diff --git a/skill-data/automation/SKILL.md b/skill-data/automation/SKILL.md index b5f54e19..0ea7be01 100644 --- a/skill-data/automation/SKILL.md +++ b/skill-data/automation/SKILL.md @@ -71,6 +71,8 @@ native automate widget-drag canvas 4 0.25 0.82 native automate widget-wheel canvas 5 18 native automate widget-key canvas tab native automate widget-key canvas cmd+c +native automate widget-pinch canvas 1.5 +native automate widget-pinch canvas 0.5 120 80 native automate profile on native automate profile off native automate bridge '{"id":"smoke","command":"native.ping","payload":{"source":"automation"}}' @@ -113,10 +115,11 @@ Semantics: 8. Use `native automate widget-drag [start-y-ratio end-y-ratio]` for continuous pointer controls. 9. Use `native automate widget-wheel ` for retained widget scroll input. Wheel targets must be interactive/scrollable widgets — a plain layout column or text node is not a wheel target; aim at the scroll/list widget id from the snapshot. Failures land in the snapshot as named reasons: `error event=automation.widget_wheel name=WheelTargetUnknown|WheelTargetNotInteractive|WheelTargetHasEmptyBounds detail=""`. 10. Use `native automate widget-key [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS). -11. Use `native automate screenshot [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-.png` (the CLI prints the artifact path and waits for the file). -12. Use `native automate tray-action ` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`. -13. Use `native automate reload` to request a WebView reload. -14. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window. +11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1` so the cumulative gesture scale (the product of `1 + delta`) lands exactly on `` — `1.5` zooms in 50%, `0.5` zooms out to half. The optional centroid is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). +12. Use `native automate screenshot [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-.png` (the CLI prints the artifact path and waits for the file). +13. Use `native automate tray-action ` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`. +14. Use `native automate reload` to request a WebView reload. +15. Use `native automate profile on` to enable per-stage frame timing: while on, `snapshot.txt` carries a `frame_profile` line with rolling p50/p90/max microseconds per pipeline stage (`rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, `host_draw`), each with a lifetime sample count (`_n=`). Drive some interactions, then `native automate snapshot | grep -o 'frame_profile.*'` to read where frame time goes; `profile off` stops recording and drops the line. Turning it on starts a fresh sample window. ## Screenshots From 72ddb7d531307df76cdf300648cefd3d53cc07b8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:45:44 -0500 Subject: [PATCH 07/14] Pin the macOS pinch ABI conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The kind codes 12-14 map to the pinch phases and the ABI event's scale field carries the magnification delta with the converted centroid on x/y — the headless half of the magnifyWithEvent: path. Co-authored-by: jhodges10 <18431344+jhodges10@users.noreply.github.com> --- src/platform/macos/root.zig | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 5adf1573..516c88ac 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -2248,6 +2248,33 @@ 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 pinch phases and carries the magnification delta" { + const label = "timeline-canvas"; + var event = std.mem.zeroes(AppKitEvent); + event.view_label = label.ptr; + event.view_label_len = label.len; + event.input_kind = 12; + try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pinch_begin, gpuSurfaceInputEventFromAppKitEvent(&event).kind); + + // The magnification delta rides the ABI event's `scale` field with + // the centroid on x/y (the host's converted, top-left-origin point). + event.input_kind = 13; + event.x = 160; + event.y = 120; + event.scale = 0.25; + const change = gpuSurfaceInputEventFromAppKitEvent(&event); + try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pinch_change, change.kind); + try std.testing.expectEqual(@as(f32, 0.25), change.scale); + try std.testing.expectEqual(@as(f32, 160), change.x); + try std.testing.expectEqual(@as(f32, 120), change.y); + + event.input_kind = 14; + event.scale = 0; + const end = gpuSurfaceInputEventFromAppKitEvent(&event); + try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pinch_end, end.kind); + try std.testing.expectEqual(@as(f32, 0), end.scale); +} + 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)); From 5861a052dcd28787bac4f14fa8cb5a37b132be5b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 02:32:08 -0500 Subject: [PATCH 08/14] Forward the terminal pinch magnification instead of discarding it - AppKit documents every magnifyWithEvent: as carrying the delta since the previous event, the Ended/Cancelled one included; the terminal branch now emits a nonzero delta as one last PINCH_CHANGE before PINCH_END so the cumulative product of (1 + delta) matches what the OS delivered - Cancelled deliberately shares the path: pinch applies deltas incrementally with no rollback, so the honest stream reports every measured delta, then ends - Pin the ordering in build.zig's host-source checks and fold a terminal-delta Ended plus a zero-delta Ended into the on_pinch product test --- build.zig | 14 ++++++++++ changelog.d/trackpad-pinch.md | 2 +- docs/src/app/native-ui/page.mdx | 2 +- packages/core/sdk/events.ts | 5 +++- src/platform/macos/appkit_host.m | 16 +++++++++++ src/platform/types.zig | 4 +++ src/runtime/ui_app_tests.zig | 48 +++++++++++++++++++++++++++----- 7 files changed, 81 insertions(+), 10 deletions(-) diff --git a/build.zig b/build.zig index a8b5a25f..187f23b3 100644 --- a/build.zig +++ b/build.zig @@ -636,6 +636,20 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (void)requestRetainedCanvasFrame" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[self requestRetainedCanvasFrame];" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-terminal-delta", "Verify the macOS pinch stream forwards a terminal Ended/Cancelled magnification as a change before the end marker", &.{ + // AppKit documents every magnifyWithEvent: as carrying the + // magnification since the previous event — the terminal one + // included. The Ended/Cancelled branch must forward a nonzero + // terminal delta as PINCH_CHANGE before PINCH_END (zero delta: + // end only), or the cumulative product of (1 + delta) diverges + // from what the OS delivered. Cancelled deliberately shares the + // path: pinch applies deltas incrementally with no rollback. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "const double terminalDelta = event.magnification;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (terminalDelta != 0) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "PINCH_CHANGE event:event magnification:terminalDelta];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "PINCH_END event:event magnification:0];" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-input-paces-retained-canvas", "Verify GPU input frame requests are paced to the display interval", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRetainedFrameIntervalNanoseconds" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "retainedFrameLastEmitNs" }, diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md index 04fdabb4..4c83c40b 100644 --- a/changelog.d/trackpad-pinch.md +++ b/changelog.d/trackpad-pinch.md @@ -1,3 +1,3 @@ -feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the centroid rides view-local `x`/`y`. Windows precision-touchpad and GTK gesture sources are staged follow-ups. +feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the centroid rides view-local `x`/`y`; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. Windows precision-touchpad and GTK gesture sources are staged follow-ups. - **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (one change carrying `scale - 1`, centroid defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. - **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build. diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index e0695e02..697d8eab 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -276,7 +276,7 @@ One rule bridges the registers: **quiet focus on a plain list row routes no keys ## Trackpad pinch -Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The gesture centroid rides `x`/`y` in view-local canvas points, so a zoom can anchor under the fingers. +Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The gesture centroid rides `x`/`y` in view-local canvas points, so a zoom can anchor under the fingers. ```zig // options: .on_pinch = onPinch, diff --git a/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index 61a13eb8..2a762c5e 100644 --- a/packages/core/sdk/events.ts +++ b/packages/core/sdk/events.ts @@ -77,7 +77,10 @@ export interface KeyEvent { /// matches enum members by name, so this is the `phase` field's type in /// `pinchMsg`'s parameter record). A host-cancelled gesture folds into /// "end": pinch delivers incremental deltas the app applies as they -/// arrive, so there is no transient state to roll back. +/// arrive, so there is no transient state to roll back. A terminal host +/// event that still measured a nonzero delta delivers it as a final +/// "change" before the "end", so the cumulative product always matches +/// what the OS reported. export type PinchPhase = "begin" | "change" | "end"; /// The pinch channel's record (`pinchMsg(pinch)`): the trackpad pinch diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index fb28bd7b..27afcbb5 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -5542,6 +5542,12 @@ - (void)magnifyWithEvent:(NSEvent *)event { // Change deltas emit uncoalesced (no per-frame queue like scroll): // the cumulative gesture scale is the PRODUCT of (1 + delta), and // summing coalesced deltas would drift from what the fingers did. + // + // EVERY magnifyWithEvent: carries the magnification since the + // previous event — the terminal (Ended/Cancelled) one included — + // so each branch below must forward a nonzero delta as a + // PINCH_CHANGE or the cumulative product diverges from what the + // OS delivered. const NSEventPhase phase = event.phase; if (phase & NSEventPhaseBegan) { [self emitQueuedPointerMotionInputEvent]; @@ -5567,6 +5573,16 @@ - (void)magnifyWithEvent:(NSEvent *)event { if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) { if (!self.pinchGestureActive) return; self.pinchGestureActive = NO; + // The terminal event's nonzero delta emits as a final + // PINCH_CHANGE before the end marker (a zero-delta terminal + // event emits only PINCH_END). Cancelled takes the same path + // deliberately: our model applies deltas incrementally with no + // rollback (see above), so the honest stream reports every + // delta the OS measured, then says the gesture is over. + const double terminalDelta = event.magnification; + if (terminalDelta != 0) { + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:terminalDelta]; + } [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0]; return; } diff --git a/src/platform/types.zig b/src/platform/types.zig index e5185998..7a7adf28 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1592,6 +1592,10 @@ pub const GpuSurfaceInputEvent = struct { /// a host-cancelled gesture folds into `.end` — pinch delivers incremental /// deltas the app applies as they arrive, so there is no transient state /// to roll back the way `pointer_cancel` rolls back an in-flight press. +/// A terminal host event (Ended or Cancelled) that still measured a +/// nonzero magnification delivers that delta as a final `.change` before +/// the `.end`, so the cumulative product always matches what the OS +/// reported. pub const PinchPhase = enum { begin, change, diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index 8e302270..8176261f 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4113,10 +4113,11 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca } }); try std.testing.expect(app_state.installed); - // begin -> change -> change -> end, the host's phase stream: the - // model hears every phase, the centroid rides x/y, and the - // cumulative scale is the PRODUCT of (1 + delta) — two +25% steps - // land on 1.5625, never the 1.45 a sum-of-deltas would produce. + // begin -> change -> change -> change -> end, the host's phase + // stream: the model hears every phase, the centroid rides x/y, and + // the cumulative scale is the PRODUCT of (1 + delta) — two +25% + // steps land on 1.5625, never the 1.45 a sum-of-deltas would + // produce. try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -4145,6 +4146,18 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca } }); try std.testing.expectEqual(@as(f32, 1.5625), app_state.model.scale); try std.testing.expectEqual(@as(u32, 0), app_state.model.ends); + // A terminal-delta Ended: AppKit's Ended/Cancelled event still + // carries the magnification since the previous event, so the host + // forwards it as one last change BEFORE the end marker — the + // product folds it in (1.5625 * 1.25 = 1.953125, binary-exact). + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_change, + .x = 120, + .y = 80, + .scale = 0.25, + } }); try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -4153,8 +4166,29 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .y = 80, } }); try std.testing.expectEqual(@as(u32, 1), app_state.model.ends); + try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); // The dispatched Msgs rebuilt the view from the model. - try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.56")); + try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.95")); + + // A zero-delta Ended is just the end marker: begin then end moves + // no scale. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_begin, + .x = 120, + .y = 80, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pinch_end, + .x = 120, + .y = 80, + } }); + try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); + try std.testing.expectEqual(@as(u32, 2), app_state.model.ends); + try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); // Non-pinch raw input never leaks into the channel: a scroll leaves // the model untouched. @@ -4164,8 +4198,8 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .kind = .scroll, .delta_y = 24, } }); - try std.testing.expectEqual(@as(f32, 1.5625), app_state.model.scale); - try std.testing.expectEqual(@as(u32, 1), app_state.model.begins); + try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); + try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); // The automation verb drives the same real events: one full gesture // whose change carries scale - 1, centroid defaulting to the view From 769eece932182e4b78dc69ef4f73301057e7f55c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 02:37:23 -0500 Subject: [PATCH 09/14] Name the pinch x/y for what it is: the pointer anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppKit reports gesture events at locationInWindow — the pointer location, never a midpoint between the fingers (raw touch positions are trackpad-normalized and have no view-space meaning) — so the API was right and the word "centroid" was wrong - Redocument x/y as the pointer anchor (zoom-at-cursor anchoring) across the platform vocabulary, the app-channel docs in both tiers, the automation verb's usage, the docs pages, the changelog fragment, and test names; the word has zero survivors --- changelog.d/trackpad-pinch.md | 4 ++-- docs/src/app/automation/page.mdx | 2 +- docs/src/app/cli/page.mdx | 2 +- docs/src/app/native-ui/page.mdx | 4 ++-- packages/core/sdk/events.ts | 11 +++++---- packages/core/src/infer.ts | 2 +- packages/core/test/emitter.test.ts | 2 +- skill-data/automation/SKILL.md | 2 +- src/automation/protocol.zig | 2 +- src/platform/macos/appkit_host.h | 4 +++- src/platform/macos/appkit_host.m | 6 ++++- src/platform/macos/root.zig | 3 ++- src/platform/types.zig | 15 ++++++++++--- src/runtime/automation_commands.zig | 5 +++-- src/runtime/automation_widget_dispatch.zig | 3 ++- src/runtime/ts_ui_app.zig | 4 ++-- src/runtime/ui_app.zig | 5 +++-- src/runtime/ui_app_tests.zig | 26 +++++++++++----------- tools/native-sdk/automation.zig | 2 +- 19 files changed, 63 insertions(+), 41 deletions(-) diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md index 4c83c40b..72ed8609 100644 --- a/changelog.d/trackpad-pinch.md +++ b/changelog.d/trackpad-pinch.md @@ -1,3 +1,3 @@ -feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the centroid rides view-local `x`/`y`; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. Windows precision-touchpad and GTK gesture sources are staged follow-ups. -- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (one change carrying `scale - 1`, centroid defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. +feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. Windows precision-touchpad and GTK gesture sources are staged follow-ups. +- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (one change carrying `scale - 1`, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. - **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build. diff --git a/docs/src/app/automation/page.mdx b/docs/src/app/automation/page.mdx index 3a345504..d6760779 100644 --- a/docs/src/app/automation/page.mdx +++ b/docs/src/app/automation/page.mdx @@ -139,7 +139,7 @@ The runtime watches the command queue and processes these actions: widget-pinch <view-label> <scale> [<x> <y>] - Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1 so the cumulative gesture scale (the product of 1 + delta) lands exactly on scale (1.5 zooms in 50%, 0.5 zooms out to half). The centroid defaults to the view center + Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1 so the cumulative gesture scale (the product of 1 + delta) lands exactly on scale (1.5 zooms in 50%, 0.5 zooms out to half). The anchor point defaults to the view center shortcut <id> diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx index d7259221..86006d9f 100644 --- a/docs/src/app/cli/page.mdx +++ b/docs/src/app/cli/page.mdx @@ -234,7 +234,7 @@ Interact with the automation server of a running automation-enabled app. See [Au
automate widget-key <view-label> <key> [<text>]
Dispatch key input to the focused retained canvas widget.
automate widget-pinch <view-label> <scale> [<x> <y>]
-
Dispatch a trackpad pinch gesture at a gpu-surface view (cumulative scale; centroid defaults to the view center).
+
Dispatch a trackpad pinch gesture at a gpu-surface view (cumulative scale; the anchor point defaults to the view center).
automate shortcut <id>
Dispatch a shortcut command event.
automate tray-action <item-id>
diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 697d8eab..4a5f96b8 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -276,7 +276,7 @@ One rule bridges the registers: **quiet focus on a plain list row routes no keys ## Trackpad pinch -Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The gesture centroid rides `x`/`y` in view-local canvas points, so a zoom can anchor under the fingers. +Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. ```zig // options: .on_pinch = onPinch, @@ -299,7 +299,7 @@ export function pinchMsg(pinch: PinchEvent): Msg | null { } ``` -macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — the cumulative scale in one gesture, centroid defaulting to the view center. +macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — the cumulative scale in one gesture, anchor point defaulting to the view center. ## Native scrolling and context menus diff --git a/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index 2a762c5e..10564e1d 100644 --- a/packages/core/sdk/events.ts +++ b/packages/core/sdk/events.ts @@ -86,10 +86,13 @@ export type PinchPhase = "begin" | "change" | "end"; /// The pinch channel's record (`pinchMsg(pinch)`): the trackpad pinch /// gesture, phase-explicit. `scale` is the magnification DELTA for this /// event (nonzero only on "change"; the cumulative gesture scale is the -/// running product of `1 + scale`), and `x`/`y` is the gesture centroid -/// in view-local canvas points. Pinch is a view-global gesture — it never -/// routes through widgets — so this is the honest home for timeline and -/// canvas zoom. Only hosts with a pinch source emit it (macOS today). +/// running product of `1 + scale`), and `x`/`y` is the pointer anchor in +/// view-local canvas points — the pointer location during the gesture +/// (hosts report gesture events at the pointer, not at a midpoint +/// between the fingers), so a zoom can anchor under the cursor. Pinch is a +/// view-global gesture — it never routes through widgets — so this is +/// the honest home for timeline and canvas zoom. Only hosts with a pinch +/// source emit it (macOS today). export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; diff --git a/packages/core/src/infer.ts b/packages/core/src/infer.ts index 0ef8ba89..4e7aa5a7 100644 --- a/packages/core/src/infer.ts +++ b/packages/core/src/infer.ts @@ -504,7 +504,7 @@ export class IntInference { } // The pinch channel's parameter record is intrinsically // fractional: magnification deltas are ~0.01..0.3 per event and - // the centroid is sub-point, so its number fields are HOST + // the pointer anchor is sub-point, so its number fields are HOST // values, never provable integers. Marking them boundary-fed // keeps a core's `pinch.scale === 0` comparison from // int-claiming the slot (which would round every zoom product diff --git a/packages/core/test/emitter.test.ts b/packages/core/test/emitter.test.ts index 516d0621..93b3d716 100644 --- a/packages/core/test/emitter.test.ts +++ b/packages/core/test/emitter.test.ts @@ -1735,7 +1735,7 @@ export function update(model: Model, msg: Msg): Model { assert.match(zig, /zoom: f64/); assert.match(zig, /pub fn pinchMsg/); - // The bad shape (missing centroid fields) is a taught NS1033. + // The bad shape (missing anchor fields) is a taught NS1033. const bad_pinch = transpile(` export type PinchPhase = "begin" | "change" | "end"; export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; } diff --git a/skill-data/automation/SKILL.md b/skill-data/automation/SKILL.md index 0ea7be01..a7b2f6ab 100644 --- a/skill-data/automation/SKILL.md +++ b/skill-data/automation/SKILL.md @@ -115,7 +115,7 @@ Semantics: 8. Use `native automate widget-drag [start-y-ratio end-y-ratio]` for continuous pointer controls. 9. Use `native automate widget-wheel ` for retained widget scroll input. Wheel targets must be interactive/scrollable widgets — a plain layout column or text node is not a wheel target; aim at the scroll/list widget id from the snapshot. Failures land in the snapshot as named reasons: `error event=automation.widget_wheel name=WheelTargetUnknown|WheelTargetNotInteractive|WheelTargetHasEmptyBounds detail=""`. 10. Use `native automate widget-key [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS). -11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1` so the cumulative gesture scale (the product of `1 + delta`) lands exactly on `` — `1.5` zooms in 50%, `0.5` zooms out to half. The optional centroid is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). +11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1` so the cumulative gesture scale (the product of `1 + delta`) lands exactly on `` — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). 12. Use `native automate screenshot [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-.png` (the CLI prints the artifact path and waits for the file). 13. Use `native automate tray-action ` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`. 14. Use `native automate reload` to request a WebView reload. diff --git a/src/automation/protocol.zig b/src/automation/protocol.zig index 1cf4400f..21b9bc37 100644 --- a/src/automation/protocol.zig +++ b/src/automation/protocol.zig @@ -101,7 +101,7 @@ pub const Action = enum { /// `pinch_begin`/`pinch_change`/`pinch_end` platform events, with /// one change carrying `scale - 1` so the cumulative gesture scale /// (the product of `1 + delta`) lands exactly on ``. The - /// centroid defaults to the view center. + /// anchor point defaults to the view center. widget_pinch, menu_command, shortcut, diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index ab423d81..ad1fa70a 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -79,7 +79,9 @@ typedef enum { /* Trackpad pinch phases (magnifyWithEvent:). The event's `scale` * field carries the per-event magnification DELTA on PINCH_CHANGE * (NSEvent.magnification semantics; cumulative gesture scale is the - * product of (1 + delta)); 0 on begin/end. Centroid rides x/y. */ + * product of (1 + delta)); 0 on begin/end. The pointer anchor rides + * x/y (the event's locationInWindow — gesture events report the + * pointer location, not a midpoint between the fingers). */ NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN = 12, NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE = 13, NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END = 14, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 27afcbb5..9f0bcbfc 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -5772,7 +5772,11 @@ - (void)emitQueuedScrollInputEvent { // Pinch input: the shared input-emit shape (converted point, top-left // origin flip, host timestamp, modifier flags) with the magnification // delta riding the event's `scale` field — unused (zero) on every other -// input kind, so the runtime reads it unconditionally. +// input kind, so the runtime reads it unconditionally. The x/y point is +// the POINTER anchor: AppKit reports gesture events at locationInWindow +// (the pointer location), never a midpoint between the fingers — NSTouch +// positions are trackpad-normalized and have no view-space meaning, and +// zoom-at-cursor is the anchoring apps want. - (void)emitPinchInputEventWithKind:(NSInteger)kind event:(NSEvent *)event magnification:(double)magnification { if (!self.host || self.surfaceLabel.length == 0 || !event) return; NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 516c88ac..32969fbe 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -2257,7 +2257,8 @@ test "mac gpu surface input maps pinch phases and carries the magnification delt try std.testing.expectEqual(platform_mod.GpuSurfaceInputKind.pinch_begin, gpuSurfaceInputEventFromAppKitEvent(&event).kind); // The magnification delta rides the ABI event's `scale` field with - // the centroid on x/y (the host's converted, top-left-origin point). + // the pointer anchor on x/y (the host's converted, top-left-origin + // pointer location). event.input_kind = 13; event.x = 160; event.y = 120; diff --git a/src/platform/types.zig b/src/platform/types.zig index 7a7adf28..8c6360cc 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1581,8 +1581,12 @@ pub const GpuSurfaceInputEvent = struct { /// Pinch magnification DELTA for this event (NSEvent.magnification /// semantics): nonzero only on `pinch_change`, 0 on begin/end. The /// cumulative gesture scale is the running product of `(1 + scale)` - /// across the gesture's change events. The gesture centroid rides - /// `x`/`y` (view-local, same space as pointer events). + /// across the gesture's change events. The pointer anchor rides + /// `x`/`y` (view-local, same space as pointer events) — the pointer + /// location during the gesture, NOT a midpoint between the fingers: + /// hosts report gesture events at the pointer (AppKit's + /// `locationInWindow`), and zoom-at-cursor is the anchoring apps + /// want. scale: f32 = 0, }; @@ -1613,7 +1617,12 @@ pub const PinchEvent = struct { /// cumulative gesture scale is the running product of `(1 + scale)` /// across the gesture's change events. scale: f32 = 0, - /// Gesture centroid, view-local canvas points (the pointer-event space). + /// The pointer anchor, view-local canvas points (the pointer-event + /// space): where the zoom should anchor. This is the POINTER + /// location during the gesture (AppKit reports gesture events at + /// `locationInWindow`), not a midpoint between the fingers — raw + /// touch positions are trackpad-normalized and never reach view + /// space, and zoom-at-cursor is what apps want anyway. x: f32 = 0, y: f32 = 0, }; diff --git a/src/runtime/automation_commands.zig b/src/runtime/automation_commands.zig index 58ebdfae..9b1e60ab 100644 --- a/src/runtime/automation_commands.zig +++ b/src/runtime/automation_commands.zig @@ -63,8 +63,9 @@ pub const AutomationWidgetKey = struct { /// scale (1.5 zooms in 50%, 0.5 zooms out to half) — the dispatch /// synthesizes begin, one change carrying `scale - 1`, and end, so the /// product of `(1 + delta)` lands exactly on `scale`. The optional -/// centroid is view-local canvas points; omitted, it defaults to the -/// view center. +/// anchor point is view-local canvas points (where the zoom anchors, +/// like the pointer position under a real pinch); omitted, it defaults +/// to the view center. pub const AutomationWidgetPinch = struct { view_label: []const u8, scale: f32, diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 6846d3f7..5254683a 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -287,7 +287,8 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { /// Drive a trackpad pinch through the real platform-event path: /// `pinch_begin`, one `pinch_change` carrying `scale - 1` (so /// the cumulative product of `1 + delta` lands exactly on the - /// commanded scale), and `pinch_end`, all at the same centroid. + /// commanded scale), and `pinch_end`, all at the same anchor + /// point. /// Plain input synthesis, the `widget-key` discipline: every /// event journals as itself and replays through the same /// dispatch — no accessibility-action record, because pinch is diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index a4b0782d..eaa62fec 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -370,8 +370,8 @@ pub fn TsUiApp(comptime core: type) type { /// the emitted PinchEvent record — `phase` (the declared /// begin/change/end alias, matched by member name), `scale` (the /// magnification DELTA on "change"; cumulative gesture scale is - /// the product of `1 + scale`), and the `x`/`y` centroid in - /// view-local canvas points. The core's return gates the channel + /// the product of `1 + scale`), and the `x`/`y` pointer anchor + /// in view-local canvas points. The core's return gates the channel /// exactly like a Zig `on_pinch` (null drops the event). fn pinchMsgAdapter(pinch: platform.PinchEvent) ?Msg { const params = @typeInfo(@TypeOf(core.pinchMsg)).@"fn".params; diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index fa19717f..2515b4b5 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -544,8 +544,9 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// app-level concern), delivered phase-explicit (`begin`, /// `change`, `end`) with the per-event magnification DELTA /// on `change` — the cumulative gesture scale is the running - /// product of `(1 + scale)` — and the centroid in view-local - /// canvas points. Only hosts with a pinch source emit these + /// product of `(1 + scale)` — and the pointer anchor in + /// view-local canvas points (`x`/`y`, the zoom-at-cursor + /// anchor). Only hosts with a pinch source emit these /// (macOS today); everywhere else the channel simply never /// fires. on_pinch: ?*const fn (pinch: platform.PinchEvent) ?MsgT = null, diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index 8176261f..d360663e 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4051,8 +4051,8 @@ const ZoomModel = struct { scale: f32 = 1, begins: u32 = 0, ends: u32 = 0, - centroid_x: f32 = 0, - centroid_y: f32 = 0, + anchor_x: f32 = 0, + anchor_y: f32 = 0, }; const ZoomMsg = union(enum) { @@ -4066,8 +4066,8 @@ fn zoomUpdate(model: *ZoomModel, msg: ZoomMsg) void { .pinch => |pinch| switch (pinch.phase) { .begin => { model.begins += 1; - model.centroid_x = pinch.x; - model.centroid_y = pinch.y; + model.anchor_x = pinch.x; + model.anchor_y = pinch.y; }, .change => model.scale *= (1 + pinch.scale), .end => model.ends += 1, @@ -4114,7 +4114,7 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca try std.testing.expect(app_state.installed); // begin -> change -> change -> change -> end, the host's phase - // stream: the model hears every phase, the centroid rides x/y, and + // stream: the model hears every phase, the pointer anchor rides x/y, and // the cumulative scale is the PRODUCT of (1 + delta) — two +25% // steps land on 1.5625, never the 1.45 a sum-of-deltas would // produce. @@ -4126,8 +4126,8 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .y = 80, } }); try std.testing.expectEqual(@as(u32, 1), app_state.model.begins); - try std.testing.expectEqual(@as(f32, 120), app_state.model.centroid_x); - try std.testing.expectEqual(@as(f32, 80), app_state.model.centroid_y); + try std.testing.expectEqual(@as(f32, 120), app_state.model.anchor_x); + try std.testing.expectEqual(@as(f32, 80), app_state.model.anchor_y); try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -4202,7 +4202,7 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); // The automation verb drives the same real events: one full gesture - // whose change carries scale - 1, centroid defaulting to the view + // whose change carries scale - 1, anchor defaulting to the view // center, so tests and users can pinch without a trackpad. app_state.model = .{}; var command_buffer: [96]u8 = undefined; @@ -4211,16 +4211,16 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca try std.testing.expectEqual(@as(u32, 1), app_state.model.begins); try std.testing.expectEqual(@as(u32, 1), app_state.model.ends); try std.testing.expectEqual(@as(f32, 1.5), app_state.model.scale); - try std.testing.expectEqual(@as(f32, 200), app_state.model.centroid_x); - try std.testing.expectEqual(@as(f32, 150), app_state.model.centroid_y); + try std.testing.expectEqual(@as(f32, 200), app_state.model.anchor_x); + try std.testing.expectEqual(@as(f32, 150), app_state.model.anchor_y); - // An explicit centroid rides through; the cumulative scale keeps + // An explicit anchor point rides through; the cumulative scale keeps // compounding across gestures exactly as deltas compound within one. const pinch_at = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 0.5 40 60", .{canvas_label}); try harness.runtime.dispatchAutomationCommand(app, pinch_at); try std.testing.expectEqual(@as(f32, 0.75), app_state.model.scale); - try std.testing.expectEqual(@as(f32, 40), app_state.model.centroid_x); - try std.testing.expectEqual(@as(f32, 60), app_state.model.centroid_y); + try std.testing.expectEqual(@as(f32, 40), app_state.model.anchor_x); + try std.testing.expectEqual(@as(f32, 60), app_state.model.anchor_y); // A malformed scale is loud driver misuse, not a dispatched gesture // (the product of 1 + delta can never reach a non-positive scale). diff --git a/tools/native-sdk/automation.zig b/tools/native-sdk/automation.zig index bcf48aa8..69589c19 100644 --- a/tools/native-sdk/automation.zig +++ b/tools/native-sdk/automation.zig @@ -452,7 +452,7 @@ fn printUsage() void { \\ widget-drag [start-y-ratio end-y-ratio] \\ widget-wheel \\ widget-key [text] - \\ widget-pinch [x y] (trackpad pinch: cumulative scale, e.g. 1.5 zooms in; centroid defaults to the view center) + \\ widget-pinch [x y] (trackpad pinch: cumulative scale, e.g. 1.5 zooms in; anchor defaults to the view center) \\ shortcut \\ tray-action (status-item dropdown row; snapshots print tray-item #id) \\ focus From 05d63c8437d5494c04ce0458a446b05e6b63c29d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 03:09:19 -0500 Subject: [PATCH 10/14] Carry the source identity on the pinch channel - PinchEvent gains window_id/label in Zig and windowId/label in TypeScript (the on_frame GpuFrame identity shape): x/y are view-local, so a coordinate without its view is not a position, and multi-window apps could not tell pinches apart - handlePinch forwards the identity the journaled platform event already carries; the TS adapter, NS1033 shape validation, and the emitted-record pins extend to the six-field contract, with the wholesale boundary classing covering the new numeric field - Pin distinguishable pinches in a two-window/two-view Zig fixture and mirror windowId plus a label match through the transpiled markup core end to end --- changelog.d/trackpad-pinch.md | 2 +- docs/src/app/native-ui/page.mdx | 2 +- packages/core/sdk/events.ts | 22 +++-- packages/core/src/emitter.ts | 16 ++-- packages/core/src/infer.ts | 7 +- packages/core/test/emitter.test.ts | 30 +++++- src/platform/types.zig | 8 ++ src/runtime/ts_ui_app.zig | 16 +++- src/runtime/ui_app.zig | 11 ++- src/runtime/ui_app_tests.zig | 141 +++++++++++++++++++++++++++-- tests/ts-core/markup_e2e_tests.zig | 13 ++- tests/ts-core/markup_fixture.ts | 18 +++- 12 files changed, 244 insertions(+), 42 deletions(-) diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md index 72ed8609..3e9ffc0d 100644 --- a/changelog.d/trackpad-pinch.md +++ b/changelog.d/trackpad-pinch.md @@ -1,3 +1,3 @@ -feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. Windows precision-touchpad and GTK gesture sources are staged follow-ups. +feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); every event names its source window and view (`window_id`/`label` in Zig, `windowId`/`label` in TypeScript), so multi-window apps tell pinches apart; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. Windows precision-touchpad and GTK gesture sources are staged follow-ups. - **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (one change carrying `scale - 1`, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. - **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build. diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 4a5f96b8..77b8f62b 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -276,7 +276,7 @@ One rule bridges the registers: **quiet focus on a plain list row routes no keys ## Trackpad pinch -Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. +Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. Every event also names its source — `window_id` and the view `label` in Zig, `windowId`/`label` in TypeScript — because view-local coordinates mean nothing without their view: multi-window apps tell pinches apart by them. ```zig // options: .on_pinch = onPinch, diff --git a/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index 10564e1d..6e513505 100644 --- a/packages/core/sdk/events.ts +++ b/packages/core/sdk/events.ts @@ -84,16 +84,20 @@ export interface KeyEvent { export type PinchPhase = "begin" | "change" | "end"; /// The pinch channel's record (`pinchMsg(pinch)`): the trackpad pinch -/// gesture, phase-explicit. `scale` is the magnification DELTA for this -/// event (nonzero only on "change"; the cumulative gesture scale is the -/// running product of `1 + scale`), and `x`/`y` is the pointer anchor in -/// view-local canvas points — the pointer location during the gesture -/// (hosts report gesture events at the pointer, not at a midpoint -/// between the fingers), so a zoom can anchor under the cursor. Pinch is a -/// view-global gesture — it never routes through widgets — so this is -/// the honest home for timeline and canvas zoom. Only hosts with a pinch -/// source emit it (macOS today). +/// gesture, phase-explicit. `windowId`/`label` name the source window and +/// gpu-surface view — `x`/`y` are view-local, so a coordinate without its +/// view is not a position, and multi-window apps tell pinches apart by +/// these. `scale` is the magnification DELTA for this event (nonzero only +/// on "change"; the cumulative gesture scale is the running product of +/// `1 + scale`), and `x`/`y` is the pointer anchor in view-local canvas +/// points — the pointer location during the gesture (hosts report gesture +/// events at the pointer, not at a midpoint between the fingers), so a +/// zoom can anchor under the cursor. Pinch is a view-global gesture — it +/// never routes through widgets — so this is the honest home for timeline +/// and canvas zoom. Only hosts with a pinch source emit it (macOS today). export interface PinchEvent { + readonly windowId: number; + readonly label: string; readonly phase: PinchPhase; readonly scale: number; readonly x: number; diff --git a/packages/core/src/emitter.ts b/packages/core/src/emitter.ts index e495818a..6c23e25f 100644 --- a/packages/core/src/emitter.ts +++ b/packages/core/src/emitter.ts @@ -1301,9 +1301,12 @@ export class Emitter { ); } } else { - // pinchMsg: { phase: , - // scale, x, y } — matched by NAME (the wiring maps the phase - // enum by member name and widens the numbers). + // pinchMsg: { windowId, label: string, + // phase: , scale, x, y } + // — matched by NAME (the wiring maps the phase enum by + // member name and widens the numbers; windowId/label are the + // source identity — x/y are view-local, so a coordinate + // without its view is not a position). const paramT = stmt.parameters.length === 1 && stmt.parameters[0].type ? this.table.resolveTypeNode(stmt.parameters[0].type!) : null; @@ -1319,16 +1322,17 @@ export class Emitter { phaseEnum.members.includes("end"); const ok = fields !== null && - fields.length === 4 && + fields.length === 6 && phaseOk && - (["scale", "x", "y"] as const).every((n) => { + fieldNamed(fields, "label")?.type.k === "string" && + (["windowId", "scale", "x", "y"] as const).every((n) => { const f = fieldNamed(fields, n); return f !== undefined && isNum(f.type.k); }); if (!ok) { this.fail( stmt, - '`pinchMsg` takes one `PinchEvent` parameter, exactly `{ phase: PinchPhase; scale: number; x: number; y: number }` where PinchPhase is a named "begin" | "change" | "end" alias', + '`pinchMsg` takes one `PinchEvent` parameter, exactly `{ windowId: number; label: string; phase: PinchPhase; scale: number; x: number; y: number }` where PinchPhase is a named "begin" | "change" | "end" alias', "NS1033", ); } diff --git a/packages/core/src/infer.ts b/packages/core/src/infer.ts index 4e7aa5a7..3fd1d369 100644 --- a/packages/core/src/infer.ts +++ b/packages/core/src/infer.ts @@ -509,8 +509,11 @@ export class IntInference { // keeps a core's `pinch.scale === 0` comparison from // int-claiming the slot (which would round every zoom product // to whole numbers); the integer side of such a comparison - // widens to f64 instead. frameMsg/keyMsg records keep their - // historical by-usage classing. + // widens to f64 instead. The loop marks EVERY field of the + // record, so any field the channel grows (windowId, the source + // identity, included) is boundary-classed without a new rule. + // frameMsg/keyMsg records keep their historical by-usage + // classing. if (stmt.name?.text === "pinchMsg") { for (const p of stmt.parameters) { if (!p.type) continue; diff --git a/packages/core/test/emitter.test.ts b/packages/core/test/emitter.test.ts index 93b3d716..70162886 100644 --- a/packages/core/test/emitter.test.ts +++ b/packages/core/test/emitter.test.ts @@ -1711,16 +1711,19 @@ test("pinchMsg emits with boundary-float record fields and holds its channel con // The good shape: the record's number fields are HOST values classed // f64 even against an integer-literal comparison (`scale === 0` must // never int-claim the slot — a rounded magnification delta would zero - // every zoom product), and the zoom chain fed from them floats too. + // every zoom product; the same wholesale classing covers the windowId + // identity field against its `=== 0` comparison), and the zoom chain + // fed from them floats too. const zig = emit(` export type PinchPhase = "begin" | "change" | "end"; -export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; readonly x: number; readonly y: number; } +export interface PinchEvent { readonly windowId: number; readonly label: string; readonly phase: PinchPhase; readonly scale: number; readonly x: number; readonly y: number; } export interface Model { readonly zoom: number; } export type Msg = | { readonly kind: "zoomed"; readonly factor: number } | { readonly kind: "noop" }; export function pinchMsg(pinch: PinchEvent): Msg | null { - if (pinch.phase !== "change" || pinch.scale === 0) return null; + if (pinch.phase !== "change" || pinch.scale === 0 || pinch.windowId === 0) return null; + if (pinch.label !== "board-canvas") return null; return { kind: "zoomed", factor: 1 + pinch.scale }; } export function initialModel(): Model { return { zoom: 1 }; } @@ -1732,10 +1735,13 @@ export function update(model: Model, msg: Msg): Model { } `); assert.match(zig, /scale: f64/); + assert.match(zig, /windowId: f64/); + assert.match(zig, /label: \[\]const u8/); assert.match(zig, /zoom: f64/); assert.match(zig, /pub fn pinchMsg/); - // The bad shape (missing anchor fields) is a taught NS1033. + // The bad shape (missing the anchor and identity fields) is a taught + // NS1033. const bad_pinch = transpile(` export type PinchPhase = "begin" | "change" | "end"; export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; } @@ -1748,4 +1754,20 @@ export function update(model: Model, msg: Msg): Model { `); assert.equal(bad_pinch.ok, false); assert.ok(bad_pinch.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(bad_pinch.diagnostics)); + + // The pre-identity 4-field shape is refused too: the channel record + // carries its source (windowId/label) — x/y are view-local, so a + // coordinate without its view is not a position. + const legacy_pinch = transpile(` +export type PinchPhase = "begin" | "change" | "end"; +export interface PinchEvent { readonly phase: PinchPhase; readonly scale: number; readonly x: number; readonly y: number; } +export interface Model { readonly w: number; } +export type Msg = { readonly kind: "a" } | { readonly kind: "b" }; +export function pinchMsg(pinch: PinchEvent): Msg | null { return null; } +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { case "a": return model; case "b": return model; } +} +`); + assert.equal(legacy_pinch.ok, false); + assert.ok(legacy_pinch.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(legacy_pinch.diagnostics)); }); diff --git a/src/platform/types.zig b/src/platform/types.zig index 8c6360cc..6afe4f57 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1611,6 +1611,14 @@ pub const PinchPhase = enum { /// deliberately: it is a view-global gesture (timeline/canvas zoom), the /// `on_key`-fallback shape rather than a widget-targeted event. pub const PinchEvent = struct { + /// Source identity: which window and gpu-surface view the gesture + /// happened on (the `GpuFrame` identity shape, because `x`/`y` are + /// view-local — a coordinate without its view is not a position). + /// Multi-window and multi-view apps tell pinches apart by these. + /// `label` is borrowed for the callback, like every event slice: + /// a model that keeps it must copy. + window_id: WindowId = 1, + label: []const u8 = "", phase: PinchPhase, /// Magnification DELTA for this event (NSEvent.magnification /// semantics): nonzero only on `.change`, 0 on begin/end. The diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index eaa62fec..6b6adc6f 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -367,7 +367,10 @@ pub fn TsUiApp(comptime core: type) type { } /// `Options.on_pinch` over the core's `pinchMsg(pinch)` export: - /// the emitted PinchEvent record — `phase` (the declared + /// the emitted PinchEvent record — `windowId`/`label` (the + /// source identity: `x`/`y` are view-local, so a coordinate + /// without its view is not a position; multi-window cores tell + /// pinches apart by these), `phase` (the declared /// begin/change/end alias, matched by member name), `scale` (the /// magnification DELTA on "change"; cumulative gesture scale is /// the product of `1 + scale`), and the `x`/`y` pointer anchor @@ -381,10 +384,11 @@ pub fn TsUiApp(comptime core: type) type { const PinchArg = params[0].type.?; comptime { const fields = @typeInfo(PinchArg).@"struct".fields; - if (fields.len != 4 or !@hasField(PinchArg, "phase") or !@hasField(PinchArg, "scale") or + if (fields.len != 6 or !@hasField(PinchArg, "windowId") or !@hasField(PinchArg, "label") or + !@hasField(PinchArg, "phase") or !@hasField(PinchArg, "scale") or !@hasField(PinchArg, "x") or !@hasField(PinchArg, "y")) { - @compileError("TsUiApp: pinchMsg's PinchEvent must be exactly { phase: \"begin\" | \"change\" | \"end\"; scale: number; x: number; y: number }"); + @compileError("TsUiApp: pinchMsg's PinchEvent must be exactly { windowId: number; label: string; phase: \"begin\" | \"change\" | \"end\"; scale: number; x: number; y: number }"); } const Phase = @FieldType(PinchArg, "phase"); const phase_info = @typeInfo(Phase); @@ -399,6 +403,12 @@ pub fn TsUiApp(comptime core: type) type { arg.phase = switch (pinch.phase) { inline else => |phase| @field(Phase, @tagName(phase)), }; + // The label slice stays borrowed exactly through the core + // call, like the Zig channel's contract: a Msg built from it + // commits (copies) on dispatch like every routed bytes + // payload. + arg.windowId = channelNum(@FieldType(PinchArg, "windowId"), @floatFromInt(pinch.window_id)); + arg.label = pinch.label; arg.scale = channelNum(@FieldType(PinchArg, "scale"), pinch.scale); arg.x = channelNum(@FieldType(PinchArg, "x"), pinch.x); arg.y = channelNum(@FieldType(PinchArg, "y"), pinch.y); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 2515b4b5..faf2203f 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -546,9 +546,12 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// on `change` — the cumulative gesture scale is the running /// product of `(1 + scale)` — and the pointer anchor in /// view-local canvas points (`x`/`y`, the zoom-at-cursor - /// anchor). Only hosts with a pinch source emit these - /// (macOS today); everywhere else the channel simply never - /// fires. + /// anchor). Every event names its source window and view + /// (`window_id`/`label`, the `on_frame` identity shape) — + /// view-local coordinates mean nothing without their view, + /// so multi-window apps tell pinches apart. Only hosts with + /// a pinch source emit these (macOS today); everywhere else + /// the channel simply never fires. on_pinch: ?*const fn (pinch: platform.PinchEvent) ?MsgT = null, /// Optional mapping from runtime timer events (started via /// `runtime.startTimer`) into messages. Framework-reserved timer @@ -3376,6 +3379,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe else => return, }; if (map(.{ + .window_id = input_event.window_id, + .label = input_event.label, .phase = phase, .scale = input_event.scale, .x = input_event.x, diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index d360663e..06fb75c2 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4053,6 +4053,12 @@ const ZoomModel = struct { ends: u32 = 0, anchor_x: f32 = 0, anchor_y: f32 = 0, + /// Source-identity mirrors: the window and view the last pinch + /// event named (x/y are view-local, so a coordinate without its + /// view is not a position — multi-window apps tell pinches apart + /// by these). + window_id: u64 = 0, + label: []const u8 = "", }; const ZoomMsg = union(enum) { @@ -4063,14 +4069,18 @@ const ZoomApp = ui_app_model.UiApp(ZoomModel, ZoomMsg); fn zoomUpdate(model: *ZoomModel, msg: ZoomMsg) void { switch (msg) { - .pinch => |pinch| switch (pinch.phase) { - .begin => { - model.begins += 1; - model.anchor_x = pinch.x; - model.anchor_y = pinch.y; - }, - .change => model.scale *= (1 + pinch.scale), - .end => model.ends += 1, + .pinch => |pinch| { + model.window_id = pinch.window_id; + model.label = pinch.label; + switch (pinch.phase) { + .begin => { + model.begins += 1; + model.anchor_x = pinch.x; + model.anchor_y = pinch.y; + }, + .change => model.scale *= (1 + pinch.scale), + .end => model.ends += 1, + } }, } } @@ -4228,3 +4238,118 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, pinch_bad)); try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); } + +const zoom_pair_main_views = [_]app_manifest.ShellView{ + .{ .label = "zoom-main-canvas", .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const zoom_pair_inspector_views = [_]app_manifest.ShellView{ + .{ .label = "zoom-inspector-canvas", .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const zoom_pair_windows = [_]app_manifest.ShellWindow{ .{ + .label = "main", + .title = "Zoom", + .width = 400, + .height = 300, + .views = &zoom_pair_main_views, +}, .{ + .label = "inspector", + .title = "Zoom Inspector", + .width = 300, + .height = 300, + .views = &zoom_pair_inspector_views, +} }; +const zoom_pair_scene: app_manifest.ShellConfig = .{ .windows = &zoom_pair_windows }; + +test "pinch identity distinguishes windows and views in the Msg" { + // Two windows, two gpu-surface views: the pinch channel forwards + // the source identity (window_id + view label) the journaled + // platform event already carries, so a multi-window app hears WHICH + // view a gesture zoomed — x/y are view-local, and a coordinate + // without its view is not a position. + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(ZoomApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = ZoomApp.init(std.heap.page_allocator, .{}, .{ + .name = "ui-app-zoom-pair", + .scene = zoom_pair_scene, + .canvas_label = "zoom-main-canvas", + .update = zoomUpdate, + .view = zoomView, + .on_pinch = zoomPinch, + }); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = "zoom-main-canvas", + .size = geometry.SizeF.init(400, 300), + .scale_factor = 2, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + + // A gesture on the main window's canvas names its source on every + // phase. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "zoom-main-canvas", + .kind = .pinch_begin, + .x = 100, + .y = 50, + } }); + try std.testing.expectEqual(@as(u64, 1), app_state.model.window_id); + try std.testing.expectEqualStrings("zoom-main-canvas", app_state.model.label); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "zoom-main-canvas", + .kind = .pinch_change, + .x = 100, + .y = 50, + .scale = 0.25, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "zoom-main-canvas", + .kind = .pinch_end, + .x = 100, + .y = 50, + } }); + try std.testing.expectEqual(@as(f32, 1.25), app_state.model.scale); + + // A gesture on the second window's view is distinguishable: same + // channel, different identity in the Msg. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 2, + .label = "zoom-inspector-canvas", + .kind = .pinch_begin, + .x = 10, + .y = 20, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 2, + .label = "zoom-inspector-canvas", + .kind = .pinch_change, + .x = 10, + .y = 20, + .scale = -0.5, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 2, + .label = "zoom-inspector-canvas", + .kind = .pinch_end, + .x = 10, + .y = 20, + } }); + try std.testing.expectEqual(@as(u64, 2), app_state.model.window_id); + try std.testing.expectEqualStrings("zoom-inspector-canvas", app_state.model.label); + // Both gestures compounded the one model's zoom (1.25 * 0.5, + // binary-exact) and every phase was heard. + try std.testing.expectEqual(@as(f32, 0.625), app_state.model.scale); + try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); + try std.testing.expectEqual(@as(u32, 2), app_state.model.ends); +} diff --git a/tests/ts-core/markup_e2e_tests.zig b/tests/ts-core/markup_e2e_tests.zig index a08ee0b2..fdfc9b14 100644 --- a/tests/ts-core/markup_e2e_tests.zig +++ b/tests/ts-core/markup_e2e_tests.zig @@ -303,6 +303,8 @@ test "the runtime markup interpreter builds the emitted model exactly like the c .draft = "", .canvasWidth = 0, .zoom = 1, + .zoomWindowId = 0, + .zoomFromBoard = false, .dark = false, .chromeTop = 0, }; @@ -483,8 +485,13 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { // pinchMsg: the trackpad pinch channel — the phase alias matches by // member name, begin/end gate to null in the core, and each change // compounds the zoom by (1 + delta): two +25% deltas land on the - // PRODUCT 1.5625, never a sum's 1.45. + // PRODUCT 1.5625, never a sum's 1.45. The source identity + // (windowId/label) rides the record into the core: this single-view + // fixture pins that the emitted core hears WHICH window and view + // the gesture happened on. try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoom); + try std.testing.expectEqual(@as(f64, 0), Bridge.model().zoomWindowId); + try std.testing.expect(!Bridge.model().zoomFromBoard); try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -517,6 +524,10 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { .y = 80, } }); try std.testing.expectEqual(@as(f64, 1.5625), Bridge.model().zoom); + // The core saw the source identity: window 1, this fixture's view + // label (the core compares `pinch.label === "ts-markup-canvas"`). + try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoomWindowId); + try std.testing.expect(Bridge.model().zoomFromBoard); // The automation pinch verb dispatches the same real events into the // transpiled core: one gesture whose change carries scale - 1. diff --git a/tests/ts-core/markup_fixture.ts b/tests/ts-core/markup_fixture.ts index 92d4bb0b..9f088b53 100644 --- a/tests/ts-core/markup_fixture.ts +++ b/tests/ts-core/markup_fixture.ts @@ -46,6 +46,12 @@ export interface Model { /// The pinch channel's cumulative zoom: the product of (1 + delta) /// across change events — the timeline-zoom derivation shape. readonly zoom: number; + /// The pinch channel's source-identity mirrors: the windowId of the + /// last zoom and whether its label named this fixture's canvas view + /// (x/y are view-local, so a coordinate without its view is not a + /// position — multi-window cores tell pinches apart by these). + readonly zoomWindowId: number; + readonly zoomFromBoard: boolean; /// The appearance channel's scheme mirror. readonly dark: boolean; /// The chrome channel's titlebar band mirror. @@ -62,7 +68,7 @@ export type Msg = | { readonly kind: "stamped"; readonly at: number } | { readonly kind: "draft_edit"; readonly edit: TextInputEvent } | { readonly kind: "canvas_resized"; readonly width: number } - | { readonly kind: "zoomed"; readonly factor: number } + | { readonly kind: "zoomed"; readonly factor: number; readonly windowId: number; readonly fromBoard: boolean } | { readonly kind: "appearance_changed"; readonly colorScheme: ColorScheme; readonly reduceMotion: boolean; readonly highContrast: boolean } | { readonly kind: "chrome_changed"; readonly insets: ChromeInsets; readonly buttons: ChromeButtons; readonly tabsProjected: boolean } | { readonly kind: "banner_set"; readonly value: Uint8Array }; @@ -86,10 +92,12 @@ export function keyMsg(key: KeyEvent): Msg | null { /// The pinch channel gates on the change phase (begin/end carry no /// delta): the model compounds the cumulative zoom as the product of -/// (1 + delta), the documented gesture-scale semantics. +/// (1 + delta), the documented gesture-scale semantics. The source +/// identity rides into the Msg so the model can pin which window and +/// view the gesture happened on. export function pinchMsg(pinch: PinchEvent): Msg | null { if (pinch.phase !== "change" || pinch.scale === 0) return null; - return { kind: "zoomed", factor: 1 + pinch.scale }; + return { kind: "zoomed", factor: 1 + pinch.scale, windowId: pinch.windowId, fromBoard: pinch.label === "ts-markup-canvas" }; } export const appearanceMsg = "appearance_changed"; @@ -108,6 +116,8 @@ export function initialModel(): Model { draft: new Uint8Array(0), canvasWidth: 0, zoom: 1, + zoomWindowId: 0, + zoomFromBoard: false, dark: false, chromeTop: 0, }; @@ -173,7 +183,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd] { case "canvas_resized": return { ...model, canvasWidth: msg.width }; case "zoomed": - return { ...model, zoom: model.zoom * msg.factor }; + return { ...model, zoom: model.zoom * msg.factor, zoomWindowId: msg.windowId, zoomFromBoard: msg.fromBoard }; case "appearance_changed": return { ...model, dark: msg.colorScheme === "dark" }; case "chrome_changed": From d454e759dabf5a49b566c64c62e1656577979477 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 09:54:17 -0500 Subject: [PATCH 11/14] Normalize AppKit's additive magnification into multiplicative pinch deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NSEvent.magnification is additive (Apple: gesture total = 1 + sum), so forwarding raw chunks made the product of (1 + delta) depend on event chunking; the macOS host now tracks the gesture's additive sum in f64 and emits per-event ratios, so the product equals Apple's total no matter how the driver sliced the gesture — the app rule is unchanged (zoom *= 1 + scale, memoryless). - A sum crossing -1 (a pinch cannot invert through zero scale) clamps at a floor of 2^-10, keeping every emitted factor positive; the arithmetic lives in tiny pure functions pinned by a file-contains step and mirrored runnably in a Zig test (chunking invariance, terminal-delta participation, round-trip to unity, clamp). - Product pins across ui_app/markup-e2e/session tests update to the normalized truths (1.5/1.75 instead of 1.5625/1.953125), and the widget-pinch docs now say plainly that is the gesture's final multiplicative zoom — the verb synthesizes events downstream of AppKit, untouched by the normalization. --- build.zig | 43 +++++-- changelog.d/trackpad-pinch.md | 4 +- docs/src/app/automation/page.mdx | 2 +- docs/src/app/cli/page.mdx | 2 +- docs/src/app/native-ui/page.mdx | 4 +- packages/core/sdk/events.ts | 10 +- skill-data/automation/SKILL.md | 2 +- src/automation/protocol.zig | 5 +- src/platform/macos/appkit_host.h | 10 +- src/platform/macos/appkit_host.m | 100 ++++++++++++---- src/platform/macos/root.zig | 130 ++++++++++++++++++++- src/platform/types.zig | 27 +++-- src/runtime/automation_commands.zig | 11 +- src/runtime/automation_widget_dispatch.zig | 8 +- src/runtime/session_tests.zig | 30 +++-- src/runtime/ts_ui_app.zig | 5 +- src/runtime/ui_app.zig | 8 +- src/runtime/ui_app_tests.zig | 35 +++--- tests/ts-core/markup_e2e_tests.zig | 30 +++-- tools/native-sdk/automation.zig | 2 +- 20 files changed, 367 insertions(+), 101 deletions(-) diff --git a/build.zig b/build.zig index 187f23b3..cbb59745 100644 --- a/build.zig +++ b/build.zig @@ -636,20 +636,45 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (void)requestRetainedCanvasFrame" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[self requestRetainedCanvasFrame];" }, }); - addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-terminal-delta", "Verify the macOS pinch stream forwards a terminal Ended/Cancelled magnification as a change before the end marker", &.{ + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-terminal-delta", "Verify the macOS pinch stream folds a terminal Ended/Cancelled magnification into the gesture before the end marker", &.{ // AppKit documents every magnifyWithEvent: as carrying the // magnification since the previous event — the terminal one - // included. The Ended/Cancelled branch must forward a nonzero - // terminal delta as PINCH_CHANGE before PINCH_END (zero delta: - // end only), or the cumulative product of (1 + delta) diverges - // from what the OS delivered. Cancelled deliberately shares the - // path: pinch applies deltas incrementally with no rollback. + // included. The Ended/Cancelled branch must fold a nonzero + // terminal magnification into the gesture's running sum and + // forward it as PINCH_CHANGE before PINCH_END (zero + // magnification: end only), or the cumulative product of + // (1 + delta) diverges from what the OS delivered. Cancelled + // deliberately shares the path: pinch applies deltas + // incrementally with no rollback. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) {" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "const double terminalDelta = event.magnification;" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (terminalDelta != 0) {" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "PINCH_CHANGE event:event magnification:terminalDelta];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[self emitNormalizedPinchChangeForEvent:event];\n [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "PINCH_END event:event magnification:0];" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-additive-normalization", "Verify the macOS host normalizes AppKit's additive magnification into multiplicative pinch deltas", &.{ + // NSEvent.magnification is ADDITIVE (Apple: add each event's + // magnification; gesture total = 1 + Σmagnification) while the + // wire contract is multiplicative (product of (1 + delta)). + // The host must track the gesture's additive running sum in f64 + // and emit per-event ratios, or the cumulative product depends + // on how the driver chunked the gesture (two +0.25 chunks: + // 1.5625; one +0.5 chunk: 1.5). The arithmetic itself is pinned + // runnably by the Zig mirror test in src/platform/macos/root.zig + // ("... normalization telescopes additive magnification ..."); + // these text pins hold the ObjC side to the mirrored formula. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static const double NativeSdkPinchMagnificationSumFloor = -1.0 + 0x1p-10;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return sum < NativeSdkPinchMagnificationSumFloor ? NativeSdkPinchMagnificationSumFloor : sum;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return (1.0 + sum) / (1.0 + previousSum) - 1.0;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkClampedPinchMagnificationSum(previousSum + event.magnification);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkNormalizedPinchDelta(previousSum, sum);" }, + // The sum resets at Began and at the synthesized begin (a + // gesture already in flight when the view becomes the hit + // target), and the normalization lives ONLY in the AppKit + // handler: synthesized automation events enter downstream of + // magnifyWithEvent: and are never re-normalized. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "self.pinchMagnificationSum = 0;" }, + .{ .path = "src/platform/macos/root.zig", .pattern = "fn normalizedDelta(previous_sum: f64, sum: f64) f64 {" }, + .{ .path = "src/platform/macos/root.zig", .pattern = "return (1.0 + sum) / (1.0 + previous_sum) - 1.0;" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-input-paces-retained-canvas", "Verify GPU input frame requests are paced to the display interval", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRetainedFrameIntervalNanoseconds" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "retainedFrameLastEmitNs" }, diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md index 3e9ffc0d..2d33c685 100644 --- a/changelog.d/trackpad-pinch.md +++ b/changelog.d/trackpad-pinch.md @@ -1,3 +1,3 @@ -feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries the magnification delta (cumulative gesture scale is the product of `1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); every event names its source window and view (`window_id`/`label` in Zig, `windowId`/`label` in TypeScript), so multi-window apps tell pinches apart; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. Windows precision-touchpad and GTK gesture sources are staged follow-ups. -- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (one change carrying `scale - 1`, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. +feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries a multiplicative delta (cumulative gesture scale is the product of `1 + scale`, applied memorylessly — `zoom *= 1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); every event names its source window and view (`window_id`/`label` in Zig, `windowId`/`label` in TypeScript), so multi-window apps tell pinches apart; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. The macOS host normalizes AppKit's ADDITIVE `NSEvent.magnification` (Apple prescribes adding each event's magnification; the gesture total is 1 + the sum) into these multiplicative factors, so the product of `1 + scale` over a gesture equals Apple's total no matter how the driver chunked the gesture into events — the rule for apps is unchanged (multiply by `1 + scale`, per event, memorylessly); only the host's honesty improved, and the final zoom is no longer chunking-dependent. A gesture whose additive sum would cross -1 (a physical impossibility — a pinch cannot invert through zero scale) clamps at a floor scale of 2^-10, keeping every emitted factor positive. Windows precision-touchpad and GTK gesture sources are staged follow-ups. +- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (`` is the gesture's FINAL multiplicative zoom — one change carrying `scale - 1`, chunking-trivial, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. - **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build. diff --git a/docs/src/app/automation/page.mdx b/docs/src/app/automation/page.mdx index d6760779..02e27e3c 100644 --- a/docs/src/app/automation/page.mdx +++ b/docs/src/app/automation/page.mdx @@ -139,7 +139,7 @@ The runtime watches the command queue and processes these actions: widget-pinch <view-label> <scale> [<x> <y>] - Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1 so the cumulative gesture scale (the product of 1 + delta) lands exactly on scale (1.5 zooms in 50%, 0.5 zooms out to half). The anchor point defaults to the view center + Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1. scale is the FINAL multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out to half) — a single change is chunking-trivial, so the cumulative product of 1 + delta lands exactly on it. The anchor point defaults to the view center shortcut <id> diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx index 86006d9f..ba72c5c6 100644 --- a/docs/src/app/cli/page.mdx +++ b/docs/src/app/cli/page.mdx @@ -234,7 +234,7 @@ Interact with the automation server of a running automation-enabled app. See [Au
automate widget-key <view-label> <key> [<text>]
Dispatch key input to the focused retained canvas widget.
automate widget-pinch <view-label> <scale> [<x> <y>]
-
Dispatch a trackpad pinch gesture at a gpu-surface view (cumulative scale; the anchor point defaults to the view center).
+
Dispatch a trackpad pinch gesture at a gpu-surface view (scale is the final multiplicative zoom for the gesture; the anchor point defaults to the view center).
automate shortcut <id>
Dispatch a shortcut command event.
automate tray-action <item-id>
diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 77b8f62b..b838c8ba 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -276,7 +276,7 @@ One rule bridges the registers: **quiet focus on a plain list row routes no keys ## Trackpad pinch -Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. Every event also names its source — `window_id` and the view `label` in Zig, `windowId`/`label` in TypeScript — because view-local coordinates mean nothing without their view: multi-window apps tell pinches apart by them. +Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the **multiplicative delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum — apply it memorylessly (`zoom *= 1 + scale`), no gesture-start bookkeeping. Hosts whose OS reports pinch additively normalize before emitting: AppKit's `NSEvent.magnification` is additive (Apple prescribes adding each event's magnification; the gesture total is 1 + the sum), and the macOS host converts each event into the ratio it moved that total by, so the product of `1 + scale` over a gesture equals Apple's total exactly — the final zoom never depends on how the driver chunked the gesture into events. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. Every event also names its source — `window_id` and the view `label` in Zig, `windowId`/`label` in TypeScript — because view-local coordinates mean nothing without their view: multi-window apps tell pinches apart by them. ```zig // options: .on_pinch = onPinch, @@ -299,7 +299,7 @@ export function pinchMsg(pinch: PinchEvent): Msg | null { } ``` -macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — the cumulative scale in one gesture, anchor point defaulting to the view center. +macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — `` is the final multiplicative zoom for the gesture (chunking-trivial: one change event whose product lands exactly on it), anchor point defaulting to the view center. ## Native scrolling and context menus diff --git a/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index 6e513505..d916c419 100644 --- a/packages/core/sdk/events.ts +++ b/packages/core/sdk/events.ts @@ -87,9 +87,13 @@ export type PinchPhase = "begin" | "change" | "end"; /// gesture, phase-explicit. `windowId`/`label` name the source window and /// gpu-surface view — `x`/`y` are view-local, so a coordinate without its /// view is not a position, and multi-window apps tell pinches apart by -/// these. `scale` is the magnification DELTA for this event (nonzero only -/// on "change"; the cumulative gesture scale is the running product of -/// `1 + scale`), and `x`/`y` is the pointer anchor in view-local canvas +/// these. `scale` is the MULTIPLICATIVE delta for this event (nonzero +/// only on "change"; the cumulative gesture scale is the running product +/// of `1 + scale` — apply it memorylessly, `zoom *= 1 + scale`, no +/// gesture-start bookkeeping; hosts normalize additive OS reporting like +/// AppKit's additive `NSEvent.magnification` into these factors, so the +/// product over a gesture equals what the OS measured regardless of how +/// events were chunked), and `x`/`y` is the pointer anchor in view-local canvas /// points — the pointer location during the gesture (hosts report gesture /// events at the pointer, not at a midpoint between the fingers), so a /// zoom can anchor under the cursor. Pinch is a view-global gesture — it diff --git a/skill-data/automation/SKILL.md b/skill-data/automation/SKILL.md index a7b2f6ab..9ec6af29 100644 --- a/skill-data/automation/SKILL.md +++ b/skill-data/automation/SKILL.md @@ -115,7 +115,7 @@ Semantics: 8. Use `native automate widget-drag [start-y-ratio end-y-ratio]` for continuous pointer controls. 9. Use `native automate widget-wheel ` for retained widget scroll input. Wheel targets must be interactive/scrollable widgets — a plain layout column or text node is not a wheel target; aim at the scroll/list widget id from the snapshot. Failures land in the snapshot as named reasons: `error event=automation.widget_wheel name=WheelTargetUnknown|WheelTargetNotInteractive|WheelTargetHasEmptyBounds detail=""`. 10. Use `native automate widget-key [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS). -11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1` so the cumulative gesture scale (the product of `1 + delta`) lands exactly on `` — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). +11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1`. `` is the FINAL multiplicative zoom for the gesture — a single change is chunking-trivial, so the cumulative gesture scale (the product of `1 + delta`) lands exactly on it — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). 12. Use `native automate screenshot [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-.png` (the CLI prints the artifact path and waits for the file). 13. Use `native automate tray-action ` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`. 14. Use `native automate reload` to request a WebView reload. diff --git a/src/automation/protocol.zig b/src/automation/protocol.zig index 21b9bc37..a8a08902 100644 --- a/src/automation/protocol.zig +++ b/src/automation/protocol.zig @@ -100,8 +100,9 @@ pub const Action = enum { /// pinch gesture against a gpu-surface view — the real /// `pinch_begin`/`pinch_change`/`pinch_end` platform events, with /// one change carrying `scale - 1` so the cumulative gesture scale - /// (the product of `1 + delta`) lands exactly on ``. The - /// anchor point defaults to the view center. + /// (the product of `1 + delta`) lands exactly on ``, the + /// gesture's FINAL multiplicative zoom. The anchor point defaults + /// to the view center. widget_pinch, menu_command, shortcut, diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index ad1fa70a..0a166f99 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -77,9 +77,13 @@ typedef enum { NATIVE_SDK_APPKIT_GPU_INPUT_IME_CANCEL_COMPOSITION = 10, NATIVE_SDK_APPKIT_GPU_INPUT_POINTER_CANCEL = 11, /* Trackpad pinch phases (magnifyWithEvent:). The event's `scale` - * field carries the per-event magnification DELTA on PINCH_CHANGE - * (NSEvent.magnification semantics; cumulative gesture scale is the - * product of (1 + delta)); 0 on begin/end. The pointer anchor rides + * field carries the per-event MULTIPLICATIVE delta on PINCH_CHANGE + * (cumulative gesture scale is the product of (1 + delta)); 0 on + * begin/end. NSEvent.magnification is ADDITIVE (Apple: add each + * event's magnification; gesture total = 1 + Σmagnification), so + * the host normalizes before emitting: the product of (1 + delta) + * over a gesture equals Apple's 1 + Σmagnification, invariant to + * how the driver chunked the gesture. The pointer anchor rides * x/y (the event's locationInWindow — gesture events report the * pointer location, not a midpoint between the fingers). */ NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN = 12, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 9f0bcbfc..328233e3 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -570,6 +570,7 @@ @interface NativeSdkMetalSurfaceView : NSView @property(nonatomic, assign) uint64_t scrollDriverEventLastEmitNs; @property(nonatomic, assign) BOOL controlClickActive; @property(nonatomic, assign) BOOL pinchGestureActive; +@property(nonatomic, assign) double pinchMagnificationSum; - (void)configureWithHost:(NativeSdkAppKitHost *)host windowId:(uint64_t)windowId label:(NSString *)label; - (BOOL)isAvailable; - (void)updateDrawableSize; @@ -615,6 +616,7 @@ - (void)emitQueuedPointerMotionInputEvent; - (void)queueScrollInputEvent:(NSEvent *)event deltaX:(double)deltaX deltaY:(double)deltaY; - (void)emitQueuedScrollInputEvent; - (void)emitPinchInputEventWithKind:(NSInteger)kind event:(NSEvent *)event magnification:(double)magnification; +- (void)emitNormalizedPinchChangeForEvent:(NSEvent *)event; - (void)emitInputEventWithKind:(NSInteger)kind point:(NSPoint)point timestampNs:(uint64_t)timestampNs modifiers:(uint32_t)modifiers keyText:(NSString *)keyText inputText:(NSString *)inputText button:(NSInteger)button deltaX:(double)deltaX deltaY:(double)deltaY; - (void)emitSyntheticKeyDownWithKey:(NSString *)key modifiers:(uint32_t)modifiers; - (void)updateSurfaceTrackingArea; @@ -5543,46 +5545,56 @@ - (void)magnifyWithEvent:(NSEvent *)event { // the cumulative gesture scale is the PRODUCT of (1 + delta), and // summing coalesced deltas would drift from what the fingers did. // + // AppKit's NSEvent.magnification is ADDITIVE: Apple prescribes + // adding each event's magnification to the current scale, so the + // gesture's total is 1 + Σmagnification no matter how the driver + // chunks it into events. Our wire contract is MULTIPLICATIVE + // (cumulative scale = product of (1 + delta)), so the host + // normalizes here — emitNormalizedPinchChangeForEvent: tracks the + // gesture's additive running sum and emits the per-event ratio — + // and the product of (1 + delta) over the gesture equals Apple's + // 1 + Σmagnification, chunking-invariant. Forwarding the raw + // additive chunks instead would make the product depend on how + // AppKit happened to slice the gesture (two +0.25 chunks: 1.5625; + // one +0.5 chunk: 1.5) — driver/timing-dependent zoom. + // // EVERY magnifyWithEvent: carries the magnification since the // previous event — the terminal (Ended/Cancelled) one included — - // so each branch below must forward a nonzero delta as a - // PINCH_CHANGE or the cumulative product diverges from what the - // OS delivered. + // so each branch below must fold a nonzero magnification into the + // sum and forward it as a PINCH_CHANGE or the cumulative product + // diverges from what the OS delivered. const NSEventPhase phase = event.phase; if (phase & NSEventPhaseBegan) { [self emitQueuedPointerMotionInputEvent]; self.pinchGestureActive = YES; + self.pinchMagnificationSum = 0; [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; - if (event.magnification != 0) { - [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:event.magnification]; - } + [self emitNormalizedPinchChangeForEvent:event]; return; } if (phase & NSEventPhaseChanged) { if (!self.pinchGestureActive) { // A gesture already in flight when this view became the - // hit target skips Began; open the stream cleanly anyway. + // hit target skips Began; open the stream cleanly anyway + // (synthesized begin, so the running sum resets here too). self.pinchGestureActive = YES; + self.pinchMagnificationSum = 0; [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; } - if (event.magnification != 0) { - [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:event.magnification]; - } + [self emitNormalizedPinchChangeForEvent:event]; return; } if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) { if (!self.pinchGestureActive) return; self.pinchGestureActive = NO; - // The terminal event's nonzero delta emits as a final - // PINCH_CHANGE before the end marker (a zero-delta terminal - // event emits only PINCH_END). Cancelled takes the same path - // deliberately: our model applies deltas incrementally with no - // rollback (see above), so the honest stream reports every - // delta the OS measured, then says the gesture is over. - const double terminalDelta = event.magnification; - if (terminalDelta != 0) { - [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:terminalDelta]; - } + // The terminal event's nonzero magnification joins the running + // sum and emits as a final PINCH_CHANGE before the end marker + // (a zero-magnification terminal event emits only PINCH_END). + // Cancelled takes the same path deliberately: our model applies + // deltas incrementally with no rollback (see above), so the + // honest stream reports every delta the OS measured, then says + // the gesture is over. + [self emitNormalizedPinchChangeForEvent:event]; [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0]; return; } @@ -5769,10 +5781,56 @@ - (void)emitQueuedScrollInputEvent { deltaY:deltaY]; } +// --- Pinch normalization ----------------------------------------------- +// AppKit's NSEvent.magnification is ADDITIVE (Apple: add each event's +// magnification to the current scale; gesture total = 1 + Σmagnification). +// The wire contract is MULTIPLICATIVE (cumulative scale = the product of +// (1 + delta)), so the host converts each additive chunk into the ratio it +// moved the gesture total by. The arithmetic lives in tiny pure functions +// so the Zig-side test can mirror it verbatim (documentation-by- +// computation; ObjC is unreachable from the Zig test graph). +// +// A pinch-in can drive the additive sum negative, but a real gesture can +// never invert through zero scale — a sum at or below -1 would blow the +// ratio up (division by 1 + S -> 0) or flip its sign. Clamp the running +// sum to a floor just above -1: the gesture's cumulative scale bottoms +// out at 2^-10 (~0.001x, exactly representable) and every emitted factor +// keeps 1 + delta > 0. +static const double NativeSdkPinchMagnificationSumFloor = -1.0 + 0x1p-10; + +static double NativeSdkClampedPinchMagnificationSum(double sum) { + return sum < NativeSdkPinchMagnificationSumFloor ? NativeSdkPinchMagnificationSumFloor : sum; +} + +// The multiplicative delta that advances the gesture total from +// (1 + previousSum) to (1 + sum): the product of (1 + delta) over the +// gesture telescopes to 1 + Σmagnification — Apple's total — exactly in +// this f64 math, so the app-side product is invariant to how the driver +// chunked the gesture into events (up to f32 wire rounding per event). +static double NativeSdkNormalizedPinchDelta(double previousSum, double sum) { + return (1.0 + sum) / (1.0 + previousSum) - 1.0; +} + +// Fold one magnify event's additive magnification into the gesture's +// running sum and emit the normalized multiplicative delta (nothing to +// emit when the event measured no magnification, or when the floor clamp +// swallowed the whole chunk). +- (void)emitNormalizedPinchChangeForEvent:(NSEvent *)event { + if (event.magnification == 0) return; + const double previousSum = self.pinchMagnificationSum; + const double sum = NativeSdkClampedPinchMagnificationSum(previousSum + event.magnification); + self.pinchMagnificationSum = sum; + const double delta = NativeSdkNormalizedPinchDelta(previousSum, sum); + if (delta == 0) return; + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:delta]; +} + // Pinch input: the shared input-emit shape (converted point, top-left // origin flip, host timestamp, modifier flags) with the magnification // delta riding the event's `scale` field — unused (zero) on every other -// input kind, so the runtime reads it unconditionally. The x/y point is +// input kind, so the runtime reads it unconditionally. The delta is +// computed in f64 here and rounds to the platform event's f32 `scale` +// at the Zig-side ABI conversion. The x/y point is // the POINTER anchor: AppKit reports gesture events at locationInWindow // (the pointer location), never a midpoint between the fingers — NSTouch // positions are trackpad-normalized and have no view-space meaning, and diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 32969fbe..03df1739 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -906,8 +906,10 @@ fn gpuSurfaceInputEventFromAppKitEvent(event: *const AppKitEvent) platform_mod.G .text = event.input_text[0..event.input_text_len], .composition_cursor = if (event.has_composition_cursor != 0) event.composition_cursor else null, .modifiers = shortcutModifiersFromFlags(event.shortcut_modifiers), - // The pinch magnification delta rides the ABI event's `scale` - // field (zero on every non-pinch input emission). + // The pinch delta rides the ABI event's `scale` field (zero on + // every non-pinch input emission). The host already normalized + // AppKit's additive magnification into a multiplicative factor + // in f64; this cast is the wire's f32 rounding point. .scale = @floatCast(event.scale), }; } @@ -2276,6 +2278,130 @@ test "mac gpu surface input maps pinch phases and carries the magnification delt try std.testing.expectEqual(@as(f32, 0), end.scale); } +/// MIRROR of appkit_host.m's pinch normalization, not a link: the ObjC +/// host is unreachable from the Zig test graph, so this restates +/// `NativeSdkClampedPinchMagnificationSum` / `NativeSdkNormalizedPinchDelta` +/// verbatim as documentation-by-computation (the file-contains step +/// `test-appkit-pinch-additive-normalization` pins the ObjC text; this +/// pins the arithmetic). Keep the two in lockstep. +const PinchNormalizationMirror = struct { + /// Mirrors NativeSdkPinchMagnificationSumFloor. + const sum_floor: f64 = -1.0 + 0x1p-10; + + /// Mirrors NativeSdkClampedPinchMagnificationSum. + fn clampedSum(sum: f64) f64 { + return if (sum < sum_floor) sum_floor else sum; + } + + /// Mirrors NativeSdkNormalizedPinchDelta. + fn normalizedDelta(previous_sum: f64, sum: f64) f64 { + return (1.0 + sum) / (1.0 + previous_sum) - 1.0; + } + + /// The host's per-gesture loop over raw additive AppKit + /// magnification chunks: fold each into the f64 running sum, emit + /// the multiplicative delta rounded to the f32 wire (the + /// `gpuSurfaceInputEventFromAppKitEvent` cast), skip zero deltas. + /// Returns the number of deltas emitted. + fn emittedDeltas(chunks: []const f64, deltas: []f32) usize { + var sum: f64 = 0; + var count: usize = 0; + for (chunks) |chunk| { + if (chunk == 0) continue; + const previous_sum = sum; + sum = clampedSum(previous_sum + chunk); + const delta = normalizedDelta(previous_sum, sum); + if (delta == 0) continue; + deltas[count] = @floatCast(delta); + count += 1; + } + return count; + } + + /// The app-side accumulation rule, f32 like the reference models: + /// `scale *= 1 + delta` per change event, memoryless. + fn product(deltas: []const f32) f32 { + var scale: f32 = 1; + for (deltas) |delta| scale *= 1 + delta; + return scale; + } +}; + +test "mac appkit pinch normalization telescopes additive magnification into a chunking-invariant product" { + const Mirror = PinchNormalizationMirror; + var deltas: [8]f32 = undefined; + + // Two raw +0.25 AppKit chunks: the running sums are 0.25 then 0.5, + // so the emitted factors are 1.25 then 1.5/1.25 = 1.2 — the product + // is Apple's 1 + 0.5 = 1.5, never the 1.5625 that forwarding the + // raw chunks would compound to. 0.2 is not exactly representable in + // f32 (the wire rounds it to 0.20000000298...), but the f32 literal + // 0.2 rounds identically, so the delta pin is exact — and the f32 + // product 1.25 * (1 + fl32(0.2)) = 1.5000000596... rounds to + // exactly 1.5 (ties-to-even lands on 1.5's even mantissa), so the + // product pin is exact too. + const two_quarters = Mirror.emittedDeltas(&.{ 0.25, 0.25 }, &deltas); + try std.testing.expectEqual(@as(usize, 2), two_quarters); + try std.testing.expectEqual(@as(f32, 0.25), deltas[0]); + try std.testing.expectEqual(@as(f32, 0.2), deltas[1]); + try std.testing.expectEqual(@as(f32, 1.5), Mirror.product(deltas[0..two_quarters])); + + // Chunking invariance — the property this normalization exists for: + // the same additive total (+0.5) delivered as one chunk, two +0.25 + // chunks, or four +0.125 chunks produces the same final product. + // The chunk values are exactly representable, and each of the three + // f32 products happens to round exactly to 1.5, so the agreement + // assertion is exact (the general guarantee is one f32 ulp per + // emitted event). Forwarding raw chunks instead would give 1.5 vs + // 1.5625 vs ~1.6018 — driver/timing-dependent zoom. + const one_chunk = Mirror.emittedDeltas(&.{0.5}, &deltas); + const final_one = Mirror.product(deltas[0..one_chunk]); + const four_chunks = Mirror.emittedDeltas(&.{ 0.125, 0.125, 0.125, 0.125 }, &deltas); + const final_four = Mirror.product(deltas[0..four_chunks]); + try std.testing.expectEqual(@as(usize, 1), one_chunk); + try std.testing.expectEqual(@as(usize, 4), four_chunks); + try std.testing.expectEqual(@as(f32, 1.5), final_one); + try std.testing.expectEqual(final_one, final_four); + + // The terminal-delta path participates: an Ended event carrying + // +0.25 after a +0.25 change joins the same running sum, so the + // final product is 1.5 exactly as if both chunks were Changed + // events (the host folds the terminal magnification into the sum + // before emitting the last change). + const terminal = Mirror.emittedDeltas(&.{ 0.25, 0.25 }, &deltas); + try std.testing.expectEqual(@as(f32, 1.5), Mirror.product(deltas[0..terminal])); + + // A gesture that returns its additive sum to zero returns the + // product to unity: raw +0.25 then -0.25 emits factors 1.25 and + // 1/1.25 = 0.8, and the f32 product 1.25 * (1 - fl32(0.2)) rounds + // exactly to 1.0 (the session replay reference pins the same fact + // through the app channel). + const round_trip = Mirror.emittedDeltas(&.{ 0.25, -0.25 }, &deltas); + try std.testing.expectEqual(@as(usize, 2), round_trip); + try std.testing.expectEqual(@as(f32, -0.2), deltas[1]); + try std.testing.expectEqual(@as(f32, 1.0), Mirror.product(deltas[0..round_trip])); + + // Degenerate clamp: a strong pinch-in summing below -1 (here + // -0.5 then -0.75 = -1.25) would invert the gesture through zero + // scale — impossible physically, and a sum at -1 divides by zero. + // The running sum clamps to the floor just above -1: every emitted + // factor keeps 1 + delta > 0 (the stream never flips sign) and the + // product bottoms out at the floor's scale, 2^-10 exactly (both + // emitted deltas, -0.5 and -(1 - 2^-9), are f32-exact). + const clamped = Mirror.emittedDeltas(&.{ -0.5, -0.75 }, &deltas); + try std.testing.expectEqual(@as(usize, 2), clamped); + try std.testing.expectEqual(@as(f32, -0.5), deltas[0]); + try std.testing.expectEqual(@as(f32, -0.998046875), deltas[1]); + for (deltas[0..clamped]) |delta| try std.testing.expect(1 + delta > 0); + try std.testing.expectEqual(@as(f32, 0.0009765625), Mirror.product(deltas[0..clamped])); + + // A further pinch-in chunk against the floor is fully swallowed by + // the clamp: the sum cannot move, the delta is zero, nothing emits. + const swallowed = Mirror.emittedDeltas(&.{ -0.5, -0.75, -0.5 }, &deltas); + try std.testing.expectEqual(@as(usize, 2), swallowed); + try std.testing.expectEqual(@as(f32, 0.0009765625), Mirror.product(deltas[0..swallowed])); +} + 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 6afe4f57..620ef086 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1578,10 +1578,17 @@ pub const GpuSurfaceInputEvent = struct { text: []const u8 = "", composition_cursor: ?usize = null, modifiers: ShortcutModifiers = .{}, - /// Pinch magnification DELTA for this event (NSEvent.magnification - /// semantics): nonzero only on `pinch_change`, 0 on begin/end. The - /// cumulative gesture scale is the running product of `(1 + scale)` - /// across the gesture's change events. The pointer anchor rides + /// Pinch MULTIPLICATIVE delta for this event: nonzero only on + /// `pinch_change`, 0 on begin/end. The cumulative gesture scale is + /// the running product of `(1 + scale)` across the gesture's change + /// events. Hosts whose OS reports pinch additively normalize before + /// emitting — AppKit's `NSEvent.magnification` is additive (Apple: + /// add each event's magnification; gesture total is + /// 1 + Σmagnification), and the macOS host converts each chunk into + /// the ratio it moved that total by, so the product of `(1 + scale)` + /// over a gesture equals Apple's 1 + Σmagnification (up to f32 wire + /// rounding) no matter how the driver chunked the gesture into + /// events. The pointer anchor rides /// `x`/`y` (view-local, same space as pointer events) — the pointer /// location during the gesture, NOT a midpoint between the fingers: /// hosts report gesture events at the pointer (AppKit's @@ -1620,10 +1627,14 @@ pub const PinchEvent = struct { window_id: WindowId = 1, label: []const u8 = "", phase: PinchPhase, - /// Magnification DELTA for this event (NSEvent.magnification - /// semantics): nonzero only on `.change`, 0 on begin/end. The - /// cumulative gesture scale is the running product of `(1 + scale)` - /// across the gesture's change events. + /// MULTIPLICATIVE delta for this event: nonzero only on `.change`, + /// 0 on begin/end. The cumulative gesture scale is the running + /// product of `(1 + scale)` across the gesture's change events — + /// apply it memorylessly (`zoom *= 1 + scale`), no gesture-start + /// bookkeeping. Hosts normalize additive OS reporting (AppKit's + /// additive `NSEvent.magnification`) into these factors, so the + /// product over a gesture equals what the OS measured regardless of + /// event chunking. scale: f32 = 0, /// The pointer anchor, view-local canvas points (the pointer-event /// space): where the zoom should anchor. This is the POINTER diff --git a/src/runtime/automation_commands.zig b/src/runtime/automation_commands.zig index 9b1e60ab..5e721419 100644 --- a/src/runtime/automation_commands.zig +++ b/src/runtime/automation_commands.zig @@ -59,10 +59,13 @@ pub const AutomationWidgetKey = struct { }; /// `widget-pinch [ ]`: a trackpad pinch -/// gesture against a gpu-surface view. `scale` is the CUMULATIVE gesture -/// scale (1.5 zooms in 50%, 0.5 zooms out to half) — the dispatch -/// synthesizes begin, one change carrying `scale - 1`, and end, so the -/// product of `(1 + delta)` lands exactly on `scale`. The optional +/// gesture against a gpu-surface view. `scale` is the FINAL +/// multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out +/// to half) — the dispatch synthesizes begin, one change carrying +/// `scale - 1`, and end, so the product of `(1 + delta)` lands exactly +/// on `scale` (a single change is chunking-trivial: the host-side +/// additive normalization only concerns real AppKit event streams, +/// upstream of these synthesized platform events). The optional /// anchor point is view-local canvas points (where the zoom anchors, /// like the pointer position under a real pinch); omitted, it defaults /// to the view center. diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 5254683a..c3ea2b4a 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -287,8 +287,12 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { /// Drive a trackpad pinch through the real platform-event path: /// `pinch_begin`, one `pinch_change` carrying `scale - 1` (so /// the cumulative product of `1 + delta` lands exactly on the - /// commanded scale), and `pinch_end`, all at the same anchor - /// point. + /// commanded scale — the gesture's FINAL multiplicative zoom; + /// one change is chunking-trivial), and `pinch_end`, all at the + /// same anchor point. These synthesized events enter DOWNSTREAM + /// of the AppKit host, so the host's additive-magnification + /// normalization (appkit_host.m, real gestures only) never + /// touches them. /// Plain input synthesis, the `widget-key` discipline: every /// event journals as itself and replays through the same /// dispatch — no accessibility-action record, because pinch is diff --git a/src/runtime/session_tests.zig b/src/runtime/session_tests.zig index b22e6fd7..da50173d 100644 --- a/src/runtime/session_tests.zig +++ b/src/runtime/session_tests.zig @@ -409,14 +409,19 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la } }); try std.testing.expectEqualStrings("line oneline two", app_state.model.queryText()); - // A trackpad pinch, twice over: the raw journaled phase stream (the - // macOS host shape — begin, two deltas, end; the +25% then -25% - // deltas compound as a PRODUCT, 1.25 * 0.75 = 0.9375, never a sum's - // 1.0), then the automation verb's synthesized gesture, which - // journals the same leaf gpu_surface_input events. Replay must - // re-derive the identical cumulative zoom from the journaled scale - // fields alone. Every value here is binary-exact in f32, so the - // model equality below is exact, not approximate. + // A trackpad pinch, twice over: the journaled phase stream (the + // macOS host shape — begin, two deltas, end; the deltas are what + // the normalizing host emits for raw additive magnifications +0.25 + // then -0.25: factors 1.25 then 1/1.25 = 0.8, so the PRODUCT + // returns to 1.0 exactly as the additive sum returns to 0), then + // the automation verb's synthesized gesture, which journals the + // same leaf gpu_surface_input events. Replay must re-derive the + // identical cumulative zoom from the journaled scale fields alone. + // -0.2's f32 is rounded, but the f32 product 1.25 * (1 - fl32(0.2)) + // rounds exactly to 1.0 (verified in the normalization mirror test, + // platform/macos/root.zig), so the model equality below stays + // exact, not approximate — and replay equality is bitwise anyway, + // because replay re-dispatches the very same journaled f32 deltas. try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -438,7 +443,7 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la .kind = .pinch_change, .x = 200, .y = 150, - .scale = -0.25, + .scale = -0.2, } }); try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, @@ -510,11 +515,12 @@ test "a recorded session replays to identical model state and fingerprints" { // paste landing STRIPPED of its line breaks (single-line rule). try std.testing.expectEqual(@as(u32, 3), recorded.model.query_edits); try std.testing.expectEqualStrings("line oneline two", recorded.model.queryText()); - // Both pinch gestures reached the model: the raw stream's product - // (1.25 * 0.75 = 0.9375) times the verb's exact 1.5. + // Both pinch gestures reached the model: the normalized stream's + // product (1.25 * 0.8 = 1.0, the round trip back to unity) times + // the verb's exact 1.5. try std.testing.expectEqual(@as(u32, 2), recorded.model.pinch_begins); try std.testing.expectEqual(@as(u32, 2), recorded.model.pinch_ends); - try std.testing.expectEqual(@as(f32, 1.40625), recorded.model.zoom); + try std.testing.expectEqual(@as(f32, 1.5), recorded.model.zoom); const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); try std.testing.expect(replayed.report.ok()); diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index 6b6adc6f..b6471925 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -372,8 +372,9 @@ pub fn TsUiApp(comptime core: type) type { /// without its view is not a position; multi-window cores tell /// pinches apart by these), `phase` (the declared /// begin/change/end alias, matched by member name), `scale` (the - /// magnification DELTA on "change"; cumulative gesture scale is - /// the product of `1 + scale`), and the `x`/`y` pointer anchor + /// MULTIPLICATIVE delta on "change" — hosts normalize additive + /// OS reporting, so the cumulative gesture scale is the product + /// of `1 + scale`, chunking-invariant), and the `x`/`y` pointer anchor /// in view-local canvas points. The core's return gates the channel /// exactly like a Zig `on_pinch` (null drops the event). fn pinchMsgAdapter(pinch: platform.PinchEvent) ?Msg { diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index faf2203f..0143bbad 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -542,9 +542,13 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// shape). Pinch deliberately bypasses the widget pipeline: /// it is a view-global gesture (timeline/canvas zoom is an /// app-level concern), delivered phase-explicit (`begin`, - /// `change`, `end`) with the per-event magnification DELTA + /// `change`, `end`) with the per-event MULTIPLICATIVE delta /// on `change` — the cumulative gesture scale is the running - /// product of `(1 + scale)` — and the pointer anchor in + /// product of `(1 + scale)`, applied memorylessly + /// (`zoom *= 1 + scale`); hosts normalize additive OS + /// reporting (AppKit's additive `NSEvent.magnification`) + /// into these factors, so the product matches what the OS + /// measured regardless of event chunking — and the pointer anchor in /// view-local canvas points (`x`/`y`, the zoom-at-cursor /// anchor). Every event names its source window and view /// (`window_id`/`label`, the `on_frame` identity shape) — diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index 06fb75c2..321416b4 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4124,10 +4124,17 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca try std.testing.expect(app_state.installed); // begin -> change -> change -> change -> end, the host's phase - // stream: the model hears every phase, the pointer anchor rides x/y, and - // the cumulative scale is the PRODUCT of (1 + delta) — two +25% - // steps land on 1.5625, never the 1.45 a sum-of-deltas would - // produce. + // stream: the model hears every phase, the pointer anchor rides + // x/y, and the cumulative scale is the PRODUCT of (1 + delta). The + // deltas here are what the normalizing macOS host emits for a + // gesture AppKit chunked as three raw +0.25 magnifications + // (magnification is ADDITIVE; the host converts each chunk into the + // ratio it moved the gesture total by): 0.25, then 0.2, then 1/6 — + // so the product tracks Apple's 1 + Σmagnification: 1.5 after two + // chunks, 1.75 after three. The f32 wire values of 0.2 and 1/6 are + // rounded, but both products round exactly to 1.5 and 1.75 (see + // the normalization mirror test in platform/macos/root.zig), so + // the pins are exact. try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -4152,21 +4159,23 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .kind = .pinch_change, .x = 120, .y = 80, - .scale = 0.25, + .scale = 0.2, } }); - try std.testing.expectEqual(@as(f32, 1.5625), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.5), app_state.model.scale); try std.testing.expectEqual(@as(u32, 0), app_state.model.ends); // A terminal-delta Ended: AppKit's Ended/Cancelled event still // carries the magnification since the previous event, so the host - // forwards it as one last change BEFORE the end marker — the - // product folds it in (1.5625 * 1.25 = 1.953125, binary-exact). + // folds it into the gesture's running sum and forwards it as one + // last change BEFORE the end marker — the third raw +0.25 chunk + // normalizes to the factor 1.75/1.5, delta 1/6, and the product + // lands on 1.75 (Apple's 1 + 0.75). try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, .kind = .pinch_change, .x = 120, .y = 80, - .scale = 0.25, + .scale = 1.0 / 6.0, } }); try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, @@ -4176,9 +4185,9 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .y = 80, } }); try std.testing.expectEqual(@as(u32, 1), app_state.model.ends); - try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.75), app_state.model.scale); // The dispatched Msgs rebuilt the view from the model. - try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.95")); + try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.75")); // A zero-delta Ended is just the end marker: begin then end moves // no scale. @@ -4198,7 +4207,7 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca } }); try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); try std.testing.expectEqual(@as(u32, 2), app_state.model.ends); - try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.75), app_state.model.scale); // Non-pinch raw input never leaks into the channel: a scroll leaves // the model untouched. @@ -4208,7 +4217,7 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .kind = .scroll, .delta_y = 24, } }); - try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.75), app_state.model.scale); try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); // The automation verb drives the same real events: one full gesture diff --git a/tests/ts-core/markup_e2e_tests.zig b/tests/ts-core/markup_e2e_tests.zig index fdfc9b14..a5225873 100644 --- a/tests/ts-core/markup_e2e_tests.zig +++ b/tests/ts-core/markup_e2e_tests.zig @@ -484,11 +484,17 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { // pinchMsg: the trackpad pinch channel — the phase alias matches by // member name, begin/end gate to null in the core, and each change - // compounds the zoom by (1 + delta): two +25% deltas land on the - // PRODUCT 1.5625, never a sum's 1.45. The source identity - // (windowId/label) rides the record into the core: this single-view - // fixture pins that the emitted core hears WHICH window and view - // the gesture happened on. + // compounds the zoom by (1 + delta), a PRODUCT, never a sum. The + // deltas are what the normalizing macOS host emits for a gesture + // AppKit chunked as two raw +0.25 magnifications (additive: total + // 1.5): factors 1.25 then 1.2, so the product is 1.5 regardless of + // chunking. The core accumulates in f64 from the f32 wire deltas, + // and 0.2's f32 rounding carries into the f64 product (~4e-9 high), + // hence the ulp-tight tolerance instead of exact equality — the + // f32-model twins of this pin (ui_app/session tests) round exactly. + // The source identity (windowId/label) rides the record into the + // core: this single-view fixture pins that the emitted core hears + // WHICH window and view the gesture happened on. try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoom); try std.testing.expectEqual(@as(f64, 0), Bridge.model().zoomWindowId); try std.testing.expect(!Bridge.model().zoomFromBoard); @@ -514,7 +520,7 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { .kind = .pinch_change, .x = 120, .y = 80, - .scale = 0.25, + .scale = 0.2, } }); try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ .window_id = 1, @@ -523,18 +529,22 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { .x = 120, .y = 80, } }); - try std.testing.expectEqual(@as(f64, 1.5625), Bridge.model().zoom); + try std.testing.expectApproxEqAbs(@as(f64, 1.5), Bridge.model().zoom, 1e-8); // The core saw the source identity: window 1, this fixture's view // label (the core compares `pinch.label === "ts-markup-canvas"`). try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoomWindowId); try std.testing.expect(Bridge.model().zoomFromBoard); - // The automation pinch verb dispatches the same real events into the - // transpiled core: one gesture whose change carries scale - 1. + // The automation pinch verb dispatches the same real events into + // the transpiled core: one gesture whose single change carries + // scale - 1 (the verb's is the FINAL multiplicative zoom — + // chunking-trivial, so it needs no normalization and gets none). + // The doubling is exact; the tolerance only carries the wire + // rounding already in the zoom. var pinch_buffer: [96]u8 = undefined; const pinch = try std.fmt.bufPrint(&pinch_buffer, "widget-pinch {s} 2", .{canvas_label}); try h.harness.runtime.dispatchAutomationCommand(h.app, pinch); - try std.testing.expectEqual(@as(f64, 3.125), Bridge.model().zoom); + try std.testing.expectApproxEqAbs(@as(f64, 3.0), Bridge.model().zoom, 2e-8); } test "boot images register and launch env overrides dispatch at install" { diff --git a/tools/native-sdk/automation.zig b/tools/native-sdk/automation.zig index 69589c19..e6452438 100644 --- a/tools/native-sdk/automation.zig +++ b/tools/native-sdk/automation.zig @@ -452,7 +452,7 @@ fn printUsage() void { \\ widget-drag [start-y-ratio end-y-ratio] \\ widget-wheel \\ widget-key [text] - \\ widget-pinch [x y] (trackpad pinch: cumulative scale, e.g. 1.5 zooms in; anchor defaults to the view center) + \\ widget-pinch [x y] (trackpad pinch: is the gesture's final multiplicative zoom, e.g. 1.5 zooms in; anchor defaults to the view center) \\ shortcut \\ tray-action (status-item dropdown row; snapshots print tray-item #id) \\ focus From 57863b30bb85bcb3e3f4a07074e89ccc1e00f6cd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 10:45:51 -0500 Subject: [PATCH 12/14] Forward raw AppKit magnification as the multiplicative pinch delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert the running-sum additive normalization of NSEvent.magnification: WebKit (ViewGestureControllerMac.mm: m_magnification += m_magnification * scaleWithResistance) and Chromium (pinch_update.scale = magnification + 1.0) both compound raw magnification multiplicatively, and Apple's own Event Handling Guide example multiplies — only the API reference's loose prose says "add". Raw magnification IS the multiplicative per-event delta; the doctrine comment at magnifyWithEvent: carries the receipts, and the test-appkit-pinch-magnification-doctrine step pins the comment, the raw forwarding, and the floor so a sum-based transformation cannot come back quietly. - The one guard that stays is per-event: a single magnification at or below -1 (a factor <= 0 on the wire — a zoom inverted through zero scale, physically impossible) clamps to -1 + 2^-10, keeping every emitted factor positive; the terminal-delta forwarding and synthesized-begin structure are unchanged. - Product pins return to the raw-forwarding truths (ui_app 1.5625/1.953125 and "Zoom 1.95", markup-e2e exact 1.5625/3.125, session 1.40625), the Zig normalization mirror test is deleted with the machinery it mirrored, and every contract/doc surface now states the engine convention instead of host-side normalization. --- build.zig | 56 ++++----- changelog.d/trackpad-pinch.md | 4 +- docs/src/app/automation/page.mdx | 2 +- docs/src/app/native-ui/page.mdx | 4 +- packages/core/sdk/events.ts | 15 +-- skill-data/automation/SKILL.md | 2 +- src/platform/macos/appkit_host.h | 12 +- src/platform/macos/appkit_host.m | 137 +++++++++------------ src/platform/macos/root.zig | 130 +------------------ src/platform/types.zig | 39 +++--- src/runtime/automation_commands.zig | 4 +- src/runtime/automation_widget_dispatch.zig | 8 +- src/runtime/session_tests.zig | 30 ++--- src/runtime/ts_ui_app.zig | 6 +- src/runtime/ui_app.zig | 14 +-- src/runtime/ui_app_tests.zig | 35 +++--- tests/ts-core/markup_e2e_tests.zig | 31 ++--- 17 files changed, 179 insertions(+), 350 deletions(-) diff --git a/build.zig b/build.zig index cbb59745..ea81f29e 100644 --- a/build.zig +++ b/build.zig @@ -636,44 +636,42 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (void)requestRetainedCanvasFrame" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[self requestRetainedCanvasFrame];" }, }); - addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-terminal-delta", "Verify the macOS pinch stream folds a terminal Ended/Cancelled magnification into the gesture before the end marker", &.{ + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-terminal-delta", "Verify the macOS pinch stream forwards a terminal Ended/Cancelled magnification as a change before the end marker", &.{ // AppKit documents every magnifyWithEvent: as carrying the // magnification since the previous event — the terminal one - // included. The Ended/Cancelled branch must fold a nonzero - // terminal magnification into the gesture's running sum and - // forward it as PINCH_CHANGE before PINCH_END (zero + // included. The Ended/Cancelled branch must forward a nonzero + // terminal magnification as PINCH_CHANGE before PINCH_END (zero // magnification: end only), or the cumulative product of // (1 + delta) diverges from what the OS delivered. Cancelled // deliberately shares the path: pinch applies deltas // incrementally with no rollback. .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) {" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[self emitNormalizedPinchChangeForEvent:event];\n [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[self emitPinchChangeForEvent:event];\n [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0];" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "PINCH_END event:event magnification:0];" }, }); - addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-additive-normalization", "Verify the macOS host normalizes AppKit's additive magnification into multiplicative pinch deltas", &.{ - // NSEvent.magnification is ADDITIVE (Apple: add each event's - // magnification; gesture total = 1 + Σmagnification) while the - // wire contract is multiplicative (product of (1 + delta)). - // The host must track the gesture's additive running sum in f64 - // and emit per-event ratios, or the cumulative product depends - // on how the driver chunked the gesture (two +0.25 chunks: - // 1.5625; one +0.5 chunk: 1.5). The arithmetic itself is pinned - // runnably by the Zig mirror test in src/platform/macos/root.zig - // ("... normalization telescopes additive magnification ..."); - // these text pins hold the ObjC side to the mirrored formula. - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static const double NativeSdkPinchMagnificationSumFloor = -1.0 + 0x1p-10;" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return sum < NativeSdkPinchMagnificationSumFloor ? NativeSdkPinchMagnificationSumFloor : sum;" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return (1.0 + sum) / (1.0 + previousSum) - 1.0;" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkClampedPinchMagnificationSum(previousSum + event.magnification);" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkNormalizedPinchDelta(previousSum, sum);" }, - // The sum resets at Began and at the synthesized begin (a - // gesture already in flight when the view becomes the hit - // target), and the normalization lives ONLY in the AppKit - // handler: synthesized automation events enter downstream of - // magnifyWithEvent: and are never re-normalized. - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "self.pinchMagnificationSum = 0;" }, - .{ .path = "src/platform/macos/root.zig", .pattern = "fn normalizedDelta(previous_sum: f64, sum: f64) f64 {" }, - .{ .path = "src/platform/macos/root.zig", .pattern = "return (1.0 + sum) / (1.0 + previous_sum) - 1.0;" }, + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-pinch-magnification-doctrine", "Verify the macOS host forwards raw NSEvent.magnification as the multiplicative pinch delta (the engine convention), with the per-event floor", &.{ + // The reading of NSEvent.magnification is SETTLED and this step + // exists to keep it settled: Apple's API-reference prose says + // "add", Apple's own Event Handling Guide example multiplies, + // and every browser engine (WebKit, Chromium) treats raw + // magnification as the multiplicative per-event delta. The + // doctrine comment at magnifyWithEvent: carries the receipts; + // these pins hold both the comment and the code to it. Any + // sum-based "additive normalization" of magnification must + // change emitPinchChangeForEvent:'s pinned body — and thereby + // fail this step, on purpose. Re-read the doctrine comment + // before touching either. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "m_magnification += m_magnification * scaleWithResistance;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "pinch_update.scale = event.magnification + 1.0;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "Ruling: raw event.magnification IS the multiplicative per-event" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "Do NOT reintroduce a running-sum" }, + // The raw forwarding, pinned as one body: the only transform + // between the OS and the wire is the per-event floor (a single + // magnification at or below -1 would emit a factor <= 0 — a + // zoom inverted through zero scale, physically impossible). + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static const double NativeSdkPinchMagnificationFloor = -1.0 + 0x1p-10;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return magnification < NativeSdkPinchMagnificationFloor ? NativeSdkPinchMagnificationFloor : magnification;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (void)emitPinchChangeForEvent:(NSEvent *)event {\n const double magnification = NativeSdkClampedPinchMagnification(event.magnification);\n if (magnification == 0) return;\n [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:magnification];\n}" }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-input-paces-retained-canvas", "Verify GPU input frame requests are paced to the display interval", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRetainedFrameIntervalNanoseconds" }, diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md index 2d33c685..c4e36616 100644 --- a/changelog.d/trackpad-pinch.md +++ b/changelog.d/trackpad-pinch.md @@ -1,3 +1,3 @@ -feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries a multiplicative delta (cumulative gesture scale is the product of `1 + scale`, applied memorylessly — `zoom *= 1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); every event names its source window and view (`window_id`/`label` in Zig, `windowId`/`label` in TypeScript), so multi-window apps tell pinches apart; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. The macOS host normalizes AppKit's ADDITIVE `NSEvent.magnification` (Apple prescribes adding each event's magnification; the gesture total is 1 + the sum) into these multiplicative factors, so the product of `1 + scale` over a gesture equals Apple's total no matter how the driver chunked the gesture into events — the rule for apps is unchanged (multiply by `1 + scale`, per event, memorylessly); only the host's honesty improved, and the final zoom is no longer chunking-dependent. A gesture whose additive sum would cross -1 (a physical impossibility — a pinch cannot invert through zero scale) clamps at a floor scale of 2^-10, keeping every emitted factor positive. Windows precision-touchpad and GTK gesture sources are staged follow-ups. -- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (`` is the gesture's FINAL multiplicative zoom — one change carrying `scale - 1`, chunking-trivial, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. +feature: **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries a multiplicative delta (cumulative gesture scale is the product of `1 + scale`, applied memorylessly — `zoom *= 1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); every event names its source window and view (`window_id`/`label` in Zig, `windowId`/`label` in TypeScript), so multi-window apps tell pinches apart; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. On macOS the delta is AppKit's raw per-event `NSEvent.magnification`, forwarded untransformed: raw magnification IS the multiplicative per-event delta — the convention every browser engine ships — so the product of `1 + scale` is the zoom users already experience for the same gesture in Safari and Chrome. The one guard is a per-event floor: a single event's magnification at or below -1 (a zoom inverting through zero scale — physically impossible, only a driver glitch could report it) clamps just above -1, so every emitted factor stays positive. Windows precision-touchpad and GTK gesture sources are staged follow-ups. +- **`widget-pinch` automation verb**: `native automate widget-pinch [x y]` drives the real pinch event stream without a trackpad (`` is the gesture's FINAL multiplicative zoom — one change carrying `scale - 1`, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7. - **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build. diff --git a/docs/src/app/automation/page.mdx b/docs/src/app/automation/page.mdx index 02e27e3c..05834289 100644 --- a/docs/src/app/automation/page.mdx +++ b/docs/src/app/automation/page.mdx @@ -139,7 +139,7 @@ The runtime watches the command queue and processes these actions: widget-pinch <view-label> <scale> [<x> <y>] - Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1. scale is the FINAL multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out to half) — a single change is chunking-trivial, so the cumulative product of 1 + delta lands exactly on it. The anchor point defaults to the view center + Dispatch a trackpad pinch gesture at a gpu-surface view: the real pinch_begin/pinch_change/pinch_end events, with one change carrying scale - 1. scale is the final multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out to half) — the cumulative product of 1 + delta lands exactly on it. The anchor point defaults to the view center shortcut <id> diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index b838c8ba..d3bfb336 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -276,7 +276,7 @@ One rule bridges the registers: **quiet focus on a plain list row routes no keys ## Trackpad pinch -Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the **multiplicative delta** for that event: the cumulative gesture scale is the running product of `1 + scale`, never the sum — apply it memorylessly (`zoom *= 1 + scale`), no gesture-start bookkeeping. Hosts whose OS reports pinch additively normalize before emitting: AppKit's `NSEvent.magnification` is additive (Apple prescribes adding each event's magnification; the gesture total is 1 + the sum), and the macOS host converts each event into the ratio it moved that total by, so the product of `1 + scale` over a gesture equals Apple's total exactly — the final zoom never depends on how the driver chunked the gesture into events. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. Every event also names its source — `window_id` and the view `label` in Zig, `windowId`/`label` in TypeScript — because view-local coordinates mean nothing without their view: multi-window apps tell pinches apart by them. +Pinch-to-zoom reaches the app as a view-global gesture channel — the `on_key` shape, deliberately outside the widget pipeline (timeline and canvas zoom are app-level concerns, not widget presses). The channel is phase-explicit: `begin`, then a stream of `change` events, then `end` (a host-cancelled gesture folds into `end` — pinch delivers incremental deltas the app applies as they arrive, so there is nothing to roll back; a terminal host event that still measured a nonzero delta arrives as one last `change` before the `end`, so the cumulative product always matches what the OS reported). Each `change` carries `scale`, the magnification **delta** for that event, and the delta is multiplicative: the cumulative gesture scale is the running product of `1 + scale`, never the sum — apply it memorylessly (`zoom *= 1 + scale`), no gesture-start bookkeeping. On macOS that delta is AppKit's raw per-event `NSEvent.magnification`, forwarded untransformed — raw magnification is the multiplicative per-event delta in every browser engine (Safari and Chrome both compound `1 + magnification` per event), so the product is the zoom users already know from the same gesture in a browser. The pointer anchor rides `x`/`y` in view-local canvas points — the pointer location during the gesture (hosts report gesture events at the pointer, not at a midpoint between the fingers), so a zoom can anchor under the cursor. Every event also names its source — `window_id` and the view `label` in Zig, `windowId`/`label` in TypeScript — because view-local coordinates mean nothing without their view: multi-window apps tell pinches apart by them. ```zig // options: .on_pinch = onPinch, @@ -299,7 +299,7 @@ export function pinchMsg(pinch: PinchEvent): Msg | null { } ``` -macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — `` is the final multiplicative zoom for the gesture (chunking-trivial: one change event whose product lands exactly on it), anchor point defaulting to the view center. +macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch [x y]` — `` is the final multiplicative zoom for the gesture (one change event whose product lands exactly on it), anchor point defaulting to the view center. ## Native scrolling and context menus diff --git a/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index d916c419..5ae5736f 100644 --- a/packages/core/sdk/events.ts +++ b/packages/core/sdk/events.ts @@ -87,13 +87,14 @@ export type PinchPhase = "begin" | "change" | "end"; /// gesture, phase-explicit. `windowId`/`label` name the source window and /// gpu-surface view — `x`/`y` are view-local, so a coordinate without its /// view is not a position, and multi-window apps tell pinches apart by -/// these. `scale` is the MULTIPLICATIVE delta for this event (nonzero -/// only on "change"; the cumulative gesture scale is the running product -/// of `1 + scale` — apply it memorylessly, `zoom *= 1 + scale`, no -/// gesture-start bookkeeping; hosts normalize additive OS reporting like -/// AppKit's additive `NSEvent.magnification` into these factors, so the -/// product over a gesture equals what the OS measured regardless of how -/// events were chunked), and `x`/`y` is the pointer anchor in view-local canvas +/// these. `scale` is the magnification DELTA for this event (nonzero +/// only on "change"), and the delta is MULTIPLICATIVE: the cumulative +/// gesture scale is the running product of `1 + scale` — apply it +/// memorylessly, `zoom *= 1 + scale`, no gesture-start bookkeeping. On +/// macOS this is AppKit's raw per-event `NSEvent.magnification`, which +/// IS that multiplicative delta per the browser-engine convention, so +/// the product matches the zoom the same gesture performs in Safari and +/// Chrome. `x`/`y` is the pointer anchor in view-local canvas /// points — the pointer location during the gesture (hosts report gesture /// events at the pointer, not at a midpoint between the fingers), so a /// zoom can anchor under the cursor. Pinch is a view-global gesture — it diff --git a/skill-data/automation/SKILL.md b/skill-data/automation/SKILL.md index 9ec6af29..6d8a0374 100644 --- a/skill-data/automation/SKILL.md +++ b/skill-data/automation/SKILL.md @@ -115,7 +115,7 @@ Semantics: 8. Use `native automate widget-drag [start-y-ratio end-y-ratio]` for continuous pointer controls. 9. Use `native automate widget-wheel ` for retained widget scroll input. Wheel targets must be interactive/scrollable widgets — a plain layout column or text node is not a wheel target; aim at the scroll/list widget id from the snapshot. Failures land in the snapshot as named reasons: `error event=automation.widget_wheel name=WheelTargetUnknown|WheelTargetNotInteractive|WheelTargetHasEmptyBounds detail=""`. 10. Use `native automate widget-key [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS). -11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1`. `` is the FINAL multiplicative zoom for the gesture — a single change is chunking-trivial, so the cumulative gesture scale (the product of `1 + delta`) lands exactly on it — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). +11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1`. `` is the FINAL multiplicative zoom for the gesture — the cumulative gesture scale (the product of `1 + delta`) lands exactly on it — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). 12. Use `native automate screenshot [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-.png` (the CLI prints the artifact path and waits for the file). 13. Use `native automate tray-action ` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`. 14. Use `native automate reload` to request a WebView reload. diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index 0a166f99..5a512cf5 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -77,13 +77,11 @@ typedef enum { NATIVE_SDK_APPKIT_GPU_INPUT_IME_CANCEL_COMPOSITION = 10, NATIVE_SDK_APPKIT_GPU_INPUT_POINTER_CANCEL = 11, /* Trackpad pinch phases (magnifyWithEvent:). The event's `scale` - * field carries the per-event MULTIPLICATIVE delta on PINCH_CHANGE - * (cumulative gesture scale is the product of (1 + delta)); 0 on - * begin/end. NSEvent.magnification is ADDITIVE (Apple: add each - * event's magnification; gesture total = 1 + Σmagnification), so - * the host normalizes before emitting: the product of (1 + delta) - * over a gesture equals Apple's 1 + Σmagnification, invariant to - * how the driver chunked the gesture. The pointer anchor rides + * field carries the per-event magnification DELTA on PINCH_CHANGE — + * raw NSEvent.magnification, which IS the multiplicative per-event + * delta per the browser-engine convention (see the doctrine note at + * magnifyWithEvent: in appkit_host.m); cumulative gesture scale is + * the product of (1 + delta); 0 on begin/end. The pointer anchor rides * x/y (the event's locationInWindow — gesture events report the * pointer location, not a midpoint between the fingers). */ NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN = 12, diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 328233e3..caf97704 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -570,7 +570,6 @@ @interface NativeSdkMetalSurfaceView : NSView @property(nonatomic, assign) uint64_t scrollDriverEventLastEmitNs; @property(nonatomic, assign) BOOL controlClickActive; @property(nonatomic, assign) BOOL pinchGestureActive; -@property(nonatomic, assign) double pinchMagnificationSum; - (void)configureWithHost:(NativeSdkAppKitHost *)host windowId:(uint64_t)windowId label:(NSString *)label; - (BOOL)isAvailable; - (void)updateDrawableSize; @@ -616,7 +615,7 @@ - (void)emitQueuedPointerMotionInputEvent; - (void)queueScrollInputEvent:(NSEvent *)event deltaX:(double)deltaX deltaY:(double)deltaY; - (void)emitQueuedScrollInputEvent; - (void)emitPinchInputEventWithKind:(NSInteger)kind event:(NSEvent *)event magnification:(double)magnification; -- (void)emitNormalizedPinchChangeForEvent:(NSEvent *)event; +- (void)emitPinchChangeForEvent:(NSEvent *)event; - (void)emitInputEventWithKind:(NSInteger)kind point:(NSPoint)point timestampNs:(uint64_t)timestampNs modifiers:(uint32_t)modifiers keyText:(NSString *)keyText inputText:(NSString *)inputText button:(NSInteger)button deltaX:(double)deltaX deltaY:(double)deltaY; - (void)emitSyntheticKeyDownWithKey:(NSString *)key modifiers:(uint32_t)modifiers; - (void)updateSurfaceTrackingArea; @@ -5545,56 +5544,63 @@ - (void)magnifyWithEvent:(NSEvent *)event { // the cumulative gesture scale is the PRODUCT of (1 + delta), and // summing coalesced deltas would drift from what the fingers did. // - // AppKit's NSEvent.magnification is ADDITIVE: Apple prescribes - // adding each event's magnification to the current scale, so the - // gesture's total is 1 + Σmagnification no matter how the driver - // chunks it into events. Our wire contract is MULTIPLICATIVE - // (cumulative scale = product of (1 + delta)), so the host - // normalizes here — emitNormalizedPinchChangeForEvent: tracks the - // gesture's additive running sum and emits the per-event ratio — - // and the product of (1 + delta) over the gesture equals Apple's - // 1 + Σmagnification, chunking-invariant. Forwarding the raw - // additive chunks instead would make the product depend on how - // AppKit happened to slice the gesture (two +0.25 chunks: 1.5625; - // one +0.5 chunk: 1.5) — driver/timing-dependent zoom. + // HOW TO READ NSEvent.magnification — settled; do not "fix" again. + // Apple's documentation contradicts itself: the NSEvent API + // reference's prose says to ADD each event's magnification to your + // scale factor, while Apple's own Event Handling Guide example + // MULTIPLIES (currentSize *= 1 + event.magnification). The de facto + // platform behavior is what the browser engines ship, and every + // engine reads raw magnification as the MULTIPLICATIVE per-event + // delta: + // - WebKit, ViewGestureControllerMac.mm: + // m_magnification += m_magnification * scaleWithResistance; + // i.e. zoom *= 1 + event.magnification, per event. + // - Chromium's mac gesture-event builder emits + // pinch_update.scale = event.magnification + 1.0; + // compounded multiplicatively downstream. + // Ruling: raw event.magnification IS the multiplicative per-event + // delta. This handler forwards it untransformed (per-event floor + // aside — see emitPinchChangeForEvent:), and the app-side product + // of (1 + scale) is the zoom users already experience for the same + // gesture in Safari and Chrome. Do NOT reintroduce a running-sum + // "additive normalization" here: it matches neither Apple's example + // nor any engine — a raw +0.25, +0.20 stream must land on + // 1.25 * 1.20 = 1.5, where a sum-normalized stream lands on 1.45. // // EVERY magnifyWithEvent: carries the magnification since the // previous event — the terminal (Ended/Cancelled) one included — - // so each branch below must fold a nonzero magnification into the - // sum and forward it as a PINCH_CHANGE or the cumulative product - // diverges from what the OS delivered. + // so each branch below must forward a nonzero magnification as a + // PINCH_CHANGE or the cumulative product diverges from what the + // OS delivered. const NSEventPhase phase = event.phase; if (phase & NSEventPhaseBegan) { [self emitQueuedPointerMotionInputEvent]; self.pinchGestureActive = YES; - self.pinchMagnificationSum = 0; [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; - [self emitNormalizedPinchChangeForEvent:event]; + [self emitPinchChangeForEvent:event]; return; } if (phase & NSEventPhaseChanged) { if (!self.pinchGestureActive) { // A gesture already in flight when this view became the - // hit target skips Began; open the stream cleanly anyway - // (synthesized begin, so the running sum resets here too). + // hit target skips Began; open the stream cleanly anyway. self.pinchGestureActive = YES; - self.pinchMagnificationSum = 0; [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; } - [self emitNormalizedPinchChangeForEvent:event]; + [self emitPinchChangeForEvent:event]; return; } if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) { if (!self.pinchGestureActive) return; self.pinchGestureActive = NO; - // The terminal event's nonzero magnification joins the running - // sum and emits as a final PINCH_CHANGE before the end marker - // (a zero-magnification terminal event emits only PINCH_END). - // Cancelled takes the same path deliberately: our model applies - // deltas incrementally with no rollback (see above), so the - // honest stream reports every delta the OS measured, then says - // the gesture is over. - [self emitNormalizedPinchChangeForEvent:event]; + // The terminal event's nonzero magnification emits as a final + // PINCH_CHANGE before the end marker (a zero-magnification + // terminal event emits only PINCH_END). Cancelled takes the + // same path deliberately: our model applies deltas + // incrementally with no rollback (see above), so the honest + // stream reports every delta the OS measured, then says the + // gesture is over. + [self emitPinchChangeForEvent:event]; [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_END event:event magnification:0]; return; } @@ -5781,56 +5787,35 @@ - (void)emitQueuedScrollInputEvent { deltaY:deltaY]; } -// --- Pinch normalization ----------------------------------------------- -// AppKit's NSEvent.magnification is ADDITIVE (Apple: add each event's -// magnification to the current scale; gesture total = 1 + Σmagnification). -// The wire contract is MULTIPLICATIVE (cumulative scale = the product of -// (1 + delta)), so the host converts each additive chunk into the ratio it -// moved the gesture total by. The arithmetic lives in tiny pure functions -// so the Zig-side test can mirror it verbatim (documentation-by- -// computation; ObjC is unreachable from the Zig test graph). -// -// A pinch-in can drive the additive sum negative, but a real gesture can -// never invert through zero scale — a sum at or below -1 would blow the -// ratio up (division by 1 + S -> 0) or flip its sign. Clamp the running -// sum to a floor just above -1: the gesture's cumulative scale bottoms -// out at 2^-10 (~0.001x, exactly representable) and every emitted factor -// keeps 1 + delta > 0. -static const double NativeSdkPinchMagnificationSumFloor = -1.0 + 0x1p-10; - -static double NativeSdkClampedPinchMagnificationSum(double sum) { - return sum < NativeSdkPinchMagnificationSumFloor ? NativeSdkPinchMagnificationSumFloor : sum; -} - -// The multiplicative delta that advances the gesture total from -// (1 + previousSum) to (1 + sum): the product of (1 + delta) over the -// gesture telescopes to 1 + Σmagnification — Apple's total — exactly in -// this f64 math, so the app-side product is invariant to how the driver -// chunked the gesture into events (up to f32 wire rounding per event). -static double NativeSdkNormalizedPinchDelta(double previousSum, double sum) { - return (1.0 + sum) / (1.0 + previousSum) - 1.0; -} - -// Fold one magnify event's additive magnification into the gesture's -// running sum and emit the normalized multiplicative delta (nothing to -// emit when the event measured no magnification, or when the floor clamp -// swallowed the whole chunk). -- (void)emitNormalizedPinchChangeForEvent:(NSEvent *)event { - if (event.magnification == 0) return; - const double previousSum = self.pinchMagnificationSum; - const double sum = NativeSdkClampedPinchMagnificationSum(previousSum + event.magnification); - self.pinchMagnificationSum = sum; - const double delta = NativeSdkNormalizedPinchDelta(previousSum, sum); - if (delta == 0) return; - [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:delta]; +// --- Pinch change forwarding --------------------------------------------- +// Raw event.magnification IS the wire's multiplicative per-event delta +// (see the doctrine note at magnifyWithEvent:); it forwards untransformed. +// One guard stands between the OS and the wire: a single event whose +// magnification is at or below -1 would put a factor (1 + delta) <= 0 on +// the wire — a zoom inverting through zero scale, which no physical pinch +// can perform (only a driver/toolkit glitch could report it), and which +// downstream products cannot recover from. Clamp PER EVENT to a floor +// just above -1; the epsilon is 2^-10 (~0.001, exactly representable in +// f32 and f64), so a glitched event's factor bottoms out at 2^-10 and +// every emitted factor stays > 0. +static const double NativeSdkPinchMagnificationFloor = -1.0 + 0x1p-10; + +static double NativeSdkClampedPinchMagnification(double magnification) { + return magnification < NativeSdkPinchMagnificationFloor ? NativeSdkPinchMagnificationFloor : magnification; +} + +// Forward one magnify event's raw magnification as a PINCH_CHANGE +// (nothing to emit when the event measured no magnification). +- (void)emitPinchChangeForEvent:(NSEvent *)event { + const double magnification = NativeSdkClampedPinchMagnification(event.magnification); + if (magnification == 0) return; + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_CHANGE event:event magnification:magnification]; } // Pinch input: the shared input-emit shape (converted point, top-left // origin flip, host timestamp, modifier flags) with the magnification // delta riding the event's `scale` field — unused (zero) on every other -// input kind, so the runtime reads it unconditionally. The delta is -// computed in f64 here and rounds to the platform event's f32 `scale` -// at the Zig-side ABI conversion. The x/y point is +// input kind, so the runtime reads it unconditionally. The x/y point is // the POINTER anchor: AppKit reports gesture events at locationInWindow // (the pointer location), never a midpoint between the fingers — NSTouch // positions are trackpad-normalized and have no view-space meaning, and diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 03df1739..32969fbe 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -906,10 +906,8 @@ fn gpuSurfaceInputEventFromAppKitEvent(event: *const AppKitEvent) platform_mod.G .text = event.input_text[0..event.input_text_len], .composition_cursor = if (event.has_composition_cursor != 0) event.composition_cursor else null, .modifiers = shortcutModifiersFromFlags(event.shortcut_modifiers), - // The pinch delta rides the ABI event's `scale` field (zero on - // every non-pinch input emission). The host already normalized - // AppKit's additive magnification into a multiplicative factor - // in f64; this cast is the wire's f32 rounding point. + // The pinch magnification delta rides the ABI event's `scale` + // field (zero on every non-pinch input emission). .scale = @floatCast(event.scale), }; } @@ -2278,130 +2276,6 @@ test "mac gpu surface input maps pinch phases and carries the magnification delt try std.testing.expectEqual(@as(f32, 0), end.scale); } -/// MIRROR of appkit_host.m's pinch normalization, not a link: the ObjC -/// host is unreachable from the Zig test graph, so this restates -/// `NativeSdkClampedPinchMagnificationSum` / `NativeSdkNormalizedPinchDelta` -/// verbatim as documentation-by-computation (the file-contains step -/// `test-appkit-pinch-additive-normalization` pins the ObjC text; this -/// pins the arithmetic). Keep the two in lockstep. -const PinchNormalizationMirror = struct { - /// Mirrors NativeSdkPinchMagnificationSumFloor. - const sum_floor: f64 = -1.0 + 0x1p-10; - - /// Mirrors NativeSdkClampedPinchMagnificationSum. - fn clampedSum(sum: f64) f64 { - return if (sum < sum_floor) sum_floor else sum; - } - - /// Mirrors NativeSdkNormalizedPinchDelta. - fn normalizedDelta(previous_sum: f64, sum: f64) f64 { - return (1.0 + sum) / (1.0 + previous_sum) - 1.0; - } - - /// The host's per-gesture loop over raw additive AppKit - /// magnification chunks: fold each into the f64 running sum, emit - /// the multiplicative delta rounded to the f32 wire (the - /// `gpuSurfaceInputEventFromAppKitEvent` cast), skip zero deltas. - /// Returns the number of deltas emitted. - fn emittedDeltas(chunks: []const f64, deltas: []f32) usize { - var sum: f64 = 0; - var count: usize = 0; - for (chunks) |chunk| { - if (chunk == 0) continue; - const previous_sum = sum; - sum = clampedSum(previous_sum + chunk); - const delta = normalizedDelta(previous_sum, sum); - if (delta == 0) continue; - deltas[count] = @floatCast(delta); - count += 1; - } - return count; - } - - /// The app-side accumulation rule, f32 like the reference models: - /// `scale *= 1 + delta` per change event, memoryless. - fn product(deltas: []const f32) f32 { - var scale: f32 = 1; - for (deltas) |delta| scale *= 1 + delta; - return scale; - } -}; - -test "mac appkit pinch normalization telescopes additive magnification into a chunking-invariant product" { - const Mirror = PinchNormalizationMirror; - var deltas: [8]f32 = undefined; - - // Two raw +0.25 AppKit chunks: the running sums are 0.25 then 0.5, - // so the emitted factors are 1.25 then 1.5/1.25 = 1.2 — the product - // is Apple's 1 + 0.5 = 1.5, never the 1.5625 that forwarding the - // raw chunks would compound to. 0.2 is not exactly representable in - // f32 (the wire rounds it to 0.20000000298...), but the f32 literal - // 0.2 rounds identically, so the delta pin is exact — and the f32 - // product 1.25 * (1 + fl32(0.2)) = 1.5000000596... rounds to - // exactly 1.5 (ties-to-even lands on 1.5's even mantissa), so the - // product pin is exact too. - const two_quarters = Mirror.emittedDeltas(&.{ 0.25, 0.25 }, &deltas); - try std.testing.expectEqual(@as(usize, 2), two_quarters); - try std.testing.expectEqual(@as(f32, 0.25), deltas[0]); - try std.testing.expectEqual(@as(f32, 0.2), deltas[1]); - try std.testing.expectEqual(@as(f32, 1.5), Mirror.product(deltas[0..two_quarters])); - - // Chunking invariance — the property this normalization exists for: - // the same additive total (+0.5) delivered as one chunk, two +0.25 - // chunks, or four +0.125 chunks produces the same final product. - // The chunk values are exactly representable, and each of the three - // f32 products happens to round exactly to 1.5, so the agreement - // assertion is exact (the general guarantee is one f32 ulp per - // emitted event). Forwarding raw chunks instead would give 1.5 vs - // 1.5625 vs ~1.6018 — driver/timing-dependent zoom. - const one_chunk = Mirror.emittedDeltas(&.{0.5}, &deltas); - const final_one = Mirror.product(deltas[0..one_chunk]); - const four_chunks = Mirror.emittedDeltas(&.{ 0.125, 0.125, 0.125, 0.125 }, &deltas); - const final_four = Mirror.product(deltas[0..four_chunks]); - try std.testing.expectEqual(@as(usize, 1), one_chunk); - try std.testing.expectEqual(@as(usize, 4), four_chunks); - try std.testing.expectEqual(@as(f32, 1.5), final_one); - try std.testing.expectEqual(final_one, final_four); - - // The terminal-delta path participates: an Ended event carrying - // +0.25 after a +0.25 change joins the same running sum, so the - // final product is 1.5 exactly as if both chunks were Changed - // events (the host folds the terminal magnification into the sum - // before emitting the last change). - const terminal = Mirror.emittedDeltas(&.{ 0.25, 0.25 }, &deltas); - try std.testing.expectEqual(@as(f32, 1.5), Mirror.product(deltas[0..terminal])); - - // A gesture that returns its additive sum to zero returns the - // product to unity: raw +0.25 then -0.25 emits factors 1.25 and - // 1/1.25 = 0.8, and the f32 product 1.25 * (1 - fl32(0.2)) rounds - // exactly to 1.0 (the session replay reference pins the same fact - // through the app channel). - const round_trip = Mirror.emittedDeltas(&.{ 0.25, -0.25 }, &deltas); - try std.testing.expectEqual(@as(usize, 2), round_trip); - try std.testing.expectEqual(@as(f32, -0.2), deltas[1]); - try std.testing.expectEqual(@as(f32, 1.0), Mirror.product(deltas[0..round_trip])); - - // Degenerate clamp: a strong pinch-in summing below -1 (here - // -0.5 then -0.75 = -1.25) would invert the gesture through zero - // scale — impossible physically, and a sum at -1 divides by zero. - // The running sum clamps to the floor just above -1: every emitted - // factor keeps 1 + delta > 0 (the stream never flips sign) and the - // product bottoms out at the floor's scale, 2^-10 exactly (both - // emitted deltas, -0.5 and -(1 - 2^-9), are f32-exact). - const clamped = Mirror.emittedDeltas(&.{ -0.5, -0.75 }, &deltas); - try std.testing.expectEqual(@as(usize, 2), clamped); - try std.testing.expectEqual(@as(f32, -0.5), deltas[0]); - try std.testing.expectEqual(@as(f32, -0.998046875), deltas[1]); - for (deltas[0..clamped]) |delta| try std.testing.expect(1 + delta > 0); - try std.testing.expectEqual(@as(f32, 0.0009765625), Mirror.product(deltas[0..clamped])); - - // A further pinch-in chunk against the floor is fully swallowed by - // the clamp: the sum cannot move, the delta is zero, nothing emits. - const swallowed = Mirror.emittedDeltas(&.{ -0.5, -0.75, -0.5 }, &deltas); - try std.testing.expectEqual(@as(usize, 2), swallowed); - try std.testing.expectEqual(@as(f32, 0.0009765625), Mirror.product(deltas[0..swallowed])); -} - 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 620ef086..eacc1f83 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1578,17 +1578,17 @@ pub const GpuSurfaceInputEvent = struct { text: []const u8 = "", composition_cursor: ?usize = null, modifiers: ShortcutModifiers = .{}, - /// Pinch MULTIPLICATIVE delta for this event: nonzero only on - /// `pinch_change`, 0 on begin/end. The cumulative gesture scale is - /// the running product of `(1 + scale)` across the gesture's change - /// events. Hosts whose OS reports pinch additively normalize before - /// emitting — AppKit's `NSEvent.magnification` is additive (Apple: - /// add each event's magnification; gesture total is - /// 1 + Σmagnification), and the macOS host converts each chunk into - /// the ratio it moved that total by, so the product of `(1 + scale)` - /// over a gesture equals Apple's 1 + Σmagnification (up to f32 wire - /// rounding) no matter how the driver chunked the gesture into - /// events. The pointer anchor rides + /// Pinch magnification DELTA for this event: nonzero only on + /// `pinch_change`, 0 on begin/end. The delta is MULTIPLICATIVE: the + /// cumulative gesture scale is the running product of `(1 + scale)` + /// across the gesture's change events. On macOS this is AppKit's + /// raw per-event `NSEvent.magnification`, forwarded untransformed — + /// raw magnification IS the multiplicative per-event delta, the + /// convention every browser engine ships (see the doctrine note at + /// `magnifyWithEvent:` in appkit_host.m), so the product matches + /// the zoom the same gesture performs in Safari and Chrome. The + /// host guarantees `1 + scale > 0` on every event (a per-event + /// floor clamps the physically impossible). The pointer anchor rides /// `x`/`y` (view-local, same space as pointer events) — the pointer /// location during the gesture, NOT a midpoint between the fingers: /// hosts report gesture events at the pointer (AppKit's @@ -1627,14 +1627,15 @@ pub const PinchEvent = struct { window_id: WindowId = 1, label: []const u8 = "", phase: PinchPhase, - /// MULTIPLICATIVE delta for this event: nonzero only on `.change`, - /// 0 on begin/end. The cumulative gesture scale is the running - /// product of `(1 + scale)` across the gesture's change events — - /// apply it memorylessly (`zoom *= 1 + scale`), no gesture-start - /// bookkeeping. Hosts normalize additive OS reporting (AppKit's - /// additive `NSEvent.magnification`) into these factors, so the - /// product over a gesture equals what the OS measured regardless of - /// event chunking. + /// Magnification DELTA for this event: nonzero only on `.change`, + /// 0 on begin/end. The delta is MULTIPLICATIVE — the cumulative + /// gesture scale is the running product of `(1 + scale)` across the + /// gesture's change events; apply it memorylessly + /// (`zoom *= 1 + scale`), no gesture-start bookkeeping. On macOS + /// this is AppKit's raw per-event `NSEvent.magnification`, which IS + /// that multiplicative delta per the browser-engine convention, so + /// the product matches the zoom the same gesture performs in Safari + /// and Chrome. Every event keeps `1 + scale > 0`. scale: f32 = 0, /// The pointer anchor, view-local canvas points (the pointer-event /// space): where the zoom should anchor. This is the POINTER diff --git a/src/runtime/automation_commands.zig b/src/runtime/automation_commands.zig index 5e721419..120fcc74 100644 --- a/src/runtime/automation_commands.zig +++ b/src/runtime/automation_commands.zig @@ -63,9 +63,7 @@ pub const AutomationWidgetKey = struct { /// multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out /// to half) — the dispatch synthesizes begin, one change carrying /// `scale - 1`, and end, so the product of `(1 + delta)` lands exactly -/// on `scale` (a single change is chunking-trivial: the host-side -/// additive normalization only concerns real AppKit event streams, -/// upstream of these synthesized platform events). The optional +/// on `scale`. The optional /// anchor point is view-local canvas points (where the zoom anchors, /// like the pointer position under a real pinch); omitted, it defaults /// to the view center. diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index c3ea2b4a..5d0457c9 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -287,12 +287,8 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { /// Drive a trackpad pinch through the real platform-event path: /// `pinch_begin`, one `pinch_change` carrying `scale - 1` (so /// the cumulative product of `1 + delta` lands exactly on the - /// commanded scale — the gesture's FINAL multiplicative zoom; - /// one change is chunking-trivial), and `pinch_end`, all at the - /// same anchor point. These synthesized events enter DOWNSTREAM - /// of the AppKit host, so the host's additive-magnification - /// normalization (appkit_host.m, real gestures only) never - /// touches them. + /// commanded scale — the gesture's FINAL multiplicative zoom), + /// and `pinch_end`, all at the same anchor point. /// Plain input synthesis, the `widget-key` discipline: every /// event journals as itself and replays through the same /// dispatch — no accessibility-action record, because pinch is diff --git a/src/runtime/session_tests.zig b/src/runtime/session_tests.zig index da50173d..b22e6fd7 100644 --- a/src/runtime/session_tests.zig +++ b/src/runtime/session_tests.zig @@ -409,19 +409,14 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la } }); try std.testing.expectEqualStrings("line oneline two", app_state.model.queryText()); - // A trackpad pinch, twice over: the journaled phase stream (the - // macOS host shape — begin, two deltas, end; the deltas are what - // the normalizing host emits for raw additive magnifications +0.25 - // then -0.25: factors 1.25 then 1/1.25 = 0.8, so the PRODUCT - // returns to 1.0 exactly as the additive sum returns to 0), then - // the automation verb's synthesized gesture, which journals the - // same leaf gpu_surface_input events. Replay must re-derive the - // identical cumulative zoom from the journaled scale fields alone. - // -0.2's f32 is rounded, but the f32 product 1.25 * (1 - fl32(0.2)) - // rounds exactly to 1.0 (verified in the normalization mirror test, - // platform/macos/root.zig), so the model equality below stays - // exact, not approximate — and replay equality is bitwise anyway, - // because replay re-dispatches the very same journaled f32 deltas. + // A trackpad pinch, twice over: the raw journaled phase stream (the + // macOS host shape — begin, two deltas, end; the +25% then -25% + // deltas compound as a PRODUCT, 1.25 * 0.75 = 0.9375, never a sum's + // 1.0), then the automation verb's synthesized gesture, which + // journals the same leaf gpu_surface_input events. Replay must + // re-derive the identical cumulative zoom from the journaled scale + // fields alone. Every value here is binary-exact in f32, so the + // model equality below is exact, not approximate. try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -443,7 +438,7 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la .kind = .pinch_change, .x = 200, .y = 150, - .scale = -0.2, + .scale = -0.25, } }); try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, @@ -515,12 +510,11 @@ test "a recorded session replays to identical model state and fingerprints" { // paste landing STRIPPED of its line breaks (single-line rule). try std.testing.expectEqual(@as(u32, 3), recorded.model.query_edits); try std.testing.expectEqualStrings("line oneline two", recorded.model.queryText()); - // Both pinch gestures reached the model: the normalized stream's - // product (1.25 * 0.8 = 1.0, the round trip back to unity) times - // the verb's exact 1.5. + // Both pinch gestures reached the model: the raw stream's product + // (1.25 * 0.75 = 0.9375) times the verb's exact 1.5. try std.testing.expectEqual(@as(u32, 2), recorded.model.pinch_begins); try std.testing.expectEqual(@as(u32, 2), recorded.model.pinch_ends); - try std.testing.expectEqual(@as(f32, 1.5), recorded.model.zoom); + try std.testing.expectEqual(@as(f32, 1.40625), recorded.model.zoom); const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); try std.testing.expect(replayed.report.ok()); diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index b6471925..4f4a4a30 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -372,9 +372,9 @@ pub fn TsUiApp(comptime core: type) type { /// without its view is not a position; multi-window cores tell /// pinches apart by these), `phase` (the declared /// begin/change/end alias, matched by member name), `scale` (the - /// MULTIPLICATIVE delta on "change" — hosts normalize additive - /// OS reporting, so the cumulative gesture scale is the product - /// of `1 + scale`, chunking-invariant), and the `x`/`y` pointer anchor + /// magnification DELTA on "change" — multiplicative, so the + /// cumulative gesture scale is the product of `1 + scale`, + /// applied memorylessly), and the `x`/`y` pointer anchor /// in view-local canvas points. The core's return gates the channel /// exactly like a Zig `on_pinch` (null drops the event). fn pinchMsgAdapter(pinch: platform.PinchEvent) ?Msg { diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 0143bbad..6b8528df 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -542,13 +542,13 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// shape). Pinch deliberately bypasses the widget pipeline: /// it is a view-global gesture (timeline/canvas zoom is an /// app-level concern), delivered phase-explicit (`begin`, - /// `change`, `end`) with the per-event MULTIPLICATIVE delta - /// on `change` — the cumulative gesture scale is the running - /// product of `(1 + scale)`, applied memorylessly - /// (`zoom *= 1 + scale`); hosts normalize additive OS - /// reporting (AppKit's additive `NSEvent.magnification`) - /// into these factors, so the product matches what the OS - /// measured regardless of event chunking — and the pointer anchor in + /// `change`, `end`) with the per-event magnification DELTA + /// on `change` — a MULTIPLICATIVE delta: the cumulative + /// gesture scale is the running product of `(1 + scale)`, + /// applied memorylessly (`zoom *= 1 + scale`); on macOS the + /// host forwards AppKit's raw `NSEvent.magnification`, + /// which IS that delta per the browser-engine convention + /// — and the pointer anchor in /// view-local canvas points (`x`/`y`, the zoom-at-cursor /// anchor). Every event names its source window and view /// (`window_id`/`label`, the `on_frame` identity shape) — diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index 321416b4..ad7bfd1d 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4125,16 +4125,11 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca // begin -> change -> change -> change -> end, the host's phase // stream: the model hears every phase, the pointer anchor rides - // x/y, and the cumulative scale is the PRODUCT of (1 + delta). The - // deltas here are what the normalizing macOS host emits for a - // gesture AppKit chunked as three raw +0.25 magnifications - // (magnification is ADDITIVE; the host converts each chunk into the - // ratio it moved the gesture total by): 0.25, then 0.2, then 1/6 — - // so the product tracks Apple's 1 + Σmagnification: 1.5 after two - // chunks, 1.75 after three. The f32 wire values of 0.2 and 1/6 are - // rounded, but both products round exactly to 1.5 and 1.75 (see - // the normalization mirror test in platform/macos/root.zig), so - // the pins are exact. + // x/y, and the cumulative scale is the PRODUCT of (1 + delta) — + // two +25% steps land on 1.5625, never the 1.45 a sum-of-deltas + // would produce (deltas are raw NSEvent.magnification, the + // multiplicative per-event delta per the engine convention; see + // the doctrine note in appkit_host.m). try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, @@ -4159,23 +4154,21 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .kind = .pinch_change, .x = 120, .y = 80, - .scale = 0.2, + .scale = 0.25, } }); - try std.testing.expectEqual(@as(f32, 1.5), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.5625), app_state.model.scale); try std.testing.expectEqual(@as(u32, 0), app_state.model.ends); // A terminal-delta Ended: AppKit's Ended/Cancelled event still // carries the magnification since the previous event, so the host - // folds it into the gesture's running sum and forwards it as one - // last change BEFORE the end marker — the third raw +0.25 chunk - // normalizes to the factor 1.75/1.5, delta 1/6, and the product - // lands on 1.75 (Apple's 1 + 0.75). + // forwards it as one last change BEFORE the end marker — the + // product folds it in (1.5625 * 1.25 = 1.953125, binary-exact). try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, .label = canvas_label, .kind = .pinch_change, .x = 120, .y = 80, - .scale = 1.0 / 6.0, + .scale = 0.25, } }); try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = 1, @@ -4185,9 +4178,9 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .y = 80, } }); try std.testing.expectEqual(@as(u32, 1), app_state.model.ends); - try std.testing.expectEqual(@as(f32, 1.75), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); // The dispatched Msgs rebuilt the view from the model. - try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.75")); + try std.testing.expect(try retainedTextExists(&harness.runtime, "Zoom 1.95")); // A zero-delta Ended is just the end marker: begin then end moves // no scale. @@ -4207,7 +4200,7 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca } }); try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); try std.testing.expectEqual(@as(u32, 2), app_state.model.ends); - try std.testing.expectEqual(@as(f32, 1.75), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); // Non-pinch raw input never leaks into the channel: a scroll leaves // the model untouched. @@ -4217,7 +4210,7 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca .kind = .scroll, .delta_y = 24, } }); - try std.testing.expectEqual(@as(f32, 1.75), app_state.model.scale); + try std.testing.expectEqual(@as(f32, 1.953125), app_state.model.scale); try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); // The automation verb drives the same real events: one full gesture diff --git a/tests/ts-core/markup_e2e_tests.zig b/tests/ts-core/markup_e2e_tests.zig index a5225873..231b8c38 100644 --- a/tests/ts-core/markup_e2e_tests.zig +++ b/tests/ts-core/markup_e2e_tests.zig @@ -484,17 +484,11 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { // pinchMsg: the trackpad pinch channel — the phase alias matches by // member name, begin/end gate to null in the core, and each change - // compounds the zoom by (1 + delta), a PRODUCT, never a sum. The - // deltas are what the normalizing macOS host emits for a gesture - // AppKit chunked as two raw +0.25 magnifications (additive: total - // 1.5): factors 1.25 then 1.2, so the product is 1.5 regardless of - // chunking. The core accumulates in f64 from the f32 wire deltas, - // and 0.2's f32 rounding carries into the f64 product (~4e-9 high), - // hence the ulp-tight tolerance instead of exact equality — the - // f32-model twins of this pin (ui_app/session tests) round exactly. - // The source identity (windowId/label) rides the record into the - // core: this single-view fixture pins that the emitted core hears - // WHICH window and view the gesture happened on. + // compounds the zoom by (1 + delta): two +25% deltas land on the + // PRODUCT 1.5625, never a sum's 1.45. The source identity + // (windowId/label) rides the record into the core: this single-view + // fixture pins that the emitted core hears WHICH window and view + // the gesture happened on. try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoom); try std.testing.expectEqual(@as(f64, 0), Bridge.model().zoomWindowId); try std.testing.expect(!Bridge.model().zoomFromBoard); @@ -520,7 +514,7 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { .kind = .pinch_change, .x = 120, .y = 80, - .scale = 0.2, + .scale = 0.25, } }); try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{ .window_id = 1, @@ -529,22 +523,19 @@ test "the wiring channels drive the core: frame, key, appearance, and chrome" { .x = 120, .y = 80, } }); - try std.testing.expectApproxEqAbs(@as(f64, 1.5), Bridge.model().zoom, 1e-8); + try std.testing.expectEqual(@as(f64, 1.5625), Bridge.model().zoom); // The core saw the source identity: window 1, this fixture's view // label (the core compares `pinch.label === "ts-markup-canvas"`). try std.testing.expectEqual(@as(f64, 1), Bridge.model().zoomWindowId); try std.testing.expect(Bridge.model().zoomFromBoard); - // The automation pinch verb dispatches the same real events into - // the transpiled core: one gesture whose single change carries - // scale - 1 (the verb's is the FINAL multiplicative zoom — - // chunking-trivial, so it needs no normalization and gets none). - // The doubling is exact; the tolerance only carries the wire - // rounding already in the zoom. + // The automation pinch verb dispatches the same real events into the + // transpiled core: one gesture whose single change carries scale - 1 + // (the verb's is the FINAL multiplicative zoom). var pinch_buffer: [96]u8 = undefined; const pinch = try std.fmt.bufPrint(&pinch_buffer, "widget-pinch {s} 2", .{canvas_label}); try h.harness.runtime.dispatchAutomationCommand(h.app, pinch); - try std.testing.expectApproxEqAbs(@as(f64, 3.0), Bridge.model().zoom, 2e-8); + try std.testing.expectEqual(@as(f64, 3.125), Bridge.model().zoom); } test "boot images register and launch env overrides dispatch at install" { From 3395f5e1a3029adadee141feea6715a180b86126 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 11:00:53 -0500 Subject: [PATCH 13/14] Refuse widget-pinch scales whose f32 delta rounds to a zero factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The parser's finite-and-positive guard is not enough on the f32 wire: any scale at or below 2^-25 (e.g. 1e-20) rounds `scale - 1` to exactly -1, dispatching a pinch_change with factor 1 + delta = 0 — a zoom through zero scale no gesture can perform and no downstream product can recover from. - The dispatch now validates the computed wire delta and refuses with error.PinchScaleBelowWireMinimum before anything dispatches (no partial gesture reaches the journal); the minimum accepted scale — the smallest f32 above 2^-25, ~2.9802326e-8 — is named at the guard. - Tests pin both sides: `widget-pinch 1e-20` is refused with the named error and moves no model state, and the smallest accepted scale round-trips a positive factor (delta -1 + 2^-24, model product exactly 0x1.8p-25). --- src/runtime/automation_widget_dispatch.zig | 22 ++++++++++++++++++++-- src/runtime/ui_app_tests.zig | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 5d0457c9..e92a4dea 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -288,7 +288,10 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { /// `pinch_begin`, one `pinch_change` carrying `scale - 1` (so /// the cumulative product of `1 + delta` lands exactly on the /// commanded scale — the gesture's FINAL multiplicative zoom), - /// and `pinch_end`, all at the same anchor point. + /// and `pinch_end`, all at the same anchor point. A scale so + /// small its f32 delta rounds to -1 (factor 0 on the wire) is + /// refused as `PinchScaleBelowWireMinimum` — see the guard + /// below for the named minimum. /// Plain input synthesis, the `widget-key` discipline: every /// event journals as itself and replays through the same /// dispatch — no accessibility-action record, because pinch is @@ -302,6 +305,21 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { self.views[view_index].gpu_size.width / 2, self.views[view_index].gpu_size.height / 2, ); + // The wire delta is `scale - 1` in f32, and f32 rounding can + // betray the parser's `scale > 0` guard: any scale at or + // below 2^-25 rounds the difference to exactly -1 + // (ties-to-even at the halfway point), which would put the + // factor `1 + delta = 0` on the wire — a zoom through zero + // scale, which no gesture can perform and no downstream + // product can recover from. The invariant is the WIRE's + // (every emitted factor stays > 0), so validate the computed + // delta, not the input; the minimum accepted scale is the + // smallest f32 above 2^-25 (~2.9802326e-8), whose delta + // rounds to -1 + 2^-24 — the smallest positive factor the + // wire can carry. Refused before anything dispatches, so no + // partial gesture reaches the journal. + const delta = pinch.scale - 1; + if (1 + delta <= 0) return error.PinchScaleBelowWireMinimum; const timestamp_ns = automationInputTimestampNs(); try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = window_id, @@ -318,7 +336,7 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { .timestamp_ns = timestamp_ns, .x = point.x, .y = point.y, - .scale = pinch.scale - 1, + .scale = delta, } }); try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ .window_id = window_id, diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index ad7bfd1d..f363c140 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4239,6 +4239,27 @@ test "trackpad pinch reaches the app through on_pinch with product-of-deltas sca const pinch_bad = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 0", .{canvas_label}); try std.testing.expectError(error.InvalidCommand, harness.runtime.dispatchAutomationCommand(app, pinch_bad)); try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); + + // The f32 wire can betray the parser's `scale > 0` guard: a tiny + // positive scale rounds `scale - 1` to exactly -1 — factor 0 on the + // wire — so the dispatch refuses it with the wire-minimum teaching + // instead of emitting a zoom through zero scale. Nothing dispatches: + // no begin, no journaled partial gesture. + const pinch_tiny = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 1e-20", .{canvas_label}); + try std.testing.expectError(error.PinchScaleBelowWireMinimum, harness.runtime.dispatchAutomationCommand(app, pinch_tiny)); + try std.testing.expectEqual(@as(u32, 2), app_state.model.begins); + try std.testing.expectEqual(@as(f32, 0.75), app_state.model.scale); + + // The smallest accepted scale — the first f32 above 2^-25 — round- + // trips with a positive factor: its delta rounds to -1 + 2^-24, so + // the factor is exactly 2^-24 and the model's product stays > 0 + // (0.75 * 2^-24 = 0x1.8p-25, binary-exact). + const pinch_min = try std.fmt.bufPrint(&command_buffer, "widget-pinch {s} 2.9802326e-8", .{canvas_label}); + try harness.runtime.dispatchAutomationCommand(app, pinch_min); + try std.testing.expectEqual(@as(u32, 3), app_state.model.begins); + try std.testing.expectEqual(@as(u32, 3), app_state.model.ends); + try std.testing.expect(app_state.model.scale > 0); + try std.testing.expectEqual(@as(f32, 0x1.8p-25), app_state.model.scale); } const zoom_pair_main_views = [_]app_manifest.ShellView{ From 1416f8e94bf98fa359a772026592288ceed55af3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 11:51:20 -0500 Subject: [PATCH 14/14] Class export-list wiring entries at the host boundary like the modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integer inference keyed its host-ABI marking (number params of the entry's exported functions, and pinchMsg's wholesale record classing) on the inline export modifier only, so the first-class list spelling — `function pinchMsg(...) {...}` plus `export { pinchMsg };` — passed validation and wired the channel but skipped the marking: `pinch.scale === 0` int-claimed the slot to i64, every real magnification delta rounded to 0, and the adapter's zero gate silently dropped the events. The seam now reuses exportListBindings (the same export-list truth the checker, emitter, and model-helper surfaces already resolve by): an un-renamed entry-module list binding marks exactly like the modifier, and renamed bindings stay out on purpose — renamed wiring names never reach inference (NS1014/NS1047 fence them first) and a renamed ordinary function keeps its historical classing. - Family audit: pinchMsg was the only wiring entry observably broken (it force-classes its record's fields; frameMsg/keyMsg/commandMsg carry no number param slots and keep by-usage field classing, appearanceMsg/chromeMsg/envMsgs are consts the emitter already reads list-aware, and model helpers were already list-aware in types.ts) — but the same hole covered every export-list function's number params, where a `=== 0` comparison int-claimed an i64 signature that would truncate host f64 arguments; the one seam fixes both. - Tests pin the list spelling end to end: emitter mirrors of the pinch boundary-float classing and the frameMsg channel, an f64 host-boundary signature pin, an NS1014 pin that a renamed list entry still cannot bind a wiring name, a run-fidelity case whose 0.25 probe an i64 signature cannot even accept, and the markup e2e fixture's pinchMsg now exports by list so the native battery proves 0.25 deltas survive to the 1.5625 zoom product. --- packages/core/src/infer.ts | 87 ++++++++++++--------- packages/core/test/emitter.test.ts | 101 +++++++++++++++++++++++++ packages/core/test/runfidelity.test.ts | 18 +++++ tests/ts-core/markup_fixture.ts | 9 ++- 4 files changed, 179 insertions(+), 36 deletions(-) diff --git a/packages/core/src/infer.ts b/packages/core/src/infer.ts index 3fd1d369..73d80a7f 100644 --- a/packages/core/src/infer.ts +++ b/packages/core/src/infer.ts @@ -23,7 +23,7 @@ // into a slot some use forces to be an integer — that is a conflict reported // as a teaching error (NS1016), never a downstream Zig type error. -import { ts, TypedAst } from "./typed_ast.ts"; +import { ts, TypedAst, exportListBindings } from "./typed_ast.ts"; import { constFunctionValue } from "./ownership.ts"; import { thrownShapeOf } from "./checker.ts"; import type { TypeTable } from "./types.ts"; @@ -491,43 +491,60 @@ export class IntInference { markType("Msg"); // Only the ENTRY module's exports face the host ABI (the wiring and // markup bind through the entry). An exported function in an imported - // module is an ordinary cross-module call, so proof still applies. + // module is an ordinary cross-module call, so proof still applies — + // unless the entry's export list re-exports it, which puts it on the + // entry surface like any other export. + // + // Both export spellings mark: the inline modifier and an un-renamed + // export list entry (`export { pinchMsg }`) export the DECLARATION + // itself (the emitter's isExportedDecl seam emits them identically), + // so boundary classing must see them identically too. Renamed entries + // stay out on purpose: renamed wiring names never reach inference + // (the checker fences them, NS1014/NS1047), and a renamed ordinary + // function keeps its historical classing. + const entryExportedFns = new Set(); for (const stmt of this.files[0].statements) { if (ts.isFunctionDeclaration(stmt) && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { - for (const p of stmt.parameters) { - const slot = this.slots.get(p); - if (slot) { - slot.external = true; - slot.hostBoundary = true; - slot.proven = false; - } + entryExportedFns.add(stmt); + } + } + for (const b of exportListBindings(this.tast, this.files[0])) { + if (!b.renamed && b.target && ts.isFunctionDeclaration(b.target)) entryExportedFns.add(b.target); + } + for (const stmt of entryExportedFns) { + for (const p of stmt.parameters) { + const slot = this.slots.get(p); + if (slot) { + slot.external = true; + slot.hostBoundary = true; + slot.proven = false; } - // The pinch channel's parameter record is intrinsically - // fractional: magnification deltas are ~0.01..0.3 per event and - // the pointer anchor is sub-point, so its number fields are HOST - // values, never provable integers. Marking them boundary-fed - // keeps a core's `pinch.scale === 0` comparison from - // int-claiming the slot (which would round every zoom product - // to whole numbers); the integer side of such a comparison - // widens to f64 instead. The loop marks EVERY field of the - // record, so any field the channel grows (windowId, the source - // identity, included) is boundary-classed without a new rule. - // frameMsg/keyMsg records keep their historical by-usage - // classing. - if (stmt.name?.text === "pinchMsg") { - for (const p of stmt.parameters) { - if (!p.type) continue; - const t = this.table.resolveTypeNode(p.type); - if (t.k !== "struct") continue; - const struct = this.table.structs.get(t.name); - if (!struct) continue; - for (const f of struct.fields) { - const slot = this.slots.get(f.decl); - if (slot) { - slot.external = true; - slot.hostBoundary = true; - slot.proven = false; - } + } + // The pinch channel's parameter record is intrinsically + // fractional: magnification deltas are ~0.01..0.3 per event and + // the pointer anchor is sub-point, so its number fields are HOST + // values, never provable integers. Marking them boundary-fed + // keeps a core's `pinch.scale === 0` comparison from + // int-claiming the slot (which would round every zoom product + // to whole numbers); the integer side of such a comparison + // widens to f64 instead. The loop marks EVERY field of the + // record, so any field the channel grows (windowId, the source + // identity, included) is boundary-classed without a new rule. + // frameMsg/keyMsg records keep their historical by-usage + // classing. + if (stmt.name?.text === "pinchMsg") { + for (const p of stmt.parameters) { + if (!p.type) continue; + const t = this.table.resolveTypeNode(p.type); + if (t.k !== "struct") continue; + const struct = this.table.structs.get(t.name); + if (!struct) continue; + for (const f of struct.fields) { + const slot = this.slots.get(f.decl); + if (slot) { + slot.external = true; + slot.hostBoundary = true; + slot.proven = false; } } } diff --git a/packages/core/test/emitter.test.ts b/packages/core/test/emitter.test.ts index 70162886..5a1cfd66 100644 --- a/packages/core/test/emitter.test.ts +++ b/packages/core/test/emitter.test.ts @@ -1771,3 +1771,104 @@ export function update(model: Model, msg: Msg): Model { assert.equal(legacy_pinch.ok, false); assert.ok(legacy_pinch.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(legacy_pinch.diagnostics)); }); + +test("pinchMsg exported via an export list keeps the boundary-float classing", () => { + // The export-LIST spelling is first-class for entry points (an + // un-renamed entry exports the declaration itself), so it must class + // the pinch record's fields exactly like the inline modifier: `scale + // === 0` never int-claims the slot. Before the seam covered export + // lists, this spelling silently emitted `scale: i64` — every real + // magnification delta rounded to 0 and the host adapter's zero gate + // dropped the events. + const zig = emit(` +export type PinchPhase = "begin" | "change" | "end"; +export interface PinchEvent { readonly windowId: number; readonly label: string; readonly phase: PinchPhase; readonly scale: number; readonly x: number; readonly y: number; } +export interface Model { readonly zoom: number; } +export type Msg = + | { readonly kind: "zoomed"; readonly factor: number } + | { readonly kind: "noop" }; +function pinchMsg(pinch: PinchEvent): Msg | null { + if (pinch.phase !== "change" || pinch.scale === 0 || pinch.windowId === 0) return null; + if (pinch.label !== "board-canvas") return null; + return { kind: "zoomed", factor: 1 + pinch.scale }; +} +export { pinchMsg }; +export function initialModel(): Model { return { zoom: 1 }; } +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { + case "zoomed": return { ...model, zoom: model.zoom * msg.factor }; + case "noop": return model; + } +} +`); + assert.match(zig, /scale: f64/); + assert.match(zig, /windowId: f64/); + assert.match(zig, /zoom: f64/); + assert.match(zig, /pub fn pinchMsg/); +}); + +test("frameMsg exported via an export list emits its channel and holds the contract", () => { + // The sibling channels go through the same export seam: the list + // spelling emits the wired `pub fn` and the NS1033 shape contract + // still gates it. + const zig = emit(` +export interface Model { readonly w: number; } +export interface FrameEvent { readonly width: number; readonly height: number; readonly timestampMs: number; readonly intervalMs: number; } +export type Msg = { readonly kind: "resized"; readonly w: number } | { readonly kind: "noop" }; +function frameMsg(model: Model, frame: FrameEvent): Msg | null { + if (frame.width !== model.w) return { kind: "resized", w: frame.width }; + return null; +} +export { frameMsg }; +export function initialModel(): Model { return { w: 0 }; } +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { case "resized": return { w: msg.w }; case "noop": return model; } +} +`); + assert.match(zig, /pub fn frameMsg/); + + const bad_frame = transpile(` +export interface Model { readonly w: number; } +export interface FrameEvent { readonly width: number; } +export type Msg = { readonly kind: "a" } | { readonly kind: "b" }; +function frameMsg(model: Model, frame: FrameEvent): Msg | null { return null; } +export { frameMsg }; +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { case "a": return model; case "b": return model; } +} +`); + assert.equal(bad_frame.ok, false); + assert.ok(bad_frame.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(bad_frame.diagnostics)); +}); + +test("an export-list-exported function's number params are host-boundary", () => { + // The general shape of the same seam: `export { scaled }` puts the + // function on the entry's host surface exactly like the modifier, so a + // `=== 0` comparison must widen instead of int-claiming the parameter + // (an i64 signature would truncate host f64 arguments). + const zig = emit(` +function scaled(x: number): number { return x === 0 ? -1 : x * 4; } +export { scaled }; +`); + assert.match(zig, /pub fn scaled\(x: f64\)/); +}); + +test("NS1014: a renamed export list entry cannot bind a wiring entry name", () => { + // NS1014's fencing is unchanged by the export-list marking: the build + // wires the DECLARATION, so a renamed binding over a wiring name is + // still taught, never silently wired. + const renamed = transpile(` +export type PinchPhase = "begin" | "change" | "end"; +export interface PinchEvent { readonly windowId: number; readonly label: string; readonly phase: PinchPhase; readonly scale: number; readonly x: number; readonly y: number; } +export interface Model { readonly zoom: number; } +export type Msg = { readonly kind: "a" } | { readonly kind: "b" }; +function zoomIn(pinch: PinchEvent): Msg | null { return null; } +export { zoomIn as pinchMsg }; +export function initialModel(): Model { return { zoom: 1 }; } +export function update(model: Model, msg: Msg): Model { + switch (msg.kind) { case "a": return model; case "b": return model; } +} +`); + assert.equal(renamed.ok, false); + assert.ok(renamed.diagnostics.some((d) => d.id === "NS1014"), JSON.stringify(renamed.diagnostics)); +}); diff --git a/packages/core/test/runfidelity.test.ts b/packages/core/test/runfidelity.test.ts index 796c8456..a24b1adc 100644 --- a/packages/core/test/runfidelity.test.ts +++ b/packages/core/test/runfidelity.test.ts @@ -4225,6 +4225,24 @@ export function bump(n: number): number { return n + OFFSET; } { fn: "bump", args: [i(2)] }, ], }, + { + name: "an export-list entry function's number params stay host-boundary f64", + // The un-renamed list spelling puts the declaration on the entry's + // host surface exactly like the inline modifier, so a `=== 0` + // comparison against an all-integer body must never int-claim the + // parameter: the host passes fractional f64s there (an i64 signature + // would not even accept the driver's 0.25). + src: ` +function scaledOr1(n: number): number { return n === 0 ? -1 : n * 4; } +export { scaledOr1 }; +`, + calls: [ + { fn: "scaledOr1", args: [f(0.25)] }, + { fn: "scaledOr1", args: [f(0.3)] }, + { fn: "scaledOr1", args: [f(0)] }, + { fn: "scaledOr1", args: [f("-0")] }, + ], + }, { name: "heterogeneous throws: the thrown union narrows by kind in the catch, chains and callbacks included", src: ` diff --git a/tests/ts-core/markup_fixture.ts b/tests/ts-core/markup_fixture.ts index 9f088b53..268cb451 100644 --- a/tests/ts-core/markup_fixture.ts +++ b/tests/ts-core/markup_fixture.ts @@ -95,10 +95,17 @@ export function keyMsg(key: KeyEvent): Msg | null { /// (1 + delta), the documented gesture-scale semantics. The source /// identity rides into the Msg so the model can pin which window and /// view the gesture happened on. -export function pinchMsg(pinch: PinchEvent): Msg | null { +/// +/// Exported by LIST on purpose: the un-renamed entry exports the +/// declaration itself, so the wiring and the boundary-float classing +/// must treat this spelling exactly like the inline modifier — the +/// fractional deltas below (0.25 per change) must survive as f64 for +/// the zoom product to land on 1.5625. +function pinchMsg(pinch: PinchEvent): Msg | null { if (pinch.phase !== "change" || pinch.scale === 0) return null; return { kind: "zoomed", factor: 1 + pinch.scale, windowId: pinch.windowId, fromBoard: pinch.label === "ts-markup-canvas" }; } +export { pinchMsg }; export const appearanceMsg = "appearance_changed"; export const chromeMsg = "chrome_changed";