| name | native-ui |
| description | Authoring guide for native-rendered Native SDK apps - declarative Native markup (.native) views plus Zig logic on the UiApp loop. Use when building or modifying native UI (widgets, layout, bindings, messages), writing .native files, wiring Model/Msg/update, testing markup views, or verifying a native app through the automation harness. |
Author native UI with markup + Zig
A native-rendered Native SDK app is a markup view plus Zig logic:
src/<view>.native — the entire UI: elements, layout, bindings, message dispatch.
src/main.zig — Model (plain struct), Msg (tagged union), update(model, msg), and a main that hands them to native_sdk.UiApp(Model, Msg).
The markup compiles to the same widget tree a hand-written canvas.Ui(Msg) builder view would produce: identical structural widget ids, identical typed handler table. Markup can never mutate state — it binds values and dispatches messages; all logic lives in Zig.
Editors highlight .native markup well in HTML mode — the default scaffold writes no editor config, so add .vscode/settings.json with "files.associations": {"*.native": "html"} yourself, or scaffold with native init --full, which writes it.
Start a new app with native init (zero-config: app.zon + src + assets, the CLI generates the build graph), or copy examples/habits/ (smallest): change the name/id in app.zon and assets/ copies verbatim — there are no build files to edit. The native dev|test|build verbs drive any app directory shaped this way.
App wiring
const HabitsApp = native_sdk.UiApp(Model, Msg);
pub fn main(init: std.process.Init) !void {
// `create` heap-allocates the multi-MB app struct and constructs the
// Model in place — neither ever rides the stack (avoid `App.init(alloc,
// model, ...)`: its by-value Model is a stack-overflow trap once the
// Model grows).
const app_state = try HabitsApp.create(std.heap.page_allocator, .{
.name = "habits",
.scene = shell_scene, // one window, one gpu_surface view
.canvas_label = "habits-canvas", // must match the ShellView label
.update = update,
.markup = .{
.source = @embedFile("habits.native"),
.watch_path = "src/habits.native", // dev hot reload; omit in release
.io = init.io,
},
});
defer app_state.destroy();
app_state.model = initialModel(); // boot state: assign through the pointer
try runner.runWithOptions(app_state.app(), .{ ... }, init);
}
(create requires every Model field to carry a default; the model starts as .{} and boot state is assigned through the returned pointer. Tests that instantiate the app per fixture should use create/destroy too — a runtime-built Model passed to init by value crashes the test stack once models get large.)
The runtime owns the loop: install on first GPU frame, presentation, resize, pointer/keyboard dispatch into update + rebuild. With watch_path set, editing the .native file while the app runs hot-reloads the view within ~2s, preserving model state and widget ids; parse failures keep the last good view and set app_state.markup_diagnostic (line/column/message).
Release: compile the markup at comptime. canvas.CompiledMarkupView(Model, Msg, source).build parses the .native source entirely at compile time and produces the identical tree (same ids, handlers, dispatch) with no parser in the binary; markup or binding mistakes become compile errors with line/column. Hand it to .view, and gate the runtime engine per build mode:
const dev = @import("builtin").mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev });
const CompiledView = canvas.CompiledMarkupView(Model, Msg, @embedFile("habits.native"));
// options:
.view = CompiledView.build,
.markup = if (dev) .{ .source = ..., .watch_path = "src/habits.native", .io = init.io } else null,
With both set (dev), the compiled view renders until the watched file first changes, then the interpreter hot-reloads it. See examples/habits for the full pattern.
Webview panes: canvas + live web content in one window
Declare the webview in the scene next to the gpu_surface (parent it to the canvas view), reserve its region with an empty panel carrying a semantics label, and let Options.web_panes snap the webview to that widget's layout frame while the model drives navigation:
const shell_views = [_]native_sdk.ShellView{
.{ .label = "app-canvas", .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
.{ .label = "preview", .kind = .webview, .parent = "app-canvas", .url = "https://example.com/", .x = 240, .y = 76, .width = 704, .height = 548 },
};
// view: ui.panel(.{ .grow = 1, .semantics = .{ .label = "preview-pane" } }, .{})
fn panes(model: *const Model, out: []App.WebViewPane) usize {
out[0] = .{ .label = "preview", .anchor = "preview-pane", .url = model.url(), .reload_token = model.reload_token };
return 1;
}
// options: .web_panes = panes,
URL changes navigate; bumping reload_token reloads the same URL (the CenterPane/Preview-tab shape). Pane URLs must pass security.navigation.allowed_origins. Panes reconcile against the runtime's live webview state on every rebuild and presented frame, so shell relayouts cannot detach them. examples/canvas-preview is the live reference; zig build test-canvas-preview-smoke verifies it.
Menu-bar extra (status item)
Options.status_item installs a macOS NSStatusItem once, on the installing frame; its menu items dispatch commands through the same on_command mapping the toolbar and menus use (source .tray):
.status_item = .{ .title = "ZN", .tooltip = "My App", .items = &.{
.{ .id = 1, .label = "Refresh", .command = "app.refresh" },
.{ .separator = true },
.{ .id = 2, .label = "Quit", .command = "app.quit" },
} },
For a LIVE menu-bar extra (an open-count badge in the title, a latest-items dropdown), add Options.status_item_fn — the web_panes pattern: consulted on install and after every rebuild, re-applied only when its output changed (title and menu patch independently; the static status_item keeps icon/tooltip). Format derived strings into the provided scratch; item commands dispatch through on_command exactly like static items:
fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusItemState {
const title = std.fmt.bufPrint(&scratch.title_buffer, "ZN {d}", .{model.open_count}) catch "ZN";
scratch.items[0] = .{ .id = 1, .label = "Refresh", .command = "app.refresh" };
var count: usize = 1;
for (model.latest(), 0..) |issue, i| { // per-row commands: map "issue.select.N" in on_command
scratch.items[count] = .{ .id = @intCast(10 + i), .label = issue.title, .command = issue.select_command };
count += 1;
}
return .{ .title = title, .items = scratch.items[0..count] };
}
// options: .status_item_fn = statusItem,
Title updates retitle the live NSStatusItem button without re-creating it; platforms without a tray-title seam keep menu updates and log the title gap once.
Native scrolling (macOS)
Zero app code: on macOS every non-virtualized scroll region — and every windowed virtual list (ui.virtualList), whose driver content size is the full virtual extent — is driven by an invisible NSScrollView — OS momentum and the system overlay scrollbar — while the engine renders the content. widget.value stays the offset of record, so the rebuild reconcile rule ("user offset survives rebuilds until the source offset changes"), automation snapshot offsets (scroll=[offset=..]), and Options.sync all work exactly as before; the engine-drawn scrollbar simply stops painting for natively driven regions. Programmatic scrolls still work: change the source offset (or scroll via keyboard/automation) and the runtime pushes it into the native scroller. GTK/Win32 and mobile embeds keep the engine's wheel physics unchanged. Nested-scroll saturation handoff (inner region exhausted, outer continues) is per-region native today: the inner region stops at its edge like a standalone scroller.
Overscroll is off by default, per region, on both paths. Scroll regions pin at their content edges — the native scroller gets non-elastic edges, the engine's wheel/kinetic physics clamp, and kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: overscroll="rubber_band" in markup (the scroll element only — the validator rejects it elsewhere with a teaching error) or ElementOptions.overscroll = .rubber_band in Zig views. The ScrollPhysics.overscroll design token (ScrollPhysicsOverrides in a theme) flips the app-wide default; per-region values override it, and .none pins a region regardless of the token. The rubber-band shape — excursion bound, resistance, spring-back rate — stays themable through the rubberband_* physics tokens.
Context menus: one declared menu, platform-decided presentation
Authors write ONE menu; the platform decides how it presents. The default is the real OS context menu at the pointer — NSMenu on macOS, TrackPopupMenu on Windows, GtkPopoverMenu on Linux — and the selection dispatches the item's typed Msg. On hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today), the SAME declared items present automatically as an anchored canvas surface at the click point, with the standard anchored-surface behavior (Escape and outside-click dismiss, late z-pass, window clipping). Never two authored menus, never a canvas imitation where the OS menu exists.
Markup declares the menu as a <context-menu> element — a DIRECT child of the pressable element whose right-click it answers (a hit target, or an element with a bound on-press/on-hold). It is metadata, not content: it renders nothing in the row's flow. Children are menu-items (on-press required, disabled optional, the text content is the label) and bare <separator/>s, with if/else/for around them to swap or repeat items — a menu whose items all evaporate at runtime simply declares no menu (the All Notes row pattern). Conditional MENUS are spelled as conditional ITEMS: the <context-menu> itself takes no attributes and cannot sit behind a structure tag. No submenus: the platform channel carries flat items (label, enabled, separator) only.
<list-item on-press="open_note:{n.id}" label="{n.title}">
<text grow="1">{n.title}</text>
<context-menu>
<if test="{n.deleted}">
<menu-item on-press="restore_note:{n.id}">Restore</menu-item>
<menu-item on-press="purge_note:{n.id}">Delete Permanently</menu-item>
</if>
<else>
<menu-item on-press="copy_note_id:{n.id}">Copy</menu-item>
<menu-item on-press="trash_note:{n.id}">Delete</menu-item>
</else>
</context-menu>
</list-item>
The Zig builder's mirror is ElementOptions.context_menu — per-widget items in the chrome-menu shape with typed messages:
ui.listItem(.{
.on_press = Msg{ .select = entry.index },
.context_menu = &.{
.{ .label = "Open Section", .msg = Msg{ .select = entry.index } },
.{ .separator = true },
.{ .label = "Refresh Dashboard", .msg = .refresh },
},
}, entry.title)
The deepest declaring widget on the hit route wins; disabled items and separators are fine (enabled = false, .separator = true). Zero-code defaults need no declaration: editable text fields present the standard Cut / Copy / Paste / Select All menu wired to the existing clipboard actions, and a selected static text presents Copy (these defaults are presenter-only — without an OS menu they degrade to the keyboard clipboard paths). Touch long-press is design-noted for the mobile embeds: the iOS host's under-slop Pending touch state is the timer seam, pending a secondary-button leg in the embed ABI and UIEditMenuInteraction presentation.
Automation drives the native path honestly: snapshots list every widget's declared items in invocation order (context_menu=["Rename","Delete"], separators keep their slots, disabled items say so), widget-context-press <view> <id> performs the real secondary click (presenting the menu), and widget-context-menu <view> <id> <item-index> invokes an item — the selection dispatches as the same context_menu_action platform event a real pick produces (so it journals and replays), because the OS menu's tracking loop cannot be driven programmatically. Dead invocations fail by name (undeclared menu, index out of range, separator slot, disabled item). examples/notes row menus and examples/gpu-dashboard nav rows carry live menus; zig build test-example-notes, test-example-gpu-dashboard, and the runtime context-menu suite verify dispatch, the fallback surface, and the verb.
Elements
| Markup | Widget | Notes |
|---|
row, column | flex containers | main axis horizontal / vertical |
stack, panel, card | overlay containers | children stack on top of each other — gap can never space them and is a validation error (put a column/row inside for flow) |
scroll | scroll_view | wrap multiple children in a column inside it |
list, grid | list, grid | vertical stack / cell grid |
tabs, toggle-group, button-group, radio-group, breadcrumb, pagination | row containers | children flow horizontally (tab buttons, toggle-buttons, radios, ...) |
table > table-row > table-cell | table, data_row, data_cell | rows only inside a table, cells only inside a row (for/if wrappers are fine); cells are text leaves, dispatch with on-press |
dropdown-menu | dropdown_menu | vertical menu surface; children are menu-items. anchor="below|above" floats it against its PARENT's frame (see Pickers): late z-pass above the whole tree, window-clipped, auto-flipping at the window edges, zero flow space. Pair with on-dismiss |
accordion | accordion | header via text attr; children show while selected, dispatch on-toggle |
alert, bubble | surfaces | alert title via text attr; children stack inside. bubble hugs its message up to 80% of the thread (ghost exempt; explicit width wins) and takes one child — the reaction pill straddling its bottom edge, one text run, dock via (default ); on bubble itself is a teaching error (that channel belongs to the pill). Grouped runs are spacing, not vocabulary: 8 gap within a sender's run, 32 between turns |
Not markup-expressible (deliberately — write these as Zig view functions with canvas.Ui): icon_button (<button icon="..."> with empty content is the declarative icon button), data_grid (per-column cell templates), popover/menu_surface (anchored to runtime geometry), segmented_control (use tabs/toggle-group: <button> children of <tabs> lower to segmented triggers automatically, so the active tab lifts per the house treatment). Charts ARE expressible: <chart> with <series values="{binding}"> children binding model f32 iterables — see the Charts section (.band series and dynamic series composition stay with ui.chart). Built-in vector icons ARE expressible: <icon name="search"/> (closed, compile-checked name set; Ui.icon is the Zig-view equivalent). App-authored icons: canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg")) parses any SVG in the common 24x24 stroke-icon dialect at comptime; register the parsed table once at boot with canvas.icons.registerAppIcons(&table) and draw by name via ui.appIcon(.{...}, "logo") or ElementOptions.icon — registered names render exactly like built-ins on every draw path. Markup <icon>/<button icon> stay built-in-only (the compiled engine validates names at comptime, where runtime registrations cannot exist — engine parity). Runtime images ARE expressible: <image image="{cover}" width="120" height="80" label="Cover art"/> and <avatar image="{user_image}">CT</avatar> bind a u64 ImageId model field/fn (the id is just model data; 0 draws nothing / keeps the initials fallback) — see the Images section; the image binding is required on the leaf (an unbound <image> is dead markup) and stays avatar+image scoped.
Attributes
Layout: gap (flow containers only — stacking containers stack/panel/card/alert/bubble/dialog/drawer/sheet/resizable layer their children, so gap there is a validation error, not silence: wrap the children in a column/row inside; on split it sets the divider band thickness), padding (uniform), grow, width, height (definite: the element is exactly that size — intrinsic content neither shrinks nor silently overflows it; resizable treats width as the initial width), min-width (a floor WITHOUT width's definite max — the element may grow past it but never shrink below; on split panes it bounds the divider drag), wrap (text only: wrap="true" word-wraps at the width the element receives and reserves the wrapped height in columns; wrap="false" and unset are honest single-line — one line whose overflow follows overflow), overflow (text only, a teaching error elsewhere: what a single line does with content that does not fit — ellipsis, the default, elides behind a trailing … measured with the same metrics paint uses, right for width-constrained list-row titles; clip hard-cuts at the frame for fixed-format content like a duration column where "1…" beats nothing; there is deliberately no overflow-visible), text-alignment (start|center|end — text leaves, status bars, surface titles; controls that own their label placement ignore it), columns (grid only: fixed column count, omit for the derived near-square grid; a teaching error elsewhere), main (start|center|end|space_between), cross (stretch|start|center|end), virtualized, virtual-item-extent, anchor (dropdown-menu and tooltip, literal below/above: floats the surface against its parent instead of the flow — auto-flips when the preferred side does not fit, height clamps to the chosen side, x clamps into the window; an anchored tooltip's visibility is runtime-owned hover intent, unlike the model-owned dropdown), (with : // — stretch also widens the surface to at least the anchor's width, the select-menu look), (with : literal gap in points, default 4), ( only, beside — a teaching error elsewhere or without it: hover-intent show delay in ms, default 600, = instant; keyboard-focus reveals are always immediate), ( only, a teaching error elsewhere: pins the region at its content edges — the shipped default via the token — lets it bounce past them on both the engine and native paths, follows the token).
Appearance/state: (default|primary|secondary|outline|ghost|destructive), (the control scale default|sm|lg|icon on every sized element; on also the typography rungs heading|display — named typography token steps ( 28, 48, themable like every token) for section headings and hero stats/timer numerals. The two axes stay apart: heading/display on a control is a teaching error naming text as their home, unknown values list the vocabulary, and numeric sizes are refused by design — retheme the typography tokens to move the whole scale), , , , , , (, , , : vector icon drawn inline — buttons/toggle-buttons before the label, list/menu items as a leading slot; a teaching error anywhere else. A built-in name, , or one resolving to such a name). : every control class shares the control height at a given register (default 36, sm 31.5, lg 40.5 before density), so a toolbar/filter row reads as one height exactly when every control in it carries the SAME — mixing buttons with a default field renders two heights in one row, and hand-sized pressable panels () never land on the scale; compose rows from real controls at one register.
Focus: (focusable controls only — a teaching error elsewhere): moves keyboard focus to the element when it MOUNTS or when the bound value turns on, edge-triggered so holding it true never re-steals focus from the user. The TEA way to focus an editor on note-create ( or mount the field under an with ; Zig views use ) and to give keyboard-first apps their first focus without a click.
Semantics: (listitem, treeitem, button, ...; also makes the row part of its tree's roving keyboard focus set), (accessible name — it REPLACES the element's text content as the announced name, so snapshot greps and screen readers see the label, never the text; don't an element whose visible text you grep for), (tree rows: disclosure state, model-owned — omit on leaves). Accessible names are ENFORCED: an interactive control with no text content, no , and no is a validation error (icon-only controls need ; text-entry controls need or ), unknown/misused literal roles are errors ( on a text leaf can never hold rows), unnamed avatars and labels duplicating the text content are warnings ( marks an image decorative). Zig-built trees get the same discipline from (missing names as the bridges would announce them, focusables clipped out of keyboard reach, identically labeled siblings) — adopt it next to the layout sweep.
Identity: (sibling-scoped), (parent-independent — use for items that move between containers, e.g. board cards; ids then survive reparenting).
Window chrome: (Zig: ) marks the element as a window-drag surface for hidden-titlebar windows — pressing its background or plain text/icons inside moves the WINDOW (drag starts only on actual movement), double-click zooms per the OS convention, and press-claiming children (buttons, fields) stay fully interactive via the ordinary press fall-through. macOS-only; elsewhere the press is dead space. See "Hidden titlebar" below.
Render channel (Zig-only, no markup attributes): and wrap the element's emitted commands without reflowing siblings — the defaults (1, identity) emit nothing, opacity 0 culls painting (pair with when fading interactive content), and a transform moves both rendering and pointer hit-testing while accessibility frames stay at the layout frame. Pair with for tweening.
Numbers are plain (gap="12"), booleans are true/false or a binding.
When children's minimum sizes exceed their container, debug builds log a zero_canvas_layout diagnostic naming the container, axis, and overflow in pixels — flex overflow is never silent. In Zig views, .gap on a stacking kind (ui.panel(.{ .gap = 8 }, ...)) logs a zero_canvas_ui warning in debug builds with the same lesson — it never fails the build.
Chips: exclusive selection with selected=
The chip pattern — an exclusive group where the model owns which one is active — is a toggle-group of toggle-buttons (or plain buttons) whose selected= binds the model:
<toggle-group gap="2" label="Theme">
<for each="theme_prefs" as="p">
<toggle-button size="sm" selected="{p == theme_pref}" on-toggle="set_theme:{p}">{p}</toggle-button>
</for>
</toggle-group>
A toggle-button whose source asserts selected (this rebuild or the previous one) is model-driven: the source wins over the runtime's retained toggle on every rebuild, so exactly the model's selection is active — pressing a chip dispatches the Msg, the model moves the selection, and the old chip deactivates. Without a selected= that ever asserts, a toggle-button is uncontrolled: the runtime retains its pressed state across rebuilds (the multi-select formatting-bar case — bold/italic chips with zero app wiring). button with selected= is always model-driven (buttons never retain state) and dispatches on-press; toggle-button dispatches on-toggle (its activation is the toggle intent — an on-press there never fires). Model-driven chips need a handler that actually moves the model: a chip whose Msg is ignored keeps its retained press until the model asserts its selected=.
Pickers: select is the trigger — compose the options as an ANCHORED dropdown
select and combobox are trigger controls, not complete pickers: select renders the closed dropdown shape (current value as content, placeholder while empty, on-press to open) and combobox is a text entry with a menu chevron — neither owns an options list. There is no options= attribute (the closed grammar has no list-valued attributes — list-shaped vocabulary is element children, the way <context-menu> declares its items); the options ARE the composition — a dropdown-menu of menu-items under an if, beside the trigger inside a stack, floated with anchor:
<stack>
<select placeholder="Pick a repo" text="{current_repo}" on-press="toggle_repo_picker"/>
<if test="{repo_picker_open}">
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_repo_picker">
<for each="repos" key="name" as="r">
<menu-item on-press="pick_repo:{r.name}" selected="{r.name == current_repo}">{r.name}</menu-item>
</for>
</dropdown-menu>
</if>
</stack>
How the pieces fit, all model-owned (TEA):
- Open state is the model's.
toggle_repo_picker flips the bool; the surface exists only while the if renders it. There is no hidden engine open flag.
anchor floats the menu. The dropdown positions against its PARENT's frame (the stack, sized by the trigger): below it by default, flipping above when it doesn't fit and the other side has more room, height clamped to the chosen side, x clamped into the window. It consumes NO space in the flow (siblings never reflow), paints in a late z-pass above the whole tree, and escapes every ancestor scroll/clip region — window-clipped, not pane-clipped. anchor-alignment="stretch" widens it to at least the trigger's width (the select look).
on-dismiss closes it model-side. Escape and a click outside the menu dismiss the surface and dispatch the Msg; close_repo_picker clears the bool. Escape works even when the trigger took no focus (a plain-text crumb): with no relevant focus chain it dismisses the topmost mounted anchored surface. The engine hides the surface immediately (the optimistic echo), and the next rebuild's source tree is truth — a model that keeps open true gets it back. Clicking the TRIGGER while open never double-fires: the anchor region owns its surface's toggling, so only toggle_repo_picker dispatches.
- Items close on pick.
pick_repo sets the value AND clears the open flag — a click inside the surface never dismisses.
- Keyboard: once focus is in the menu (tab into it), tab wraps inside the surface (the floating focus scope) and Enter/Space activate items; Escape dismisses from the trigger or the menu.
- Automation sees everything: the floating menu and its items appear in widget snapshots at their real frames and
widget-click <item-id> works while it is open.
combobox composes the same way (the model filters the for source as the user types via on-input). The Zig mirror is ElementOptions.anchor/anchor_alignment/anchor_offset + on_dismiss on a dropdown_menu (or popover/menu_surface, which stay Zig-only) built with ui.eachCtx for the options. Budget: at most 16 anchored surfaces may be mounted per view (max_canvas_widget_anchored_per_view, loud error.WidgetAnchoredSurfaceLimitReached) — an anchor inside a <for> body is almost always a mistake.
Splitters: split panes with a model-owned fraction
split is the resizable two-pane seam: exactly two element children, and the engine synthesizes the draggable divider between them (resize cursor, focusable, ARIA separator whose value is the fraction). The fraction is MODEL-OWNED — the runtime applies each drag/keyboard step as an optimistic echo, dispatches on-resize with the applied fraction, and the model echoes it back through value so the next rebuild lays the panes exactly there:
<split value="{sidebar_split}" on-resize="sidebar_resized">
<column min-width="150">…sidebar…</column>
<split value="{list_split}" on-resize="list_resized">
<column min-width="220">…list…</column>
<column min-width="280">…editor…</column>
</split>
</split>
on-resize names an f32 Msg variant (sidebar_resized: f32); update stores it (model.sidebar_split = fraction). The delivered fraction is the value the runtime already applied and clamped, so echoing it never fights the reconcile.
min-width on the panes bounds the drag — the divider clamps so neither pane shrinks below its floor, on drag, keyboard, and layout alike.
- Uncontrolled works too: without
on-resize, the divider position survives rebuilds under the source-wins reconcile (a source-side value change wins), but pane CONTENT lays out at the declared fraction until the model echoes — bind the handler for the exact controlled loop.
- Keyboard: Tab reaches the divider; Left/Right step the fraction (Shift for 2x), Home/End jump to the clamp edges. Automation drives it with
widget-drag/widget-key, and snapshots show the divider as role=separator with the fraction as its value.
- Animated resize:
resize-duration="180" (milliseconds, split only — a teaching error elsewhere) makes the bound value a target — a model-driven move (a collapse toggle, not a drag echo) eases the rendered fraction there one presented frame at a time instead of snapping, dispatching the same on-resize echoes a drag would; resize-easing (linear/standard/emphasized/spring) shapes the ramp and needs the nonzero duration beside it (alone it is a teaching error), and reduced-motion appearances snap automatically — apps declare nothing extra.
- Three panes = nested splits, as above. More than two children is a validation error (put conditional content inside a pane).
Trees: disclosure rows with the ARIA tree keymap
tree turns a rail of pressable rows into a keyboard-navigable disclosure tree. Rows are ROLE-driven: any pressable element carrying role="treeitem" — at any nesting depth under the tree — joins one roving focus set:
<tree gap="2" label="Folders">
<for each="folderRows" key="id" as="f">
<panel role="treeitem" expanded="{f.expanded}" on-press="select_folder:{f.id}" on-toggle="toggle_folder:{f.id}" label="{f.label}">
<row gap="8" cross="center"><icon name="folder"/><text grow="1">{f.name}</text></row>
</panel>
</for>
</tree>
- Up/Down walk the visible rows in tree order, across nesting levels. Selection follows focus: each move dispatches the landed row's
on-press, so the model owns the selection exactly like a click.
- Left/Right are disclosure keys: Left on an expanded row dispatches its
on-toggle (collapse); on a collapsed row or leaf it moves focus to the PARENT row. Right on a collapsed row dispatches on-toggle (expand); on an expanded row it moves to the first child row.
- Home/End jump to the scope's first/last row; Enter/Space activate (
on-press).
- Expansion is model-owned: expandable rows bind
expanded (omit it on leaves) and the model renders child rows only while expanded — collapsed subtrees are simply not in the tree, so "visible rows" needs no engine bookkeeping. Flat rails (the notes folder list) are honest trees of leaves: Up/Down/Home/End/Enter work, Left/Right are inert.
- Single-select: selecting a row clears the previous selection across the WHOLE tree scope (rows nest, so this is not per-parent).
Press-and-hold: on-hold
on-hold is the click-acts, hold-reveals menu-button shape — a control that acts on click and offers more on hold: a pointer held ~350 ms dispatches the hold Msg (the release then presses nothing), a quick click dispatches on-press as usual, and a right/ctrl-click whose route offers no context menu dispatches the hold Msg immediately (a declared <context-menu> always wins the right-click — hold is the primary-button gesture, not the context-menu channel). Like on-press, binding it makes any element pressable. The breadcrumb-switcher pattern: on-press selects the crumb, on-hold opens an anchored dropdown-menu of its siblings — an app-designed hold-reveal surface, distinct from the row's right-click menu. Both legs are live-drivable: native automate widget-hold <view> <id> runs the pointer+timer gesture, widget-context-press <view> <id> the secondary click.
<button on-press="select_crumb:{c.id}" on-hold="open_crumb_menu:{c.id}">{c.name}</button>
Select, then act: double press and row-level Enter
The desktop list convention — click selects, the primary action (open the record, play the track) rides the double click and Enter — is two bindings on the same row. on-double-press in markup (ElementOptions.on_double_press in Zig) dispatches on the release whose runtime-derived click count reached 2, in place of a second press: the first click still dispatches on-press on its own release, so the pairing is additive, never a delay — no press timer, no swallowed first click. Like on-press, binding it makes the element a hit target; a widget with no double handler treats a double click as two single clicks. The keyboard mirror is row-level Enter: on a list-item, on-submit grows a second home beyond text entry — with a submit handler bound, plain Enter on a keyboard-focused row dispatches it as the row's PRIMARY action while Space keeps the select activation (on-press); rows without one resolve Enter as select, unchanged. Markup pairs the gestures directly (<list-item on-press="select_track:{t.id}" on-double-press="play_track:{t.id}" on-submit="play_track:{t.id}">{t.title}</list-item>), and tests drive the pointer half through msgForPointerClick and the keyboard half through msgForKeyboard. examples/soundboard's Zig track rows remain the live builder reference: on_press select, on_double_press play, on_submit play.
Widget budgets and virtualization
Every view has fixed per-view capacities (src/runtime/canvas_limits.zig): 1024 retained widget nodes (max_canvas_widget_nodes_per_view — the budget that matters for tree design; semantics and spans match it), 64 KiB retained widget text, 512 declared context-menu items summed across all widgets of the view (max_canvas_widget_context_menu_items_per_view — separators count as items), 64 chart series / 16384 chart points summed across all charts of the view (max_canvas_widget_chart_* — ui.chart downsamples every series to 256 points, so this is 64 maximal series or hundreds of sparklines), and per-frame content budgets (2048 commands, 8192 glyphs, 32 KiB frame text, 2048 path elements shared by icons and charts). Overflow is loud: error.WidgetLayoutListFull / error.WidgetNodeLimitReached / error.WidgetContextMenuLimitReached / error.WidgetAnchoredSurfaceLimitReached (at most 16 anchored floating surfaces mounted per view — max_canvas_widget_anchored_per_view) fail tests under the harness's propagate policy and log a teaching diagnostic naming the budget in production (the app degrades to the previous frame). Watch headroom without overflowing: automation snapshots report widget_nodes=N/1024 widget_semantics=N/1024 context_menu_items=N/512 on every gpu_surface view line.
Budget rules of thumb: 1024 nodes is roomy for a three-pane desktop app (~500 nodes measured for a dense sidebar + markdown detail + run surface), but node count scales with what is MOUNTED, not what is visible — so bound every unbounded collection:
- Dataset-scale uniform rows (feeds, logs, tables of thousands+): use the WINDOWED virtual list (
ui.virtualWindow + ui.virtualList, Zig views — see the next section). The view builds only the visible window; the runtime owns the scroll; budgets stay viewport-sized at 100k items.
virtualized on scroll/list/grid/table (with virtual-item-extent for fixed-extent items) lays out only the visible window + overscan; a 10,000-item list materializes ~viewport/extent nodes. It bounds NODES, not your source data: the builder still walks every item, so it suits row sets the model already holds (hundreds). Legacy virtualized containers without a declared item count are app-driven for scrolling (wheel offsets do not mutate them).
- For non-uniform content (chat transcripts, ledgers, diffs), keep a bounded window in the model and slide it with
on-scroll (see Messages) or explicit paging — the window follows the scrollbar instead of mounting everything.
- Remember multi-node rows multiply: a 4-node row × 50 mounted rows is 200 nodes before chrome.
Windowed virtual lists (Zig views): 100k rows, viewport-sized budgets
The honest infinite-scroll primitive. The RUNTIME owns the viewport math (retained scroll offset + viewport → visible index range, from a fixed per-item extent), the MODEL owns the data, and the view is the seam between them: ask ui.virtualWindow for the visible range, build ONE keyed node per item in it, hand both to ui.virtualList. The list is a runtime-scrolled scroll region — engine wheel/kinetic/keyboard everywhere, the native scroll driver on macOS — whose scrollbar spans the FULL virtual extent (item_count × stride), and every scroll observation re-derives the view so the window follows the offset with no app wiring.
const options = Ui.VirtualListOptions{
.id = "timeline", // stable identity: global key + scroll-state lookup
.item_count = model.loaded, // TOTAL items the model holds right now
.item_extent = 84, // fixed row height (v1 contract: uniform rows)
.overscan = 4,
.grow = 1,
.on_reach_end = .load_more, // infinite fetch: update appends the next batch
};
const window = ui.virtualWindow(options);
const rows = ui.arena.alloc(Ui.Node, window.itemCount()) catch { ui.failed = true; return ui.column(.{}, .{}); };
for (rows, 0..) |*row, offset| {
const index = window.start_index + offset;
var node = rowView(ui, model, index);
node.key = .{ .int = @intCast(index) }; // identity = the ITEM, not the slot
row.* = node;
}
return ui.virtualList(options, window, .{rows});
Rules:
- Key every row by item identity (index or id). A row that scrolls away and back returns under the same structural id, so engine-owned row state and model-owned per-item state (selection washes, like counts keyed by index in the model) survive window shifts.
- No
on_scroll needed. The runtime re-derives the view on scroll for mounted virtual lists; bind on_scroll only when the model wants to observe the position. Do not echo an offset into value — virtualList mirrors the runtime offset itself.
on_reach_end has hysteresis built in: fires once when a scroll comes within one viewport of the end, re-arms past 1.5 viewports — which appending a batch causes on its own by growing the extent. One Msg per approach, never a fetch storm. It works on ANY scroll container (on-reach-end on scroll in markup). on_reach_start is the exact mirror for the content START (load older history; Zig views only).
- Uniform rows are the fast path: a non-zero
item_extent makes 100k rows pure arithmetic. Give such rows single-line text (wrap = false) or fixed sub-layouts that fit the extent.
- Variable-extent rows (chat transcripts, mixed-height feeds, markdown-bearing lists): set
extent_estimate (a cheap pure fn: fn (context, logical_index) f32, derived from model facts like line/byte counts — NEVER from layout) and leave item_extent 0. Rows lay out at their intrinsic wrapped heights; the engine measures mounted rows and corrects an internal offset table, so the scrollbar geometry CONVERGES to truth as the user scrolls. Corrections are anchored on the first visible row — the scrollbar may drift as estimates correct (the honest behavior), but visible content NEVER jumps. Rough estimates are fine; wildly wrong ones just mean more scrollbar drift.
- Tail anchoring (the chat contract):
anchor = .trailing opens the list at the bottom and keeps it pinned there while the user sits at the bottom (appends never yank a scrolled-away viewport). Works for uniform rows too (item_extent doubles as the estimate).
- Prepending history: give items LOGICAL indices via
index_base (e.g. the first loaded message's sequence number) and key rows by index_base + physical. To load older history, decrease index_base by the prepended count in update — row identity, measured extents, and the viewport anchor all survive, and the offset grows by the prepended extent so the user keeps reading the same rows. re-arms from that growth exactly like re-arms from an append.
The examples/feed app is the reference: a 100,000-post deterministic MIXED-HEIGHT corpus (one-liners to long-form walls, estimate from body length), reach-end batching, per-post state by index, a zero-jump scroll-storm test, and snapshot telemetry (widget_nodes=) proving the window stays viewport-sized.
Style token attributes
Color and radius come from the design tokens, referenced by token NAME — literals only, no bindings, no raw colors (dynamic styling stays in Zig via ElementOptions.style):
- Color attributes:
background, foreground, accent, accent-foreground, border-color, focus-ring. Values are canvas.ColorTokens field names — the complete list: background, surface, surface_subtle, surface_pressed, text, text_muted, syntax_plain, syntax_comment, syntax_keyword, syntax_literal, syntax_function, syntax_property, syntax_constant, border, accent, accent_text, destructive, destructive_text, success, success_text, warning, warning_text, info, info_text, focus_ring, shadow, scrim, disabled. The syntax_* roles are the Geist Code Block palette used automatically by ui.code and Markdown fences in both built-in packs. info is the violet identity hue beside the status trio (merged PR badges, "new" chips). (border-color, not bare border — that name is reserved for a future width shorthand.)
radius — canvas.RadiusTokens field names: sm, md, lg, xl.
<row background="surface" radius="md" padding="8">
<text foreground="text_muted">Muted caption</text>
</row>
References resolve against the app's LIVE tokens on every rebuild (finalizeWithTokens), so a themed app (tokens/tokens_fn) re-resolves them when the theme changes — dark mode flips surface automatically. The DEFAULT theme follows the system appearance: an app that sets neither tokens nor tokens_fn derives the stock tokens from the OS light/dark setting (plus high-contrast and reduce-motion) and re-themes live when the user flips it. Pass explicit tokens for a fixed look, or tokens_fn for model-owned theming (custom palettes usually still follow the system scheme through on_appearance). An explicit style value set in Zig always wins over a token ref on the same field. Unknown token names are validation/compile errors.
One Zig-only style knob rides beside the tokens: ElementOptions.style = .{ .quiet_hover = true } silences a pressable surface's pointer HOVER wash only — press and selection fills, the focus ring, cursor intent, and hit testing stay — for image-forward content tiles (cover art, photo cards) where the pointer rests on content rather than a control register. Acting controls (list rows, menu items, buttons, tab triggers) keep their washes: there the hover fill IS the affordance.
Expressions — the complete grammar
Attribute values take a literal or exactly ONE {expression}; text content interpolates any number ({open_count} open · {done_count} done). An expression is PURE and TOTAL — spreadsheet power, never a programming language: no user-defined functions, no effects, no computed message names, guaranteed termination.
- Operands: binding paths (
{c.title}), numbers (3, 0.5 — no exponents), 'strings' (single quotes, no escapes), true/false.
- Arithmetic
+ - * /: numbers only (int op int stays int; any float promotes; / ALWAYS produces a float — wrap in round()/floor()/ceil() for whole-number attributes). Division by zero, integer overflow, and non-finite float results are loud errors, never silent zeros/NaN/inf.
- Comparison
== != < <= > >=: ordering takes numbers only; equality compares any two values (different types are simply NOT equal; int/float compare numerically). Comparisons do not chain (a < b < c is an error — use and). Comparison operands reject arena-computed bindings (compare source fields, or bind a pub fn ... bool).
- Boolean
and / or / not: booleans only — write {count > 0}, not {count}. Both sides always evaluate (pure, so only errors observable).
++ concatenation: joins ANY values as display text, formatted exactly like interpolation ({'$' ++ fixed(price, 2)}).
msg or msg:{path} on on-* attributes stays its own form: tags and payloads are paths, never expressions.
The function library is CLOSED (17 functions; adding one is a toolkit change — anything else is a model fn):
| fn | notes |
|---|
fixed(x, digits) | exact decimals, digits 0-6, half-away rounding (fixed(3.14159, 2) → 3.14) |
thousands(n) | whole number with , separators (1,234,567) |
percent(fraction, digits?) | percent(0.42) → 42%; digits default 0 |
date(ts) / time(ts) / datetime(ts) | unix SECONDS from the model, formatted UTC (2026-07-05, 14:03); formatting model time is pure — now() is a teaching error: reading the clock is an effect, keep a timestamp field updated by update/fx |
upper(s) / lower(s) / trim(s) | ASCII case map / whitespace trim; non-ASCII passes through unchanged |
min(a, b) / max(a, b) / abs(x) | numbers; int stays int |
round(x) / floor(x) / ceil(x) | number → whole number |
plural(count, singular, plural) | count exactly 1 picks singular ({plural(n, 'item', 'items')}) |
pad(x, width) | zero-pads the integer value of x to width digits (pad(7, 2) → 07); a negative sign precedes the zeros and does not count toward width; numbers wider than width print in full — the mm:ss counter fn ({pad(minutes, 2)}:{pad(seconds, 2)}) |
Bounds (taught one past): 256 bytes, 64 terms, 16 nesting levels per expression. Where expressions are allowed: text interpolation, attribute values, if tests, template args at use sites. Path-only by design: message tags/payloads, for each iterables, import paths. Both engines evaluate through ONE shared evaluator — results are bit-for-bit identical, floats included — and native markup check validates syntax, bounds, function names (with did-you-mean), arity, and literal types without needing the model.
Anything stateful or beyond the grammar is a Zig model function you bind to (each="visible", {summaryLine}).
Where the line sits between inline arithmetic and a model fn: inline expression arithmetic is sanctioned for ONE-OFF presentation-level derivation — {percent(done / total)} on the single readout that shows it is exactly what expressions are for. The moment a derivation is REUSED in a second binding, deserves a NAME, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named model function: {completionRate} reads at the binding site, tests in Zig, and changes in one place.
Binding resolution rules
A path like {h.streak} resolves left to right, starting from the model or a for variable:
- everything bindable is declared INSIDE the Model struct: fields, and
pub fn METHODS in the struct body. A file-scope pub fn visibleRows(model: *const Model, ...) written NEXT TO the struct is invisible to bindings and for each — a model-free native markup check still passes (grammar-only), and the view then fails at test/run time with "each does not name an iterable"; with a fresh model contract (refreshed by native test), native check catches it instantly with a did-you-mean. If a binding or each cannot find your fn, first check it lives inside pub const Model = struct { ... }
- struct fields bind directly:
{habit_count}, {h.done}
- zero-arg pub methods bind like fields:
{totalDays} calls pub fn totalDays(m: *const Model) usize
- arena-taking scalar methods bind the same way:
{summary} calls pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8 — format derived display strings straight into the build arena (it lives exactly one view build). Works anywhere a scalar binding does — text interpolation, attribute values, message payloads, expression function arguments ({upper(summary)}) — EXCEPT as a comparison operand (==, <, ...), which rejects arena-computed values with a teaching error: compare the source fields, or bind a pub fn ... bool
- enums resolve to their tag name — so
{f} renders "active", {f == filter} compares tags, and set_filter:{f} coerces the tag back into an enum payload
for each="name" resolves, in order: a Model field that is a slice/array, a pub array/slice decl (pub const filters = [_]Filter{...}), a pub fn (*const Model) []const T, or a pub fn (*const Model, std.mem.Allocator) []const T — the allocator variant is how filtered/derived lists work (allocate from the passed arena)
- item methods work too:
{h.name} may be a field or pub fn name(h: *const Habit) []const u8
Bindings are zero-argument. A parameterized query (cards of column X) becomes one model function per case.
Derive, don't store
The model stores source-of-truth state ONLY: the raw items, the current filter, the draft text. Anything the view shows that is computable from those — counts, sums, filtered views, formatted strings — is a pub method the markup binds to, never a model field. A cached derivable must be re-maintained in every update arm and goes stale the moment one is missed; a derived method cannot.
// WRONG: derived state cached in the model, maintained by hand in update()
visible_count: usize,
summary_storage: [64]u8, // preformatted display string
// RIGHT: the model keeps integers + the filter; methods derive per rebuild
pub fn visibleCount(model: *const Model) usize { ... }
pub fn visibleCents(model: *const Model) u64 { ... }
Derived numbers need no allocation: bind the methods and let text interpolation compose the line — this is exactly how the examples' status bars work (examples/habits):
<status-bar>{habit_count} habits · {totalDays} total days</status-bar>
Computed strings (money, dates, percentages) are formatted into the BUILD ARENA inside the for each allocator fn — derive display rows whose string fields are allocPrinted there. The arena lives for exactly one view build, so nothing is stored and nothing goes stale. Store amounts as integer cents; format at view time:
pub const VisibleExpense = struct { id: u32, date: []const u8, amount: []const u8 };
// A METHOD — declared inside `pub const Model = struct { ... }`. Bindings and
// `for each` resolve Model decls only; the same fn at file scope is invisible.
pub fn visible(model: *const Model, arena: std.mem.Allocator) []const VisibleExpense {
const out = arena.alloc(VisibleExpense, model.expense_count) catch return &.{};
var count: usize = 0;
for (model.expenses[0..model.expense_count]) |*e| {
if (!model.matches(e.*)) continue;
out[count] = .{
.id = e.id,
.date = e.date(),
.amount = std.fmt.allocPrint(arena, "${d}.{d:0>2}", .{ e.amount_cents / 100, e.amount_cents % 100 }) catch "",
};
count += 1;
}
return out[0..count];
}
A one-off formatted line that plain interpolation can't express (e.g. a currency total in a summary) is an arena-taking scalar fn bound directly:
pub fn summary(model: *const Model, arena: std.mem.Allocator) []const u8 {
return std.fmt.allocPrint(arena, "{d} expenses · {s} total", .{
model.visibleCount(), formatCents(arena, model.visibleCents()),
}) catch "";
}
<status-bar>{summary}</status-bar>
(The old workaround — wrapping the string in a one-element slice and iterating it with <for each="summary" as="s"> — is no longer needed; bind the fn directly. Item methods take the arena too: {e.amount} may call pub fn amount(e: *const Expense, arena: std.mem.Allocator) []const u8.)
For <if test>, prefer an explicit boolean predicate method over numeric truthiness: test="{hasHabits}" with pub fn hasHabits(m: *const Model) bool states the condition; test="{habit_count}" works (non-zero is truthy, non-empty strings too) but hides it.
Messages
on-press, on-double-press, on-toggle, on-change, on-submit (enter in a text field; primary+enter in a textarea, where enter inserts a newline), on-dismiss (dismissible surfaces: dialog, drawer, sheet, dropdown-menu — dispatched when Escape or a click outside dismisses the surface, so the model owns the close), on-hold (press-and-hold, see the Pickers section), and on-hover-enter/on-hover-leave (the pointer-hover containment pair, below) take tag or tag:{payload}. The tag must be a variant of your Msg union; payload bindings coerce to the variant's payload type: integers, floats, enums (from tag names), []const u8, bool. on-input is special: name a Msg variant whose payload is canvas.TextInputEvent and the runtime delivers each text edit in it. on-scroll (the scroll element only) is the same shape: name a Msg variant whose payload is canvas.ScrollState and the runtime delivers the post-scroll state — offset, viewport_extent, content_extent, maxOffset() — after every user scroll (wheel, kinetic momentum steps, keyboard, accessibility). In Zig views the constructors are Ui.inputMsg(.tag) / Ui.scrollMsg(.tag) on on_input / on_scroll. on-reach-end (the scroll element only; on_reach_end in Zig views, any scroll container including the windowed virtual list) is a plain Msg dispatched when a user scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (re-arms past 1.5 viewports, which appending a batch causes by growing the extent). A programmatic jump to the end fires once and NEVER re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it.
Scroll offsets follow the same mirror discipline as text: the Msg carries the offset the runtime ALREADY applied, so store it in the model and echo it back through the scroll's value — the echoed source value equals the runtime offset, which the scroll reconcile rule treats as "unchanged", so rebuilds never stomp live scrolling. on-scroll is how long content pages or lazy-loads: keep a bounded window in the model and slide it from offset (near-end when offset + viewport_extent approaches content_extent).
on-hover-enter and on-hover-leave (any element; ElementOptions.on_hover_enter / on_hover_leave in Zig views) are the pointer-hover containment pair — Elm's onMouseEnter/onMouseLeave: enter dispatches once when the pointer enters the element's hit region, leave once when it exits — discrete edges, never per-move, so hover previews, prefetch, and hover cards are ordinary Msgs. Binding either makes the element hover-hittable the way a bound press makes it pressable — but never pressable: clicks fall through, no accessibility action, and NO hover wash (the wash is the visual channel of acting controls; a quiet_hover content tile that binds hover stays visually quiet while the model hears it). Nested bound elements track containment independently (entering a bound row inside a bound card never leaves the card); enters fire outermost-first, leaves innermost-first. Every enter is answered by exactly one eventual leave — the leave Msg is captured while the element stands (kept fresh across rebuilds, retained if unbound), so it still arrives when the exit is the element unmounting. Exits resolve exactly like the hover wash: moving off and the pointer leaving the window are direct edges; content scrolling or reflowing out from under a stationary pointer re-hit-tests the last pointer position; a dismissal removing the surface under the pointer delivers that surface's leaves immediately, and whatever it reveals is entered when the model's close rebuild re-hit-tests (pair dismissible surfaces with on-dismiss, as always); overlays occlude hover the way they occlude clicks. Mouse/trackpad only — containment advances on hover-phase motion, a pointer floating without contact that touch physically cannot produce, so touch never synthesizes hover and hover-revealed affordances need a second path.
A handler or update error DEGRADES, it does not exit the app: dispatch catches it, records it in a bounded ring (runtime.dispatchErrors(), the error event=... name=... lines and dispatch_errors= count in automation snapshots, and a dispatch.error trace record at error level), and the app keeps running. Trace-sink capacity failures likewise never fail dispatch — dropped records are counted (dropped_trace_records=), not fatal. Design for it: an arm that can fail should still surface its own status in the model; the error ring is the safety net, not the UX.
Presses follow ONE rule: a click lands on the nearest pressable widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest pressable ancestor, and dragging still selects text. "Pressable" means an interactive kind (button, checkbox, list-item, ...) or ANY element with a bound on-press/on-double-press/on-toggle — the handler itself makes the element a hit target, so a pressable row is just <panel on-press="open:{id}"> (or <row on-press=...>, ui.row(.{ .on_press = ... })) with plain text children: no empty-text overlays, no duplicating the handler onto every text leaf. Nested pressables resolve to the deepest one (a button inside a pressable row wins over the row); editable text fields, scroll containers, and modal surfaces (dialog, drawer, sheet, popover, menus) always claim their own presses, so a click in a field inside a pressable row places the caret instead of activating the row. The value/text handlers (on-change, on-submit, on-input) still only belong on controls — both engines and markup check reject them on layout/decoration elements with a teaching error.
Keys: quiet list rows and the app-level fallback
Keyboard focus has two registers. RING focus is the keyboard contract: Tab/Shift+Tab walk the focusables and draw the visible ring, and a ring-focused widget owns its keys in full — activation, group arrows, Home/End. QUIET focus is bookkeeping: a pointer press records which widget the user last touched, with no ring drawn (editable text kinds are the exception — a caret is a visible promise, so they show it however focus arrives). A key event resolves top to bottom, the focused widget always outranking the app: (1) the focused widget's bound handler; (2) structural consume — any key on an editable text kind (typing stays typing, checked by widget KIND, so a focused search field blocks app shortcuts without knowing they exist) and any key the widget's kind maps as a control intent; (3) only an unclaimed key_down reaches Options.on_key, the app-level fallback (a target-less event — nothing focused — skips straight there). The fallback is a plain function from the key event to an optional Msg:
// options: .on_key = onKey,
pub fn onKey(keyboard: canvas.WidgetKeyboardEvent) ?Msg {
if (keyboard.modifiers.hasNavigationModifier() or keyboard.modifiers.shift) return null;
if (std.ascii.eqlIgnoreCase(keyboard.key, "space")) return .toggle_play;
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) return .select_next;
if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) return .select_previous;
return null;
}
One rule bridges the registers: quiet focus on a plain list-item routes NO keys — arrows, Enter, and Space on a row the user merely clicked all fall through to the fallback exactly as if nothing were focused. The why is selection-follows-intent apps: in a music library the selection is MODEL state — a click selects a row, and the arrows should keep moving that app-owned selection through the fallback (select_next/select_previous above), not silently start walking engine focus from whichever row was clicked last. Tab restores the full row contract (arrows walk the rows, Space selects, Enter submits — see "Select, then act"), rows carrying role="treeitem" keep the tree keymap under either register, and every other widget kind keeps its keys under quiet focus (a quietly focused button still presses on Space). The fallback fn is directly unit-testable (feed it key events, assert the Msgs), and examples/soundboard pins the whole pattern: Space toggles the transport from anywhere, a tabbed-to row takes Space for select and Enter for play, and the search field takes every key as typing.
Text fields: the elm-style mirror pattern
The model applies every edit event and is the source of truth; the runtime keeps caret/selection while your source text matches, and a source-side change (like clearing on submit) wins:
<text-field text="{draft}" placeholder="New task…" on-input="draft_edit" on-submit="add" grow="1" />
draft_buffer: canvas.TextBuffer(64) = .{}, // model field: text + selection + composition
pub fn draft(model: *const Model) []const u8 { // the fn the markup's text= binds
return model.draft_buffer.text();
}
.draft_edit => |edit| model.draft_buffer.apply(edit), // mirror every edit
.add => { model.addTask(model.draft_buffer.text()); model.draft_buffer.clear(); }, // clearing the source clears the field
The markup binds the FN (text="{draft}"), never the buffer: binding a TextBuffer model FIELD directly is a teaching error pointing at this pattern — the buffer holds editor state, and the binding wants the text it derives. See examples/ui-inbox for the complete pattern.
Clipboard and selection (free — do not reimplement)
The runtime owns cmd/ctrl+C/X/V in editable text: copy writes the current selection to the system clipboard, cut copies then delivers the removal to your on-input handler as an insert_text "" edit, and paste arrives as an ordinary insert_text edit — the TEA mirror above stays consistent with zero extra code. Paste is clamped to the view's text capacity: when bytes were dropped, the keyboard event carries edit_truncated = true and your TextBuffer mirror sets its own truncated flag (check it if lost paste bytes matter to your UX; TextBuffer clamps oversized insertions at a UTF-8 boundary rather than dropping the edit). Shift+arrows/home/end extend the selection from the keyboard.
Static text is selectable too: click-drag inside one text leaf or paragraph (markdown bodies included) selects with a highlight, cmd/ctrl+C copies it, and pressing anywhere else clears it. Selection and pressing coexist inside pressable rows — dragging selects (and presses nothing), a plain click collapses the selection and lands on the row's on-press. Selection is per-widget by design — there is no document model ordering text across widgets, so a drag cannot span two paragraphs (copy per paragraph). The selection survives rebuilds while that widget's text bytes are unchanged, and shows up in semantics/automation snapshots as selection=a..b on the widget line. Clipboard access from update is fx.writeClipboard / fx.readClipboard on the effects channel (see Effects) — never a pbcopy spawn; runtime.readClipboard(&buffer) / runtime.writeClipboard(text) remain for code that holds the runtime.
Effects: subprocesses and HTTP from update
update can take a third parameter — the effects channel — by declaring .update_fx instead of .update (existing two-argument apps are untouched; set exactly one):
const App = native_sdk.UiApp(Model, Msg);
const Effects = App.Effects;
pub fn update(model: *Model, msg: Msg, fx: *Effects) void { ... }
// options: .update_fx = update,
Boot-time effects — fetching the data the app opens with — go in .init_fx, TEA's init command. It runs exactly once, on the installing frame, before the first view build, so a loading flag set there is in the very first paint; results arrive as ordinary Msgs. This is THE way to boot-fetch — never a guarded on_frame (on_frame is the per-frame hook for renderer diagnostics and presented-frame reactions, and unguarded spawning from it refires forever):
fn boot(model: *Model, fx: *Effects) void {
model.loading = true;
fx.spawn(.{
.key = issues_key,
.argv = &.{ "gh", "issue", "list", "--json", "number,title" },
.output = .collect, // whole JSON on the exit Msg
.on_exit = Effects.exitMsg(.issues_loaded),
});
}
// options: .init_fx = boot, (works with either update form)
fx.spawn runs a subprocess on a runtime-owned worker thread and streams each stdout line back as a typed Msg; the exit arrives as one more Msg. Keys are caller-chosen u64s you keep in the model — no handles:
pub const Msg = union(enum) {
start,
cancel,
line: native_sdk.EffectLine, // payload types are fixed
exited: native_sdk.EffectExit,
};
.start => fx.spawn(.{
.key = stream_key, // model-stored identity
.argv = &.{ "gh", "issue", "list" },
.stdin = null, // optional, written once
.on_line = Effects.lineMsg(.line), // comptime constructors,
.on_exit = Effects.exitMsg(.exited), // like ui.inputMsg(.tag)
}),
.cancel => fx.cancel(stream_key),
.line => |line| model.recordLine(line), // COPY line.line — it is
// drain scratch, dead after
// this update call
.exited => |exit| model.finish(exit), // exit.reason, exit.code
Rules that keep this honest:
- Effects are update-side ONLY. The view never spawns anything — a button dispatches a Msg, and that Msg's update arm spawns. Markup stays declarative.
- One
on_exit Msg per spawn, always. A spawn that cannot run (all max_effects = 16 slots busy, duplicate active key, argv over capacity) still delivers it, with reason .rejected. Reasons: exited (code is real), signaled, cancelled, rejected, spawn_failed.
- After
fx.cancel(key) returns, no further on_line Msgs for that spawn arrive; exactly one .cancelled exit follows. The process is killed and reaped — no zombies. Streaming a chat agent's stdout for minutes and cancelling mid-stream is the designed-for case.
- Overflow is never silent: a full completion queue drops lines but the next delivered line's
dropped_before and the exit's dropped_lines carry the count; over-long lines arrive truncated with truncated = true. Capacities: 16 in-flight effects, 4 KiB per line by default, 64 queued completions.