Files
vercel-labs--zero-native/packages/core/sdk/events.ts
T
Chris Tate a33d579177 fix(macos): preserve file drop targets (#374)
* fix(macos): preserve file drop targets

- Route AppKit drops with labeled, view-local coordinates across canvas, WebView, and window fallback paths.
- Preserve drop metadata through Zig and cover widget routing, TypeScript contracts, docs, and examples.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>

* fix(macos): preserve file drop coordinates

* fix: keep file drop targets sized

* fix: keep capability webview interactive

* fix: constrain capabilities window width

---------

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 23:11:12 -05:00

355 lines
15 KiB
TypeScript

// @native-sdk/core/events — the canonical event record types the host and
// markup deliver into an app core, an SDK LIBRARY module: pure type
// declarations in the app-core subset, emitted into your core when imported
// and absent when not. Under node the same file resolves as-is.
//
// Every record here matches, field for field, the structural shape a
// runtime matcher expects — markup's `on-input`/`on-scroll` mirrors
// (`declaredTextInputUnion`/`declaredScrollStateRecord`), the generated
// wiring's channel builders (`frameMsg`/`keyMsg`/`pinchMsg`/`dropMsg`/
// `appearanceMsg`/`chromeMsg`), and the audio event arm validator. Matching stays
// STRUCTURAL either way (type identity cannot cross the emission
// boundary), so declaring the same shape in your own core remains legal;
// these exports exist so no core has to re-type the vocabulary and no
// hand-rolled mirror can drift. One home per type: the text-input family
// lives with the byte-splice engine in `@native-sdk/core/text` and is
// re-exported here, so `@native-sdk/core/events` resolves every event
// record an app binds. (Importing this module pulls `./text.ts` into the
// emitted core with it; unused engine functions cost source lines, never
// binary — Zig compiles only what the core references.)
//
// Names are unique across a core's whole module graph (NS1038): a core
// that imports one of these types cannot also declare its own record
// under the same name — delete the in-file mirror and import it instead.
//
// How each type is bound:
// - `TextInputEvent`, `ScrollState`: Msg-arm PAYLOAD fields —
// `{ kind: "draft_edit"; edit: TextInputEvent }`,
// `{ kind: "scrolled"; scroll: ScrollState }`.
// - `FrameEvent`, `KeyEvent`, `PinchEvent`, `FileDropEvent`: the wiring channels'
// parameter records — `frameMsg(model, frame: FrameEvent)`,
// `keyMsg(key: KeyEvent)`, `pinchMsg(pinch: PinchEvent)`,
// `dropMsg(drop: FileDropEvent)`.
// - `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
// the event's fields — the subset has no intersection arms).
// - `AppearanceEvent`, `ChromeEvent`, `AudioEvent`: the full arm
// payload shapes, canonical and importable for helper signatures
// (an arm value is structurally assignable to its event record).
// - `StatusItemState`, `StatusItemDescriptor`, and their nested records:
// the model-derived shell returned by `statusItem(model)` or keyed
// `statusItems(model)`; the generated launcher refreshes shell,
// presentation, visibility, and menu after committed model changes.
export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.ts";
/// One row in a menu-bar status item's menu. Non-separator rows need a
/// unique non-zero `id`, a non-empty `label`, and (when actionable) a
/// command name accepted by `commandMsg`. Rich readout roles may carry
/// secondary `detail`; command rows may carry a key equivalent. Spell
/// every field explicitly because app-core records have one exact shape.
/// A status item may expose at most 32 rows.
export type StatusItemTone = "normal" | "warning" | "critical";
export type StatusItemMenuRole = "command" | "info" | "header" | "hero" | "agent" | "context";
export interface StatusItemModifiers {
readonly primary: boolean;
readonly command: boolean;
readonly control: boolean;
readonly option: boolean;
readonly shift: boolean;
}
export interface StatusItemPresentation {
readonly title: Uint8Array;
readonly width: number;
readonly tone: StatusItemTone;
readonly iconOpacity: number;
readonly monospaced: boolean;
}
export interface StatusItemMenuItem {
readonly id: number;
readonly label: Uint8Array;
readonly command: Uint8Array;
readonly separator: boolean;
readonly enabled: boolean;
readonly detail: Uint8Array;
readonly role: StatusItemMenuRole;
readonly key: Uint8Array;
readonly modifiers: StatusItemModifiers;
}
/// The generated launcher's model-derived menu-bar status item. Export
/// `statusItem(model: Model): StatusItemState` from `src/core.ts`; its
/// shell, presentation, and rows are re-derived after every committed model
/// update. Byte slices are borrowed only for that apply, so returning literals
/// or freshly built arrays is safe.
export interface StatusItemState {
readonly iconPath: Uint8Array;
readonly tooltip: Uint8Array;
readonly activationCommand: Uint8Array;
readonly alternateActivationCommand: Uint8Array;
readonly openCommand: Uint8Array;
readonly presentation: StatusItemPresentation;
readonly items: readonly StatusItemMenuItem[];
}
/// One independently reconciled menu-bar status item. Export
/// `statusItems(model: Model): readonly StatusItemDescriptor[]`; `id`
/// preserves the native item while all remaining fields update live.
export interface StatusItemDescriptor {
readonly id: number;
readonly visible: boolean;
readonly iconPath: Uint8Array;
readonly tooltip: Uint8Array;
readonly activationCommand: Uint8Array;
readonly alternateActivationCommand: Uint8Array;
readonly openCommand: Uint8Array;
readonly presentation: StatusItemPresentation;
readonly items: readonly StatusItemMenuItem[];
}
/// What the user's close affordance does for a model-declared secondary
/// window. `quit` really closes it and routes `onCloseCommand`; `hide` keeps
/// the native window and view tree alive for `Cmd.showWindow(label)`.
export type WindowClosePolicy = "quit" | "hide";
export type WindowTitlebarStyle = "standard" | "hidden_inset" | "hidden_inset_tall" | "chromeless";
/// Author-facing input to `windowDescriptor`; omitted fields receive the
/// same defaults as UiApp.WindowDescriptor.
export interface WindowDescriptorSpec {
readonly label: Uint8Array;
readonly canvasLabel: Uint8Array;
readonly title?: Uint8Array;
readonly width?: number;
readonly height?: number;
readonly x?: number | null;
readonly y?: number | null;
readonly resizable?: boolean;
readonly minWidth?: number;
readonly minHeight?: number;
readonly titlebar?: WindowTitlebarStyle;
readonly transparent?: boolean;
readonly alwaysOnTop?: boolean;
readonly clickThrough?: boolean;
readonly activateOnShow?: boolean;
readonly allowsFullscreen?: boolean;
readonly closePolicy?: WindowClosePolicy;
readonly onCloseCommand?: Uint8Array;
}
/// One independently reconciled secondary window returned by
/// `windows(model)`. The generated launcher compiles
/// `src/windows/<label>.native` as its view; presence is liveness. Construct
/// entries with `windowDescriptor` and spell `label` as a literal
/// `asciiBytes("<label>")` so check/build can prove that root exists.
export interface WindowDescriptor {
readonly label: Uint8Array;
readonly canvasLabel: Uint8Array;
readonly title: Uint8Array;
readonly width: number;
readonly height: number;
readonly x: number | null;
readonly y: number | null;
readonly resizable: boolean;
readonly minWidth: number;
readonly minHeight: number;
readonly titlebar: WindowTitlebarStyle;
readonly transparent: boolean;
readonly alwaysOnTop: boolean;
readonly clickThrough: boolean;
readonly activateOnShow: boolean;
readonly allowsFullscreen: boolean;
readonly closePolicy: WindowClosePolicy;
readonly onCloseCommand: Uint8Array;
}
/// The scroll-state mirror markup's `on-scroll` matches structurally: a
/// record of exactly these eight numeric fields — the TWO-AXIS shape, one
/// offset/velocity/viewport/content quartet per axis. Offsets and extents
/// are canvas points; velocities are points per second while a fling
/// decays. A vertical list carries live `...Y` fields and quiet `...X`
/// ones (offset 0, content pinned to the viewport width); a horizontal
/// shelf the reverse. Echo `offsetY` into the model field bound as the
/// scroll's `value` (and `offsetX` into `value-x` on horizontal-capable
/// regions) to keep the region model-driven — setting those fields in
/// update scrolls it.
export interface ScrollState {
readonly offsetX: number;
readonly offsetY: number;
readonly velocityX: number;
readonly velocityY: number;
readonly viewportExtentX: number;
readonly viewportExtentY: number;
readonly contentExtentX: number;
readonly contentExtentY: number;
}
/// The presented-frame channel's record (`frameMsg(model, frame)`): the
/// canvas size in points plus the frame clock in fractional milliseconds.
/// Return null from `frameMsg` for frames that change nothing — the idle
/// law holds exactly when an idle app dispatches nothing.
export interface FrameEvent {
readonly width: number;
readonly height: number;
readonly timestampMs: number;
readonly intervalMs: number;
}
/// The key-fallback channel's record (`keyMsg(key)`): the key NAME arrives
/// lowercased ("space", "arrowleft"), plus the four modifier booleans. A
/// focused widget's own keys and editable text always win first.
export interface KeyEvent {
readonly key: string;
readonly shift: boolean;
readonly control: boolean;
readonly alt: boolean;
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;
}
/// A file drop's optional point in top-left-origin local coordinates. A
/// non-empty `FileDropEvent.viewLabel` names its canvas or WebView coordinate
/// space; an empty label may carry window-content coordinates. Hosts that
/// cannot resolve local coordinates leave `FileDropEvent.point` null.
export interface FileDropPoint {
readonly x: number;
readonly y: number;
}
/// The app-level file-drop channel's record (`dropMsg(drop)`). `windowId`
/// and `viewLabel` identify the source; `point` is present when the host can
/// resolve local coordinates. The macOS system host reports labeled canvas or
/// WebView coordinates and degrades unlabeled regions to window-content
/// coordinates; hosts without either leave `point` null. Paths are byte text
/// so arbitrary UTF-8 filesystem names cross without becoming runtime JS
/// strings. Return null from `dropMsg` to ignore a drop, or map it to an
/// ordinary Msg.
export interface FileDropEvent {
readonly windowId: number;
readonly viewLabel: string;
readonly point: FileDropPoint | null;
readonly paths: readonly Uint8Array[];
}
/// 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).
export type ColorScheme = "light" | "dark";
/// The appearance arm's payload shape, whole: what the system appearance
/// channel delivers into the arm `appearanceMsg` names. The arm itself
/// stays an inline union member carrying exactly these fields plus `kind`.
export interface AppearanceEvent {
readonly colorScheme: ColorScheme;
readonly reduceMotion: boolean;
readonly highContrast: boolean;
}
/// The chrome arm's insets record: the window-chrome band (hidden-titlebar
/// geometry) in canvas points.
export interface ChromeInsets {
readonly top: number;
readonly right: number;
readonly bottom: number;
readonly left: number;
}
/// The chrome arm's traffic-light record: the window buttons' frame in
/// canvas points.
export interface ChromeButtons {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/// The chrome arm's payload shape, whole: what the window-chrome channel
/// delivers into the arm `chromeMsg` names — delivered before the first
/// view build and again whenever the geometry changes.
export interface ChromeEvent {
readonly insets: ChromeInsets;
readonly buttons: ChromeButtons;
readonly tabsProjected: boolean;
}
/// The audio event states, mirroring the engine's event vocabulary:
/// `loaded` acknowledges a successful load with the player's duration
/// estimate; `position` ticks at the platform's honest cadence (~500ms)
/// while playing; `completed` fires exactly once at the natural end;
/// `failed` reports a load/decode/device failure; `rejected` a command the
/// effects layer refused; `spectrum` carries a band-magnitude analysis
/// frame from hosts that analyze their playback.
export type AudioState = "loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum";
/// The audio event arm's payload shape, whole — the one SDK-fixed record,
/// six fields matched by NAME: `positionMs`/`durationMs` are milliseconds,
/// `playing` is the transport state, `buffering` is true while a streamed
/// source stalls waiting for bytes, and `bands` is the 32 spectrum band
/// magnitudes (0..255 each, all zeros outside "spectrum" events).
export interface AudioEvent {
readonly state: AudioState;
readonly positionMs: number;
readonly durationMs: number;
readonly playing: boolean;
readonly buffering: boolean;
readonly bands: Uint8Array;
}
/// Native audio-capture vocabulary and fixed event record. PCM chunks are
/// interleaved signed 16-bit little-endian in the requested format.
export type AudioCaptureSource = "microphone" | "system";
export type AudioCaptureState = "started" | "data" | "failed" | "stopped" | "rejected";
export interface AudioCaptureEvent {
readonly key: number;
readonly state: AudioCaptureState;
readonly source: AudioCaptureSource;
readonly sampleRate: number;
readonly channels: number;
readonly timestampMs: number;
readonly frames: number;
readonly pcm: Uint8Array;
readonly droppedPending: number;
readonly droppedTotal: number;
}