diff --git a/build.zig b/build.zig index a8b5a25f..ea81f29e 100644 --- a/build.zig +++ b/build.zig @@ -636,6 +636,43 @@ 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 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 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-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" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "retainedFrameLastEmitNs" }, diff --git a/changelog.d/trackpad-pinch.md b/changelog.d/trackpad-pinch.md new file mode 100644 index 00000000..c4e36616 --- /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 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 9c70ffee..05834289 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. 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> 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..ba72c5c6 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 (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 8537ee9d..d3bfb336 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; 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, +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]` — `` 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 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/packages/core/sdk/events.ts b/packages/core/sdk/events.ts index 8b6cbe76..5ae5736f 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,42 @@ 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. 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 +/// 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"), 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 +/// 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; + 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..6c23e25f 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,42 @@ export class Emitter { "NS1033", ); } + } else { + // 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; + 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 === 6 && + phaseOk && + 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 `{ 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 7a6d9925..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,15 +491,61 @@ 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)) { + 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) { - const slot = this.slots.get(p); - if (slot) { - slot.external = true; - slot.hostBoundary = true; - slot.proven = false; + 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..5a1cfd66 100644 --- a/packages/core/test/emitter.test.ts +++ b/packages/core/test/emitter.test.ts @@ -1706,3 +1706,169 @@ 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; 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 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 || 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 }; } +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, /label: \[\]const u8/); + assert.match(zig, /zoom: f64/); + assert.match(zig, /pub fn pinchMsg/); + + // 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; } +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)); + + // 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)); +}); + +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/skill-data/automation/SKILL.md b/skill-data/automation/SKILL.md index b5f54e19..6d8a0374 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`. `` 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. +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 diff --git a/src/automation/protocol.zig b/src/automation/protocol.zig index 03d69eb5..a8a08902 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,14 @@ 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 + /// gesture's FINAL multiplicative zoom. The anchor point defaults + /// to the view center. + widget_pinch, menu_command, shortcut, tray_action, @@ -133,6 +144,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 +238,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/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index f004aa90..5a512cf5 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -76,6 +76,17 @@ 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 — + * 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, + 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..caf97704 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,8 @@ - (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)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; @@ -5530,6 +5533,80 @@ - (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. + // + // 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 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 emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; + [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. + self.pinchGestureActive = YES; + [self emitPinchInputEventWithKind:NATIVE_SDK_APPKIT_GPU_INPUT_PINCH_BEGIN event:event magnification:0]; + } + [self emitPinchChangeForEvent:event]; + return; + } + if (phase & (NSEventPhaseEnded | NSEventPhaseCancelled)) { + if (!self.pinchGestureActive) return; + self.pinchGestureActive = NO; + // 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; + } + // NSEventPhaseMayBegin / stationary phases carry no gesture fact. +} + - (void)keyDown:(NSEvent *)event { if ([self focusedTextAccessibilityElement]) { self.interpretedKeyEventEmittedInput = NO; @@ -5710,6 +5787,59 @@ - (void)emitQueuedScrollInputEvent { deltaY:deltaY]; } +// --- 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 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]; + 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..32969fbe 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, }; } @@ -2242,6 +2248,34 @@ 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 pointer anchor on x/y (the host's converted, top-left-origin + // pointer location). + 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)); 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/platform/types.zig b/src/platform/types.zig index 8fbd0fa8..eacc1f83 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,73 @@ pub const GpuSurfaceInputEvent = struct { text: []const u8 = "", composition_cursor: ?usize = null, modifiers: ShortcutModifiers = .{}, + /// 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 + /// `locationInWindow`), and zoom-at-cursor is the anchoring apps + /// want. + 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. +/// 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, + 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 { + /// 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: 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 + /// 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, }; /// Upper bound on native scroll drivers per gpu-surface view (one per 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/automation_commands.zig b/src/runtime/automation_commands.zig index 059d951f..120fcc74 100644 --- a/src/runtime/automation_commands.zig +++ b/src/runtime/automation_commands.zig @@ -58,6 +58,21 @@ pub const AutomationWidgetKey = struct { modifiers: AutomationKeyModifiers = .{}, }; +/// `widget-pinch [ ]`: a trackpad pinch +/// 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`. 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. +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 +276,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..e92a4dea 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -284,6 +284,70 @@ 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), + /// 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 + /// 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, + ); + // 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, + .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 = delta, + } }); + 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/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/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/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 = .{ 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/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index 8eddc80d..4f4a4a30 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,56 @@ 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 — `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" — 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 { + 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 != 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 { windowId: number; label: string; 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)), + }; + // 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); + 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..6b8528df 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -537,6 +537,26 @@ 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` — 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) — + /// 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 /// ids (>= `platform.reserved_timer_id_base`) are handled @@ -2770,6 +2790,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 +3367,33 @@ 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(.{ + .window_id = input_event.window_id, + .label = input_event.label, + .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 diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index a7d18c78..f363c140 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -4042,3 +4042,337 @@ 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, + 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) { + pinch: zero_platform.PinchEvent, +}; + +const ZoomApp = ui_app_model.UiApp(ZoomModel, ZoomMsg); + +fn zoomUpdate(model: *ZoomModel, msg: ZoomMsg) void { + switch (msg) { + .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, + } + }, + } +} + +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 -> 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 (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, + .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.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, + .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); + // 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, + .kind = .pinch_end, + .x = 120, + .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.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. + 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.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, anchor 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.anchor_x); + try std.testing.expectEqual(@as(f32, 150), app_state.model.anchor_y); + + // 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.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). + 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{ + .{ .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 2c638ee8..231b8c38 100644 --- a/tests/ts-core/markup_e2e_tests.zig +++ b/tests/ts-core/markup_e2e_tests.zig @@ -302,6 +302,9 @@ test "the runtime markup interpreter builds the emitted model exactly like the c .stampMs = -1, .draft = "", .canvasWidth = 0, + .zoom = 1, + .zoomWindowId = 0, + .zoomFromBoard = false, .dark = false, .chromeTop = 0, }; @@ -478,6 +481,61 @@ 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. 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, + .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 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). + 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..268cb451 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,15 @@ 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 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. @@ -58,6 +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 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 }; @@ -79,6 +90,23 @@ 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. The source +/// identity rides into the Msg so the model can pin which window and +/// view the gesture happened on. +/// +/// 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"; export const envMsgs = [{ env: "TS_BOARD_BANNER", msg: "banner_set" }] as const; @@ -94,6 +122,9 @@ export function initialModel(): Model { stampMs: -1, draft: new Uint8Array(0), canvasWidth: 0, + zoom: 1, + zoomWindowId: 0, + zoomFromBoard: false, dark: false, chromeTop: 0, }; @@ -158,6 +189,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, zoomWindowId: msg.windowId, zoomFromBoard: msg.fromBoard }; case "appearance_changed": return { ...model, dark: msg.colorScheme === "dark" }; case "chrome_changed": diff --git a/tools/native-sdk/automation.zig b/tools/native-sdk/automation.zig index b8416b47..e6452438 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: 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