Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/trackpad-pinch.md
Original file line number Diff line number Diff line change
@@ -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 <view-label> <scale> [x y]` drives the real pinch event stream without a trackpad (`<scale>` 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.
4 changes: 4 additions & 0 deletions docs/src/app/automation/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ The runtime watches the command queue and processes these actions:
<td><code>widget-key &lt;view-label&gt; &lt;key&gt; [&lt;text&gt;]</code></td>
<td>Dispatch key input to the focused retained canvas widget</td>
</tr>
<tr>
<td><code>widget-pinch &lt;view-label&gt; &lt;scale&gt; [&lt;x&gt; &lt;y&gt;]</code></td>
<td>Dispatch a trackpad pinch gesture at a gpu-surface view: the real <code>pinch_begin</code>/<code>pinch_change</code>/<code>pinch_end</code> events, with one change carrying <code>scale - 1</code>. <code>scale</code> is the final multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out to half) — the cumulative product of <code>1 + delta</code> lands exactly on it. The anchor point defaults to the view center</td>
</tr>
<tr>
<td><code>shortcut &lt;id&gt;</code></td>
<td>Dispatch a shortcut command event for the main window</td>
Expand Down
2 changes: 2 additions & 0 deletions docs/src/app/cli/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ Interact with the automation server of a running automation-enabled app. See [Au
<dd>Dispatch wheel input at a retained canvas widget.</dd>
<dt><code>automate widget-key &lt;view-label&gt; &lt;key&gt; [&lt;text&gt;]</code></dt>
<dd>Dispatch key input to the focused retained canvas widget.</dd>
<dt><code>automate widget-pinch &lt;view-label&gt; &lt;scale&gt; [&lt;x&gt; &lt;y&gt;]</code></dt>
<dd>Dispatch a trackpad pinch gesture at a gpu-surface view (<code>scale</code> is the final multiplicative zoom for the gesture; the anchor point defaults to the view center).</dd>
<dt><code>automate shortcut &lt;id&gt;</code></dt>
<dd>Dispatch a shortcut command event.</dd>
<dt><code>automate tray-action &lt;item-id&gt;</code></dt>
Expand Down
27 changes: 27 additions & 0 deletions docs/src/app/native-ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <view> <id> 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 <view-label> <scale> [x y]` — `<scale>` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/typescript/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) 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.

<CodeToggle>

Expand Down
41 changes: 39 additions & 2 deletions packages/core/sdk/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);

Expand Down
Loading
Loading