Compare commits

...

28 Commits

Author SHA1 Message Date
Chris Tate 79384ca47d Say which hide warms in the token and state doc comments
- The two source doc comments still claimed ANY tooltip hide opens the warm window; only a pointer-hovered tooltip hiding on pointer leave warms, matching the implementation and user docs.
2026-07-16 21:48:11 -05:00
Chris Tate 6a3b9afe12 Spend the standing focus reveal on explicit tooltip dismissal
- Keyboard activation and Escape preserved keyboard provenance, so the adoption binding-reconcile treated the ring as standing intent: an activation whose own model rebuild replaced or rekeyed the tooltip resurrected it one frame after dismissal, contradicting "stays down while focus rests on the trigger".
- Both dismissal seams now consume canvas_widget_focus_visible_keyboard when the ring rests on the dismissed tooltip's owner — safe because its only readers are the two tooltip reveal gates, while the focus ring renders from canvas_widget_focus_visible_id and stays painted; Tab away and back re-grants the contract at the one provenance write, and pointer hover re-earns its dwell untouched.
2026-07-16 21:31:03 -05:00
Chris Tate 1e05adf242 Observe window key-loss on the focused flag's own edge
- Windows and GTK announce key changes loss-first: the state echo wrote focused=false directly, so the later gain's dethroning loop saw the old window already unfocused and the tooltip key-loss reset never fired — the tooltip stayed painted and a11y-visible in the inactive window.
- setWindowFocused is now the one writer of a tracked window's focused flag (setFocusedIndex, applyNativeInfo, and updateWindowState all route through it); the reset fires on the flag's true→false edge regardless of event ordering, including a loss with no subsequent gain.
- closeWindow's transactional flip stays outside the seam deliberately: its views are removed with the window on success, and the rollback on platform failure must not have fired a reset.
2026-07-16 21:22:06 -05:00
Chris Tate 4017669f95 Say precisely which tooltip hide opens the warm window
- The runtime warms only when a pointer-hovered tooltip hides on leave — focus departure, Escape, press, blur, and prune are each a deliberate no-warm decision in canvas_widget_events.zig — but the docs claimed "any tooltip hides", contradicting the tooltip page's own next paragraph.
- Corrected the tooltip page, the Native UI guide table, the changelog fragment, the native-ui skill card, and the markup doc strings; regenerated component-vocab.json from them (webp previews untouched).
2026-07-16 19:36:05 -05:00
Chris Tate 8464fc9f1a Scope anchored-surface lookups so a tooltip never shadows the menu
- A stack can now anchor several surfaces at once (dropdown-menu + tooltip), but the menu lookups still grabbed "the anchored child" and kind-checked the winner: a focus-visible tooltip mounted after the menu made ArrowUp/Down unable to walk into the open menu and Tab departure unable to close it.
- Every consumer of the anchored-child walk now names its population: Escape and automation dismiss keep .any (topmost-first, one surface per gesture), outside-click dismissal and Tab focus scoping take .interactive (tooltips are hover chrome the intent machine owns), and the open-select keymap plus Tab departure take .menu — the scope filters during the scan, never after selection.
- Coexistence tests pin the keymap against a select + open dropdown-menu + focus-shown tooltip stack: arrows enter the menu beneath the tooltip, Tab departure dismisses the menu and leaves the tooltip alone, and Escape peels one surface per press, topmost (tooltip) first.
2026-07-16 19:35:53 -05:00
Chris Tate 6dfb4a682f Teach the Native UI guide the anchored tooltip's hover intent
- The guide predated anchored tooltips: it scoped the anchored-floating family to dropdown-menu alone and described tooltips as static leaves, contradicting the component page and skill.
- The element table row and the layout-attributes paragraph now carry the runtime-owned hover-intent summary: 600ms dwell, 400ms warm window, immediate focus-visible reveal, and tooltip-delay (0 = instant, a teaching error without anchor).
2026-07-16 18:49:04 -05:00
Chris Tate 04ccd28c50 Gate tooltip reveals and arms on app-active and window-key state
- A rebuild from the deactivation callback (or any adoption/hover in a non-key window) could reveal or arm a tooltip the blur had just cleared; every reveal/arm path now checks the new app_active register plus the owning window's key state, while the focus provenance and stored pointer stay preserved for the next honest transition.
- The frame promote disarms rather than defers under suppression, so re-key and re-activation can never spontaneously reveal.
- Sabotage-verified tests: deactivation-callback rebuild, non-key mount beneath a hovered trigger, and the reworked two-window key-loss fixture.
2026-07-16 18:45:53 -05:00
Chris Tate 641827e4e3 Arm and reveal tooltips whose binding changed beneath a stable owner
- A rebuild that mounts, replaces, or rekeys a tooltip under a hovered trigger whose own ID survived produced no hover delta and no stale register, so the new tooltip could never arm until leave/re-enter; setCanvasWidgetLayout now snapshots the outgoing tree's owned-tooltip bindings next to the prune verdict and the layout_adoption arm compares them against the adopted tree, arming a fresh dwell for the hovered owner (never an insta-show; warmth only when genuinely live) and revealing immediately for the standing keyboard focus-visible owner.
- New canvas_widget_focus_visible_keyboard register records whether the ring came from the keyboard contract, so pointer/programmatic/automation rings and the focus-return seams keep the click-focus guard at adoption time too.
- Six floating tests pin the matrix: mount-mid-hover dwell, rekey-under-shown re-earn, focus-visible immediate reveal (pointer-blind path included), caret-ring provenance gate, unmount disarm, and the unchanged-binding rebuild staying inert.
2026-07-16 18:10:07 -05:00
Chris Tate 1c5f8cd837 Drop tooltips with the whole window on app deactivation and key-loss
- App deactivation and window key-loss now feed the tooltip choke point with the existing view_blur cause for every affected canvas view: setFocusedIndex becomes the one window-key seam (window_focused, frame-change echoes, focusWindow, and native adoption all land there), so a focused→unfocused transition drops that window's tooltip conversations without touching per-window focus memory.
- The stored pointer position deliberately survives the lifecycle blur: pointer truth belongs to the pointer channel, whose own cancel (macOS mouseExited on key-window-scoped tracking) clears it on hosts that stop hover delivery.
- Three tests pin the contract: deactivation hides focus-shown and pointer-shown tooltips (warm dies, armed disarms, no stale a11y visibility), key-loss blurs only the losing window's views, and reactivation/re-key reveals nothing because both reveal paths are transition-edge-triggered.
2026-07-16 17:21:59 -05:00
Chris Tate 629fac6261 Make the rebuild's tooltip prune transactional against adoption failure
- The pre-diff visibility stamp now reads a pure prune VERDICT (canvasTooltipShownIdSurvivingLayout) instead of mutating the live registers; the mutation lands in copyWidgetLayoutTree's own prune, after the fallible diff and retained-pool validation/copy succeed
- A failed adoption previously left the OLD tree stamped visible with cleared registers: an unhideable tooltip no transition could reach
- Regression test forces the failure through the anchored-surface budget and asserts the shown tooltip stays register-owned and hideable
2026-07-16 16:36:47 -05:00
Chris Tate f375900aa8 Reconcile tooltip intent through one cause-fed choke point
- reconcileCanvasTooltipIntent owns position bookkeeping, hover re-hit-testing, containment, transitions, and the deadline-derived frame-pump kick; every entry point (pointer, consumed streams, scroll, adoption, focus, blur, cancel) is now a thin caller naming its cause
- Closes four input-path holes: transit graces armed by 0-to-0 hovers now pump the idle frame clock, scrolls re-check the content hold even when the hover id is unchanged, a released hold reprocesses the trigger already under the pointer, and consumed secondary/window-drag streams keep the stored pointer position truthful
- Five sabotage-verified regression tests pin the closed holes
2026-07-16 16:25:38 -05:00
Chris Tate c6581da6a2 Re-hit-test the stationary pointer against every adopted layout
- the adoption prune validates only tooltip/owner identity and hover survives by ID, so a rebuild that MOVED the same-ID trigger away from the stationary pointer left armed intent able to fire and a shown tooltip visible until the next pointer event
- setCanvasWidgetLayout now re-hit-tests canvas_last_pointer_position after the pose restores settle, reusing the point-blind scroll reconcile: armed disarms, shown hides with the usual warmth, a trigger arriving under the pointer arms, and the content hold is re-checked against the tooltip's NEW frame (no transit corridor - the content moved, not the pointer)
- no trustworthy position closes pointer intent exactly like a blind scroll; focus-shown tooltips survive, and a rekeyed trigger under the pointer earns a FRESH dwell instead of inheriting the old widget's show
2026-07-16 14:43:37 -05:00
Chris Tate 9c78b01388 Short-circuit pointer cancel ahead of the tooltip hover-transition gate
- a pointer-shown tooltip held open by its own hovered content reads hovered_id == 0, so cancel-to-0 was no transition and the tooltip stranded visible after the pointer left the view
- .cancel now closes the whole pointer-owned conversation (armed, content-held shown, warm window, corridor state) through the same close the point-blind scroll staleness arm uses; no warmth survives a pointer we cannot place
- the focus-shown tooltip survives a pointer cancel - the keyboard holds it - unlike view blur, where the keyboard itself leaves (commented at the seam)
2026-07-16 14:36:21 -05:00
Chris Tate c01ad1b470 Collapse degenerate corridor triangles to their boundary segments
- an apex exactly on a tooltip or trigger edge's line zeroed all three cross products for every collinear point, so the sign test read the whole infinite line as in-corridor and motion along it re-armed the transit grace forever
- a zero-area (or epsilon-area) fan triangle now contains only the segments between its actual vertices; the epsilon is half a canvas point over the longest edge, scale-honest for slivers of any length and commented at the constant
2026-07-16 14:33:07 -05:00
Chris Tate 4fd2ce69e2 Range-check whole-number attrs against their field type in both engines
- tooltip-delay="2147483648" (or an equally large model binding) trapped in the unchecked @intCast; both engines now teach the grid-lines out-of-range error instead
- the field's own integer type is the honest upper bound - no semantic ms cap is invented, matching resize-duration whose only bound is likewise its u32
- boundary values (0, i32 max) pin as accepted in both engines, literal and binding paths alike
2026-07-16 14:29:15 -05:00
Chris Tate ca1074c50f Normalize runtime tooltip visibility onto the scratch tree before the layout diff
- setCanvasWidgetLayout now prunes tooltip intent against the reconciled tree and stamps runtime-owned anchored-tooltip visibility onto the scratch BEFORE diffing, so an unchanged rebuild carrying a hidden anchored tooltip diffs clean instead of reporting the runtime's own hidden stamp as a spurious visibility invalidation every rebuild.
- A shown tooltip's scratch node is stamped visible (prune-aware), so rebuilds never pass it through a hidden state — no hide-then-show frame pair — while a rebuild that breaks the shown binding still diffs the hide honestly; adoption re-runs prune+stamp as the structural backstop.
2026-07-16 13:41:33 -05:00
Chris Tate 859d0aeedd Re-hit-test point-blind scrolls from the stationary pointer's last position
- Kinetic steps, native drivers, and keyboard scrolling borrow the view's last journaled pointer position (canvas_last_pointer_position, cleared on pointer_cancel — the view-exit event): the pointer did not move, so hover and tooltip ownership follow the post-scroll tree honestly — triggers scrolled off the pointer release, ones scrolled under it arm.
- With no trustworthy position (keyboard-only session, or the pointer left the view) the pointer's tooltip intent closes — armed, shown, warm window — instead of guessing; Base UI closes on scroll, we do strictly better only where the re-hit-test is sound.
- The wheel path keeps its live position; whichever position is used re-seeds the transit-corridor apex.
2026-07-16 13:32:18 -05:00
Chris Tate a9a72789e5 Hide focus-owned tooltips when programmatic focus moves the keyboard
- Autofocus, accessibility focus, and automation focus all funnel through focusAutomationCanvasWidget, which now steps updateCanvasTooltipIntentForProgrammaticFocusMove: a focus-owned tooltip hides (no warm window) and the new target's never reveals, the same focus-visible guard rationale as the click-focus exclusion.
- Pointer-owned tooltips stay untouched by focus moves; re-focusing the tooltip's own trigger is not a move and leaves it alone.
2026-07-16 13:23:02 -05:00
Chris Tate e85fd6d854 Reset tooltip state when a canvas view loses focus
- Both focus seams — per-view focus moves (setFocusedView, input- and command-driven) and window-level focus loss (clearFocusedView) — now drop the blurred view's whole tooltip conversation: armed delay, shown tooltip (keyboard- and pointer-owned), warm window, transit grace, and re-stamp hidden.
- Extends the focus-shown blur-hides contract (shadcn's Base UI-backed default) to the view: a tooltip painted in a view the keyboard left is a stale affordance whose semantics node kept claiming visible.
- Sabotage-verified test pins the keyboard-shown and pointer-shown registers across a sibling-view focus switch, including the semantics tree carrying no stale node.
2026-07-16 12:19:34 -05:00
Chris Tate 390f5e8bb3 Make every pointer-down dismiss tooltips, drag and context downs included
- Secondary-button downs consumed by the context-menu gesture and primary downs consumed by a window-drag region now run the same press reset (armed cancels, shown dismisses, warm closes) before their early exits — the documented pointer-down-dismisses contract (Base UI's close-on-press default; macOS help tags vanish on any click) now holds for all buttons.
- The context menu still presents and the OS drag still starts; sabotage-verified tests pin both paths against a shown tooltip.
2026-07-16 12:14:25 -05:00
Chris Tate 92fcf58567 Route every scroll path's hover change through the tooltip machine
- Wheel, kinetic steps, native scroll drivers, and keyboard scrolling now reconcile hover through one wrapper that steps the tooltip intent transition a pointer move would: scrolled-away triggers disarm/hide (usual warm window), newly-arrived triggers arm per normal.
- The step is point-blind on purpose — the content moved, not the pointer, and Base UI closes tooltips on scroll — but the wheel's live position re-seeds the transit-corridor apex for whatever it armed.
- Sabotage-verified tests pin the wheel transition chain (disarm, re-arm, frame-clock show, warm transfer, hide over dead space) and the point-blind path via End-key scrolling a shown trigger out of the tree.
2026-07-16 12:09:15 -05:00
Chris Tate ef92834ebb Hold anchored tooltips open while the pointer hovers their content
- A shown tooltip's own frame now holds it open, and the anchor gap crosses through a bounded safe-polygon transit corridor (WCAG 1.4.13 hoverable content; Base UI's hoverable default) — the tooltip stays out of hit-testing, so interaction routing and the a11y tree keep treating it as presentation chrome.
- The corridor's grace re-arms on every in-corridor move and resolves on the recorded frame clock (400ms of stillness), so slow deliberate transits never race a timer while parked pointers and replays stay deterministic.
- Motions away from the tooltip keep hiding on the move itself, pinned by the existing sweep/warm-window tests plus two new sabotage-verified travel tests.
2026-07-16 12:00:06 -05:00
Chris Tate defe3194d2 Reveal tooltips on keyboard focus, dismiss on press, validate owners
- Keyboard focus-visible now shows a trigger's anchored tooltip instantly (blur hides, never warms), pointer-down and Space/Enter cancel the armed reveal, dismiss the shown tooltip, and close the warm window, and the rebuild prune validates the owning trigger — a removed, rekeyed, disabled, or re-parented owner resets armed/shown/warm state and re-stamps hidden — all per shadcn's Base UI-backed defaults.
- Retune the timing tokens to Base UI parity: tooltip_show_delay_ms 700 -> 600 and tooltip_warm_window_ms 300 -> 400, with the components page, markup vocab, native-ui skill, and changelog fragment updated to match (and to describe the focus and press behaviors).
- Six new canvas_widget_floating_tests cover focus reveal/blur/Escape, press-on-armed, press-on-shown without instant re-show, keyboard activation, and the three owner-invalidation rebuilds; each fails when its fix is reverted.
2026-07-16 11:26:23 -05:00
Chris Tate e92fec270f Document anchored-tooltip hover intent across docs, vocab, and the UI skill
- Tooltip reference page teaches the anchored stack pattern, the 700ms/300ms hover-intent windows, tooltip-delay=0, and gains the scoped attribute table (tooltip joins the anchor family in the generated vocab)
- native-ui skill enumerates anchor on tooltip and the tooltip-delay attribute; changelog fragment tells the feature story
2026-07-16 11:25:30 -05:00
Chris Tate 4dfcd9dac1 Pin the tooltip hover dwell in record-and-replay
- The markup e2e fixture gains an anchored tooltip (tooltip-delay="200") on the Add trigger through the stack pattern
- A recorded dwell arms, shows on the frame at the deadline, and hides on leave; two recordings are byte-identical and the replay verifies every per-frame fingerprint checkpoint on the journaled clock
2026-07-16 11:25:30 -05:00
Chris Tate 4fd9964a8d Hold the engines in parity on the anchored-tooltip declaration
- Interpreter test: anchor + tooltip-delay stamp the widget declaration, the token-default and static leaves keep -1/null so existing documents lower byte-identically
- Compiled-vs-interpreter parity: identical trees and identical anchor/delay stamps across both engines for declared, defaulted, and static tooltips
2026-07-16 11:25:30 -05:00
Chris Tate 5c85550b70 Land the anchored-tooltip hover-intent state machine in the runtime
- Anchored tooltips become runtime-owned chrome: adoption stamps them hidden, hover on their trigger arms the show delay, leaving disarms, a dwell past the deadline shows on the presented frame's recorded timestamp, and hiding opens the shared warm window that shows the next trigger's tooltip instantly
- Every transition steps on journaled input/frame timestamps (canvasRenderAnimationStartNsForView at pointer dispatch, GpuSurfaceFrameEvent.timestamp_ns at frame advance) and an armed delay rides the render-animation frame pump, so recorded sweeps replay byte-identically
- Escape dismissal clears the intent machine with the surface, and five behavior tests cover sweep-shows-nothing, dwell-shows, leave-disarms, warm transfer/expiry, and tooltip-delay=0
2026-07-16 11:25:30 -05:00
Chris Tate 76082aecb1 Teach the registry the anchored tooltip and its tooltip-delay attribute
- ui_schema: tooltip (39) becomes anchorable; fresh attr code 80 tooltip-delay (.whole, field tooltip_delay) with the pins test re-pinned for the addition
- Options/Widget carry tooltip_delay (ms; -1 follows the new ControlMetricTokens tooltip_show_delay_ms/tooltip_warm_window_ms defaults of 700/300)
- Validator scopes tooltip-delay to tooltip beside anchor with teaching messages, mirrored in the LSP/docs attribute tables and covered in ui_markup_tests

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>
2026-07-16 11:25:30 -05:00
35 changed files with 5500 additions and 140 deletions
+8
View File
@@ -0,0 +1,8 @@
feature: **Anchored tooltips with hover intent**: `<tooltip anchor="above|below">` beside its trigger in a stack floats against the trigger and hands visibility to the runtime — it shows after the pointer has rested on the trigger for the show delay (default 600ms) and hides on leave, so sweeping a toolbar flashes nothing; after a pointer-hovered tooltip hides on leave, a shared 400ms warm window shows the next trigger's tooltip instantly (the other hide causes below — focus moving on, Escape, a press, view blur — deliberately open no warm window). Delay, warm window, and the behaviors below match shadcn/ui's defaults (Base UI). The model never hears hover, both engines lower it identically, and every transition steps on the recorded input/frame clock, so recorded hover-dwell sessions replay their show/hide frames byte-identically.
- **`tooltip-delay` attribute**: per-tooltip show delay in milliseconds (`0` shows the instant the trigger is hovered); absent follows the new `tooltip_show_delay_ms`/`tooltip_warm_window_ms` metric tokens. Registry attr code 80; static (non-anchored) tooltips keep their classic paint-when-rendered behavior.
- **Keyboard focus reveals immediately**: tabbing onto a tooltip-owning trigger shows its tooltip with no dwell (keyboard navigation is deliberate, and content revealed on hover or focus must not depend on pointer timing); focus moving on or Escape hides it just as immediately, without warming the pointer's skip window.
- **Hoverable content**: a shown tooltip's own bounds hold it open (WCAG 1.4.13; Base UI's `hoverable` default) — the pointer can cross the anchor gap into the tooltip along a bounded safe-polygon corridor, and it hides only after leaving both the trigger and the tooltip (with the usual warm window); the corridor resolves on the recorded frame clock, so replays stay byte-identical.
- **Scroll steps the machine**: every scroll path (wheel, kinetic steps, native drivers, keyboard scrolling) routes its hover change through the same intent transition a pointer move takes — a trigger scrolled out from under the pointer disarms/hides, and a trigger scrolled under it arms per normal.
- **Press dismisses**: ANY pointer-down — primary or secondary, including downs consumed by the context-menu gesture or a window-drag region — or Space/Enter on the focused trigger cancels a pending reveal, dismisses a shown tooltip, and closes the warm window, so an activated control never re-explains itself on the post-click hover. Keyboard activation and Escape also spend the standing focus reveal: a rebuild that replaces or rekeys the tooltip (the activation's own model update, typically) cannot resurrect it — it stays down while focus rests on the trigger, until the keyboard genuinely leaves and returns; the focus ring stays painted, and a later pointer hover re-earns the dwell normally.
- **Rebuild hygiene**: a rebuild that removes, rekeys, disables, or re-parents a tooltip's trigger resets that tooltip's armed/shown/warm state and re-stamps it hidden, even when the tooltip node itself survives.
- **View blur resets**: a canvas view that loses focus (to a sibling view or with the window) drops its entire tooltip conversation — armed delay, shown tooltip (keyboard- and pointer-owned), warm window — and re-stamps hidden, so no stale tooltip floats in a view the keyboard left. The window key-loss reset fires on the flag's own focused→unfocused edge, so it holds however the host announces the change: one gain event (macOS) or loss-before-gain (Windows, GTK), including a loss with no gain at all (every window inactive).
+19 -9
View File
@@ -3,30 +3,40 @@ import { AttrTable } from "@/components/attr-table";
# Tooltip
`tooltip` is a text leaf styled as a tooltip: its content (with `{}` interpolation) is the whole element, so showing one is ordinary conditional layout — render it under an `if` driven by the model, positioned above the control it annotates. For the chat-message surface, see [bubble](/components/bubble).
`tooltip` is a text leaf styled as a tooltip: its content (with `{}` interpolation) is the whole element. Setting `anchor="above"` (or `below`) floats it against its parent's frame — the `stack` that wraps the trigger and the tooltip as its sibling, the same shape [dropdown-menu](/components/dropdown-menu) uses — and hands visibility to the runtime: the tooltip shows after the pointer has rested on its trigger for the hover-intent delay, and hides when the pointer leaves. The model never hears hover. For the chat-message surface, see [bubble](/components/bubble).
<ComponentPreview name="tooltip" alt="A tooltip above a hovered button rendered by the engine" caption="the tooltip leaf above the hovered control it annotates" />
## Markup
```html
<column gap="8" cross="center">
<tooltip>Add to library</tooltip>
<button variant="outline" on-press="add_to_library">Hover</button>
</column>
<stack>
<button variant="outline" icon="bold" label="Bold" on-press="toggle_bold" />
<tooltip anchor="above">Bold the selection</tooltip>
</stack>
```
## Hover intent
An anchored tooltip never paints the instant its trigger is hovered: the runtime waits `tooltip-delay` milliseconds (default 600) on the recorded frame clock, so sweeping the pointer across a toolbar flashes nothing. After a pointer-hovered tooltip hides on leave, a shared 400ms warm window shows the **next** trigger's tooltip immediately and re-warms on each such hide — a dense toolbar explains itself one control at a time once the first tooltip has been earned. Only the pointer leaving warms: the other hide causes below (focus departure, Escape, a press, view blur) are deliberate endings, not the pointer moving on, so they open no warm window. `tooltip-delay="0"` opts a tooltip into showing the instant its trigger is hovered, and Escape dismisses a shown tooltip. Both windows are themable (`tooltip_show_delay_ms` / `tooltip_warm_window_ms` in the metric tokens); the defaults and the behaviors below match shadcn/ui's defaults (Base UI).
A shown tooltip's content is hoverable (WCAG 1.4.13; Base UI tooltips default `hoverable`): moving the pointer from the trigger into the tooltip — including across the anchor gap between them — keeps it open, and it hides only when the pointer leaves both the trigger and the tooltip. Scrolling steps the same machine: a trigger that scrolls out from under a stationary pointer hides its tooltip (or cancels its pending reveal), and a trigger the scroll brings under the pointer arms the normal delay.
Keyboard focus is the second reveal path, with no dwell: tabbing onto a tooltip-owning trigger shows its tooltip immediately (keyboard navigation is deliberate, and hover- or focus-revealed content must not depend on pointer timing), and moving focus on — or pressing Escape — hides it just as immediately without warming the pointer's skip window. Pressing dismisses tooltips outright: any pointer-down — primary or secondary, including a down that starts a context menu or a window drag — or Space/Enter on the focused trigger cancels a pending reveal, hides a shown tooltip, and closes the warm window, so an activated control does not re-explain itself on the post-click hover. A view that loses focus — to a sibling view or with the whole window — drops its tooltip state entirely and re-stamps the tooltip hidden.
Without `anchor`, the leaf keeps its classic behavior: a static element that paints whenever the view renders it — conditional layout under an `if` driven by the model.
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.column(.{ .gap = 8, .cross = .center }, .{
ui.el(.tooltip, .{ .text = "Add to library" }, .{}),
ui.button(.{ .variant = .outline, .on_press = .add_to_library }, "Hover"),
ui.el(.stack, .{}, .{
ui.button(.{ .variant = .outline, .icon = "bold", .on_press = .toggle_bold, .semantics = .{ .label = "Bold" } }, ""),
ui.el(.tooltip, .{ .text = "Bold the selection", .anchor = .above }, .{}),
})
```
## Attributes
<AttrTable attrs={["text"]} />
<AttrTable element="tooltip" attrs={["text", "anchor", "anchor-alignment", "anchor-offset", "tooltip-delay"]} />
+2 -2
View File
@@ -52,7 +52,7 @@ The runtime owns the rest: install, presentation, resize, typed event dispatch,
| `resizable` | resizable panel | engine-managed drag handle; `width` sets the initial width |
| `split` | two-pane splitter | exactly two element children (nest splits for more panes); the engine synthesizes the draggable divider between them. `value` binds the model-owned first-pane fraction, `on-resize` names an f32 Msg variant dispatched with each applied fraction (echo it back through `value`), `min-width` on the panes bounds the drag, `gap` sets the divider band thickness; the focused divider takes Left/Right (Shift for bigger steps) and Home/End. `resize-duration` (milliseconds, split only) animates model-driven value moves — the runtime eases the rendered fraction to the new value one presented frame at a time instead of snapping, and reduced-motion appearances snap automatically; `resize-easing` (`linear`/`standard`/`emphasized`/`spring`) shapes the ramp and needs a nonzero duration beside it |
| `tree` | disclosure tree | vertical container whose descendant rows with `role="treeitem"` — at any nesting depth — form one roving keyboard focus set: Up/Down walk visible rows (selection follows focus via each row's `on-press`), Left collapses or moves to the parent row, Right expands or moves to the first child row, Home/End jump to the edges, Enter/Space activate; expandable rows bind `expanded` and `on-toggle`, and the model owns both states |
| `text`, `badge`, `tooltip` | text leaves | `{}` interpolation allowed in content; `text` takes a line policy via `wrap` (`"true"` word-wraps; `"false"`/unset paint one honest line) and an overflow policy via `overflow` (`ellipsis` — the default trailing elision — or `clip`) and the typography rungs via `size="heading"`/`size="display"` (themable token steps above title — section headings, hero stats) |
| `text`, `badge`, `tooltip` | text leaves | `{}` interpolation allowed in content; `text` takes a line policy via `wrap` (`"true"` word-wraps; `"false"`/unset paint one honest line) and an overflow policy via `overflow` (`ellipsis` — the default trailing elision — or `clip`) and the typography rungs via `size="heading"`/`size="display"` (themable token steps above title — section headings, hero stats). `tooltip` with `anchor="above\|below"` floats against its parent — the `stack` wrapping trigger + tooltip, the same shape as the anchored `dropdown-menu` — and the runtime owns its visibility through hover intent: it shows after the pointer rests on the trigger for `tooltip-delay` milliseconds (default 600; `"0"` shows instantly), skips the delay during the shared 400ms warm window after a pointer-hovered tooltip hides on leave, and shows immediately when the trigger gains keyboard focus; it hides on pointer leave (the only hide that warms), focus departure, Escape, or a press — the model never hears hover. Without `anchor` it stays a static leaf that paints whenever the view renders it — see [Tooltip](/components/tooltip) |
| `text` > `span` | inline styled runs | mixed-style text in one wrapped paragraph: span children style runs with `weight="regular\|medium\|bold"`, `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size — inline headings, hero stats), `underline`, `foreground` (a token name); `{bindings}` interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see [Rich text](#rich-text-inline-spans-and-markdown) |
| `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | text-bearing controls | the label is the element content; `button`, `toggle-button`, `list-item`, and `menu-item` also take `icon="..."` — a vector icon drawn inline (buttons and toggle-buttons before the label, icon-only when the content is empty; list and menu items as a leading slot), one hit target that follows the element's enabled/disabled tint; tab strips are `toggle-button` children, so tabs get icons this way; `select` shows its `placeholder` while empty and dispatches `on-press`; `avatar` renders initials, or a runtime image via `image="{binding}"`. `list-item` alone also accepts element children IN PLACE of the text run (the list-row composite): the children flow horizontally inside the row's flat chrome — transparent at rest, full-width hover/selected washes, one hit target — so composite rows (title + snippet, leading icon + trailing badge) stay honest list rows instead of bordered boxes; name the row with `label` and never mix a text run with children. Rows that really are cards (a kanban board's draggable cards) opt into the card treatment explicitly with `background`/`radius` |
| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` |
@@ -90,7 +90,7 @@ A few widget kinds are deliberately **not** markup elements because their shape
Apps with their own iconography can parse any stroke-dialect/Feather/Tabler-dialect SVG at comptime (`canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))`) and register it at boot with `canvas.icons.registerAppIcons(&table)`: the draw paths (icon leaves via `ui.appIcon`, `ElementOptions.icon` on buttons, toggle buttons, icon buttons, and list/menu items) resolve registered names exactly like built-ins. Markup reaches them through the `app:` NAMESPACE (`<icon name="app:waveform"/>`, `icon="app:waveform"`): bare names keep the closed built-in vocabulary (the compiled engine proves them at comptime, where a runtime registration cannot exist), while `app:` names are structurally accepted by both engines and verified by `native check` against the model contract - declare the table as `pub const app_icons` on the app root so the contract emit reflects the same names `main` registers. Bound icon names (`icon="{binding}"`) make the choice model data - a per-row status icon, a play/pause toggle - and any name that fails to resolve at draw time renders the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap.
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4). `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) 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; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) 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; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
## Styling with design tokens
+22 -4
View File
@@ -190,7 +190,7 @@
},
{
"name": "tooltip",
"doc": "Tooltip text leaf; content supports {} interpolation."
"doc": "Tooltip text leaf; content supports {} interpolation. anchor=\"above|below\" floats it against its parent (put it beside its trigger in a stack) and hands visibility to the RUNTIME: it shows after the trigger has been hovered tooltip-delay milliseconds (default 600; a shared 400ms warm window after a pointer-hovered tooltip hides on leave shows the next one instantly), shows immediately when the trigger gains keyboard focus, and hides on pointer leave, focus departure, Escape, or a press of the trigger (only the pointer-leave hide warms) - the model never hears hover. Without anchor it stays a static leaf that paints whenever the view renders it."
},
{
"name": "input",
@@ -436,6 +436,10 @@
"name": "quiet-hover",
"doc": "Pressable (hit-target) elements only: the quiet-surface knob for image-forward content tiles - the pointer resting on the element paints NO hover wash (the wash belongs to acting controls like rows, menu items, and buttons). Only the hover fill goes quiet: press and selection fills, the focus ring, cursor intent, and hit testing keep their own channels."
},
{
"name": "tooltip-delay",
"doc": "tooltip only, beside anchor: hover-intent show delay in milliseconds (a plain number or one {binding}) - the runtime shows the anchored tooltip after its trigger has been hovered this long on the recorded frame clock. 0 shows the instant the trigger is hovered; absent follows the 600ms token default. A shared 400ms warm window after a pointer-hovered tooltip hides on leave skips the delay on the next trigger (no other hide cause warms); keyboard-focus reveals are always immediate. A delay without an anchor is a teaching error (it would be silently inert)."
},
{
"name": "background",
"doc": "Background color token (literal ColorTokens field name: background, surface, surface_subtle, ...)."
@@ -785,15 +789,29 @@
"dropdown-menu": [
{
"name": "anchor",
"doc": "dropdown-menu: floats the surface against its PARENT's frame instead of the flow (literal below or above; either side auto-flips at the window edges). Late z-pass above the whole tree, window-clipped — never cropped by a scroll pane, never reflows siblings. Put the dropdown beside its trigger inside a stack."
"doc": "dropdown-menu and tooltip: floats the surface against its PARENT's frame instead of the flow (literal below or above; either side auto-flips at the window edges). Late z-pass above the whole tree, window-clipped — never cropped by a scroll pane, never reflows siblings. Put the surface beside its trigger inside a stack. An anchored tooltip's visibility is runtime-owned (hover intent on the trigger; see tooltip-delay), unlike the model-owned dropdown."
},
{
"name": "anchor-alignment",
"doc": "dropdown-menu (with anchor): horizontal alignment against the anchor - start, end, or stretch (stretch also widens the surface to at least the anchor's width, the select-menu look)."
"doc": "dropdown-menu and tooltip (with anchor): horizontal alignment against the anchor - start, end, or stretch (stretch also widens the surface to at least the anchor's width, the select-menu look)."
},
{
"name": "anchor-offset",
"doc": "dropdown-menu (with anchor): literal gap in points between the anchor edge and the surface (default 4)."
"doc": "dropdown-menu and tooltip (with anchor): literal gap in points between the anchor edge and the surface (default 4)."
}
],
"tooltip": [
{
"name": "anchor",
"doc": "dropdown-menu and tooltip: floats the surface against its PARENT's frame instead of the flow (literal below or above; either side auto-flips at the window edges). Late z-pass above the whole tree, window-clipped — never cropped by a scroll pane, never reflows siblings. Put the surface beside its trigger inside a stack. An anchored tooltip's visibility is runtime-owned (hover intent on the trigger; see tooltip-delay), unlike the model-owned dropdown."
},
{
"name": "anchor-alignment",
"doc": "dropdown-menu and tooltip (with anchor): horizontal alignment against the anchor - start, end, or stretch (stretch also widens the surface to at least the anchor's width, the select-menu look)."
},
{
"name": "anchor-offset",
"doc": "dropdown-menu and tooltip (with anchor): literal gap in points between the anchor edge and the surface (default 4)."
}
],
"template": [
+2 -2
View File
@@ -171,7 +171,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
| `resizable` | resizable | engine-managed drag handle; `width` sets the initial width |
| `split` | split | two-pane horizontal splitter: exactly two element children (nest splits for more panes), the engine synthesizes the draggable divider between them. `value` binds the model-owned first-pane fraction (0 lays out at 0.5), `on-resize` names an f32 Msg variant dispatched with every applied fraction (echo it back through `value` — see Splitters), `min-width` on the panes bounds the drag, `gap` sets the divider band thickness. The divider is focusable: Left/Right (Shift for bigger steps) adjust, Home/End jump to the clamp edges |
| `tree` | tree | disclosure-tree container (vertical flow): descendant rows carrying `role="treeitem"` — at ANY nesting depth — form one roving keyboard focus set with the ARIA tree keymap. Up/Down walk visible rows (selection follows focus through each row's `on-press`), Left collapses an expanded row or moves to the parent row, Right expands a collapsed row or moves to the first child row, Home/End jump to the edges, Enter/Space activate. Expandable rows bind `expanded` and `on-toggle`; the model owns selection and expansion (collapsed children are simply not rendered) |
| `text`, `badge`, `tooltip` | text leaves | text content, `{}` interpolation allowed; `text` line policy via `wrap` (`"true"` word-wraps; `"false"`/unset paint one honest line, overflow eliding by default — `overflow="clip"` opts out), and `text` alone takes the typography rungs `size="heading"`/`size="display"` (themable token steps above title — section headings, hero stats, timer numerals) |
| `text`, `badge`, `tooltip` | text leaves | text content, `{}` interpolation allowed; `text` line policy via `wrap` (`"true"` word-wraps; `"false"`/unset paint one honest line, overflow eliding by default — `overflow="clip"` opts out), and `text` alone takes the typography rungs `size="heading"`/`size="display"` (themable token steps above title — section headings, hero stats, timer numerals). `tooltip` with `anchor="above\|below"` floats against its parent (the stack wrapping trigger + tooltip, the dropdown pattern) and the RUNTIME owns its visibility — hover intent on the trigger: shows after `tooltip-delay` ms (default 600; `"0"` = instant) and immediately on keyboard focus; hides on leave, focus departure, Escape, or a press of the trigger (a press also closes the warm window), and a shared 400ms warm window after a pointer-hovered tooltip hides on leave (the only hide that warms) shows the next trigger's tooltip instantly; the model never hears hover. These are shadcn/ui's defaults (Base UI). Without `anchor` it stays a static leaf that paints whenever the view renders it |
| `text` > `span` | inline styled runs | mixed-style text in ONE wrapped paragraph: span children style runs with `weight="regular\|medium\|bold"`, `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size — inline headings, hero stats), `underline`, `foreground` (token name); `{bindings}` interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see "Rich text" |
| `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | text-bearing controls | label is the text content; `button`, `toggle-button`, `list-item`, and `menu-item` also take `icon="save"` — a vector icon drawn inline (buttons/toggle-buttons before the label, icon-only when the content is empty: add a `label`; list/menu items as a leading slot), ONE hit target whose icon follows the element's enabled/disabled tint (no overlay stacking, no duplicated `on-press`); tab strips are `toggle-button` children, so tabs get icons this way; `select` shows `placeholder` while empty and dispatches `on-press`; `avatar` renders initials, or a runtime image via `image="{binding}"` (see the Images section) |
| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); the checkbox/radio label rides `text="..."` — these are not text-bearing elements, so text content is a teaching error (`label=` alone names one for accessibility without a visible label); a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) |
@@ -191,7 +191,7 @@ Not markup-expressible (deliberately — write these as Zig view functions with
## 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` only, 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), `anchor-alignment` (with `anchor`: `start`/`end`/`stretch` — stretch also widens the surface to at least the anchor's width, the select-menu look), `anchor-offset` (with `anchor`: literal gap in points, default 4), `overscroll` (`scroll` only, a teaching error elsewhere: `none` pins the region at its content edges — the shipped default via the `ScrollPhysics.overscroll` token — `rubber_band` lets it bounce past them on both the engine and native paths, `default` follows the token).
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), `anchor-alignment` (with `anchor`: `start`/`end`/`stretch` — stretch also widens the surface to at least the anchor's width, the select-menu look), `anchor-offset` (with `anchor`: literal gap in points, default 4), `tooltip-delay` (`tooltip` only, beside `anchor` — a teaching error elsewhere or without it: hover-intent show delay in ms, default 600, `"0"` = instant; keyboard-focus reveals are always immediate), `overscroll` (`scroll` only, a teaching error elsewhere: `none` pins the region at its content edges — the shipped default via the `ScrollPhysics.overscroll` token — `rubber_band` lets it bounce past them on both the engine and native paths, `default` follows the token).
Appearance/state: `variant` (default|primary|secondary|outline|ghost|destructive), `size` (the control scale default|sm|lg|icon on every sized element; on `text` also the typography rungs heading|display — named typography token steps (`heading_size` 28, `display_size` 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), `disabled`, `checked`, `selected`, `value`, `placeholder`, `icon` (`button`, `toggle-button`, `list-item`, `menu-item`: 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, `app:<name>`, or one `{binding}` resolving to such a name). **One size register per row**: 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 `size` — mixing `size="sm"` buttons with a default field renders two heights in one row, and hand-sized pressable panels (`height="30"`) never land on the scale; compose rows from real controls at one register.
Focus: `autofocus` (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 (`<text-field autofocus="{editing}" ...>` or mount the field under an `<if>` with `autofocus="true"`; Zig views use `ElementOptions.autofocus`) and to give keyboard-first apps their first focus without a click.
Semantics: `role` (listitem, treeitem, button, ...; `treeitem` also makes the row part of its tree's roving keyboard focus set), `label` (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 `label` an element whose visible text you grep for), `expanded` (tree rows: disclosure state, model-owned — omit on leaves). Accessible names are ENFORCED: an interactive control with no text content, no `text=`, and no `label=` is a validation error (icon-only controls need `label`; text-entry controls need `label` or `placeholder`), unknown/misused literal roles are errors (`role="tree"` on a text leaf can never hold rows), unnamed avatars and labels duplicating the text content are warnings (`label=""` marks an image decorative). Zig-built trees get the same discipline from `canvas.expectA11yAuditSweepClean` (missing names as the bridges would announce them, focusables clipped out of keyboard reach, identically labeled siblings) — adopt it next to the layout sweep.
+22
View File
@@ -549,6 +549,26 @@ pub const ControlMetricTokens = struct {
/// cycle is part of the register's identity, and reduced motion
/// already gates the loop through `MotionTokens` upstream.
spinner_period_ms: u32 = 1000,
/// Hover-intent show delay for ANCHORED tooltips, in milliseconds:
/// the runtime shows an anchored tooltip only after its trigger has
/// been hovered this long on the recorded frame clock, so sweeping
/// a dense toolbar never flashes every tooltip. Per-tooltip markup
/// (`tooltip-delay=`) and the builder (`tooltip_delay:`) override
/// it; 0 shows the instant the trigger is hovered. Kept with the
/// behavior metrics (like `spinner_period_ms`) because hover intent
/// is part of the tooltip register's identity, not the motion
/// ladder. 600ms is shadcn's Base UI-backed default trigger delay.
tooltip_show_delay_ms: u32 = 600,
/// The shared warm window after a pointer-hovered tooltip hides on
/// pointer leave (focus departure, Escape, presses, and view blur
/// deliberately do not warm): moving
/// to another tooltip trigger within this many milliseconds shows
/// its tooltip immediately (no delay) and re-warms on the next
/// hide — the skip-delay polish that makes sweeping a toolbar of
/// already-explained controls feel right. View-wide by design, not
/// per-tooltip: the warmth belongs to the pointer, not the widget.
/// 400ms is shadcn's Base UI-backed default provider timeout.
tooltip_warm_window_ms: u32 = 400,
};
pub const ShadowToken = struct {
@@ -1197,6 +1217,8 @@ pub const ControlMetricTokenOverrides = struct {
spinner_segment_radius_ratio: ?f32 = null,
spinner_tail_opacity: ?f32 = null,
spinner_period_ms: ?u32 = null,
tooltip_show_delay_ms: ?u32 = null,
tooltip_warm_window_ms: ?u32 = null,
pub fn apply(self: ControlMetricTokenOverrides, base: ControlMetricTokens) ControlMetricTokens {
return applyFlatTokenOverrides(ControlMetricTokens, base, self);
+12
View File
@@ -598,6 +598,17 @@ pub fn Ui(comptime Msg: type) type {
/// Negative (the default) declares no origin. Only
/// meaningful with a nonzero `resize_duration`.
resize_origin: f32 = -1,
/// Hover-intent show delay for an ANCHORED tooltip (markup
/// `tooltip-delay`), in milliseconds: the runtime shows the
/// tooltip only after its trigger has been hovered this
/// long on the recorded frame clock. 0 shows the instant
/// the trigger is hovered. Negative (the default) follows
/// the token default
/// (`ControlMetricTokens.tooltip_show_delay_ms`). Only
/// meaningful on a tooltip with `anchor` — the markup
/// validator rejects it anywhere else as silently-inert
/// data.
tooltip_delay: i32 = -1,
style: canvas.WidgetStyle = .{},
/// Named token references resolved against design tokens in
/// `finalizeWithTokens`; explicit `style` values win.
@@ -2697,6 +2708,7 @@ pub fn Ui(comptime Msg: type) type {
.resize_duration_ms = options.resize_duration,
.resize_easing = options.resize_easing,
.resize_origin = options.resize_origin,
.tooltip_delay_ms = options.tooltip_delay,
};
applyKindDefaultLayout(kind, options, &widget.layout);
return widget;
+27 -4
View File
@@ -1133,13 +1133,15 @@ pub const known_dismiss_element_names = schema.dismiss_element_names;
pub const on_dismiss_element_message = "on-dismiss is only supported on dismissible surfaces (dialog, drawer, sheet, dropdown-menu) - Escape and click-outside dismiss those, and the Msg lets the model own the close (clear the open flag in update)";
/// Elements that may float as anchored surfaces. dropdown-menu is the
/// markup channel; popover/menu-surface stay Zig views (documented
/// exclusions) and dialogs/drawers/sheets place themselves.
/// Elements that may float as anchored surfaces. dropdown-menu and
/// tooltip are the markup channels; popover/menu-surface stay Zig views
/// (documented exclusions) and dialogs/drawers/sheets place themselves.
/// An anchored tooltip's visibility is RUNTIME-owned (hover intent on
/// its trigger), unlike the model-owned dropdown.
/// Registry-derived from the `anchorable` element predicate.
pub const known_anchor_element_names = schema.anchor_element_names;
pub const anchor_element_message = "anchor is only supported on dropdown-menu - it floats the surface against its PARENT's frame (put the dropdown beside its trigger inside a stack); dialogs, drawers, and sheets place themselves";
pub const anchor_element_message = "anchor is only supported on dropdown-menu and tooltip - it floats the surface against its PARENT's frame (put the dropdown or tooltip beside its trigger inside a stack); dialogs, drawers, and sheets place themselves";
pub const anchor_value_message = "anchor takes a literal placement: below or above (either side flips automatically when the surface does not fit and the other side has more room)";
pub const anchor_alignment_value_message = "anchor-alignment takes a literal alignment: start, end, or stretch (stretch also widens the surface to at least the anchor's width)";
pub const anchor_offset_value_message = "anchor-offset takes a literal number: the gap in points between the anchor edge and the surface";
@@ -1241,6 +1243,10 @@ pub const resize_origin_element_message = "resize-origin is only supported on sp
pub const resize_origin_dependent_attr_message = "resize-origin needs a nonzero resize-duration on the same split - without a duration a mount lands on its value and the origin is silently inert";
pub const tooltip_delay_element_message = "tooltip-delay is only supported on tooltip - it sets the hover-intent show delay (milliseconds; 0 shows the instant the trigger is hovered) the runtime waits before showing an ANCHORED tooltip; anywhere else it would be silently inert";
pub const tooltip_delay_dependent_attr_message = "tooltip-delay needs anchor on the same tooltip - only an anchored tooltip is hover-shown by the runtime (a static tooltip paints whenever the view renders it), so without anchor the delay is silently inert";
pub const avatar_image_message = "image takes one {binding} to a u64 ImageId the app registered at runtime (fx.registerImageBytes) - runtime image ids are model data, not markup literals; 0 renders the initials fallback";
pub const avatar_image_element_message = "image is only supported on avatar - the other image-bearing widgets (image, icon-button) stay Zig views (ui.image with ElementOptions.image)";
@@ -3211,6 +3217,23 @@ fn validateNode(document: MarkupDocument, node: MarkupNode, parent_element: ?[]c
}
}
}
if (std.mem.eql(u8, attribute.name, "tooltip-delay")) {
// Hover intent exists only where the runtime owns
// visibility: anywhere but a tooltip the delay is
// silently inert (same policy as resize-duration
// off split).
if (!std.mem.eql(u8, node.name, "tooltip")) {
return attrError(node, attribute, tooltip_delay_element_message);
}
// The delay shapes a hover-show that exists only on
// an ANCHORED tooltip: a static tooltip paints
// whenever the view renders it, so a delay beside
// no anchor is silently inert — a teaching error
// (the anchor-alignment-without-anchor policy).
if (node.attr("anchor") == null) {
return attrError(node, attribute, tooltip_delay_dependent_attr_message);
}
}
if (std.mem.eql(u8, attribute.name, "size")) {
// The size register's closed literal vocabulary
// (bindings resolve at build): the control scale
+9 -1
View File
@@ -1732,8 +1732,16 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
.optional => @field(options, zig_field) = evalExpr(node, entries, raw, ui, model, scope).truthy(),
.int => {
comptime requireVariant(variant, &.{.integer}, node, "expected a whole number");
// Range-checked against the field's own integer type
// before the cast (the grid-lines pattern; the
// interpreter fails the same way): expression values
// are i64, so a literal or model binding can deliver
// 2147483648 to the i32 tooltip-delay — an unchecked
// @intCast TRAPPED on it instead of failing the
// build. The field type is the honest upper bound;
// no semantic cap is invented on top of it.
@field(options, zig_field) = switch (evalExpr(node, entries, raw, ui, model, scope)) {
.integer => |int| if (int < 0) runtimeFail(FieldType, ui) else @intCast(int),
.integer => |int| if (int < 0 or int > std.math.maxInt(FieldType)) runtimeFail(FieldType, ui) else @intCast(int),
else => runtimeFail(FieldType, ui),
};
},
@@ -479,6 +479,133 @@ test "compiled split tween pair lowers into the layout-tween declaration identic
try testing.expectEqual(interpreted_split.value, compiled_split.value);
}
// ---------------------------------------- anchored-tooltip intent parity
const tooltip_intent_markup =
\\<column gap="12">
\\ <stack>
\\ <text>Bold</text>
\\ <tooltip anchor="above" tooltip-delay="250">Bold the selection</tooltip>
\\ </stack>
\\ <stack>
\\ <text>Link</text>
\\ <tooltip anchor="below" anchor-offset="8">Insert a link</tooltip>
\\ </stack>
\\ <tooltip>Copied!</tooltip>
\\</column>
;
const TooltipIntentCompiled = canvas.CompiledMarkupView(EntriesModel, EntriesMsg, tooltip_intent_markup);
test "compiled anchored tooltips stamp the hover-intent declaration identically to the interpreter" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const entries = [_]Entry{.{ .id = 11, .label = "first" }};
const model = EntriesModel{ .entries = &entries };
var view = try markup_view.MarkupView(EntriesModel, EntriesMsg).init(arena, tooltip_intent_markup);
var interpreter_ui = EntriesUi.init(arena);
const interpreted = try interpreter_ui.finalize(try view.build(&interpreter_ui, &model));
var compiled_ui = EntriesUi.init(arena);
const compiled = try compiled_ui.finalize(TooltipIntentCompiled.build(&compiled_ui, &model));
try expectSameTree(EntriesMsg, interpreted, compiled);
// Both engines stamp the declaration the runtime's hover-intent
// machine reads: the anchor floats the leaf against its parent, and
// tooltip-delay overrides the token default (-1 keeps it). The bare
// static tooltip keeps null anchor and the -1 default, so existing
// documents lower byte-identically.
inline for (.{ interpreted.root, compiled.root }) |root| {
const declared = fixture.findByText(root, .tooltip, "Bold the selection").?;
try testing.expectEqual(@as(i32, 250), declared.tooltip_delay_ms);
try testing.expectEqual(canvas.WidgetAnchorPlacement.above, declared.layout.anchor.?.placement);
const defaulted = fixture.findByText(root, .tooltip, "Insert a link").?;
try testing.expectEqual(@as(i32, -1), defaulted.tooltip_delay_ms);
try testing.expectEqual(canvas.WidgetAnchorPlacement.below, defaulted.layout.anchor.?.placement);
try testing.expectEqual(@as(f32, 8), defaulted.layout.anchor.?.offset);
const static = fixture.findByText(root, .tooltip, "Copied!").?;
try testing.expectEqual(@as(i32, -1), static.tooltip_delay_ms);
try testing.expectEqual(@as(?canvas.WidgetAnchor, null), static.layout.anchor);
}
}
// ------------------------------------------- tooltip-delay range guard
const TooltipDelayModel = struct { delay: i64 = 0 };
const tooltip_delay_binding_markup =
\\<stack>
\\ <text>Bold</text>
\\ <tooltip anchor="above" tooltip-delay="{delay}">Bold the selection</tooltip>
\\</stack>
;
const tooltip_delay_boundary_markup =
\\<column gap="12">
\\ <stack>
\\ <text>Bold</text>
\\ <tooltip anchor="above" tooltip-delay="0">Bold the selection</tooltip>
\\ </stack>
\\ <stack>
\\ <text>Link</text>
\\ <tooltip anchor="below" tooltip-delay="2147483647">Insert a link</tooltip>
\\ </stack>
\\</column>
;
const tooltip_delay_overflow_literal_markup =
\\<stack>
\\ <text>Bold</text>
\\ <tooltip anchor="above" tooltip-delay="2147483648">Bold the selection</tooltip>
\\</stack>
;
const TooltipDelayUi = canvas.Ui(EntriesMsg);
const TooltipDelayBindingCompiled = canvas.CompiledMarkupView(TooltipDelayModel, EntriesMsg, tooltip_delay_binding_markup);
const TooltipDelayBoundaryCompiled = canvas.CompiledMarkupView(TooltipDelayModel, EntriesMsg, tooltip_delay_boundary_markup);
const TooltipDelayOverflowLiteralCompiled = canvas.CompiledMarkupView(TooltipDelayModel, EntriesMsg, tooltip_delay_overflow_literal_markup);
test "compiled tooltip-delay past i32 max fails the build instead of trapping" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A literal one past maxInt(i32): the option field's own type is
// the bound (the grid-lines pattern), so the cast that used to
// trap latches the failed build the interpreter's teaching error
// mirrors, and finalize surfaces it.
var literal_ui = TooltipDelayUi.init(arena);
const literal_node = TooltipDelayOverflowLiteralCompiled.build(&literal_ui, &TooltipDelayModel{});
try testing.expect(literal_ui.failed);
try testing.expectError(error.OutOfMemory, literal_ui.finalize(literal_node));
// Bindings deliver i64 model values into the same cast seam, so an
// out-of-range model value fails the same way — per-build, since
// only the value (not the document) is wrong.
var overflow_ui = TooltipDelayUi.init(arena);
const overflow_model = TooltipDelayModel{ .delay = @as(i64, std.math.maxInt(i32)) + 1 };
const overflow_node = TooltipDelayBindingCompiled.build(&overflow_ui, &overflow_model);
try testing.expect(overflow_ui.failed);
try testing.expectError(error.OutOfMemory, overflow_ui.finalize(overflow_node));
// The same document lowers when the model holds an in-range value.
var ok_ui = TooltipDelayUi.init(arena);
const ok_model = TooltipDelayModel{ .delay = 250 };
const ok_tree = try ok_ui.finalize(TooltipDelayBindingCompiled.build(&ok_ui, &ok_model));
try testing.expectEqual(@as(i32, 250), fixture.findByText(ok_tree.root, .tooltip, "Bold the selection").?.tooltip_delay_ms);
// Boundary values pass: 0 (the instant-show escape hatch) and the
// type's exact max.
var boundary_ui = TooltipDelayUi.init(arena);
const boundary_tree = try boundary_ui.finalize(TooltipDelayBoundaryCompiled.build(&boundary_ui, &TooltipDelayModel{}));
try testing.expectEqual(@as(i32, 0), fixture.findByText(boundary_tree.root, .tooltip, "Bold the selection").?.tooltip_delay_ms);
try testing.expectEqual(@as(i32, std.math.maxInt(i32)), fixture.findByText(boundary_tree.root, .tooltip, "Insert a link").?.tooltip_delay_ms);
}
// --------------------- multi-child for bodies and the for-empty else
const multi_entries_markup =
+47
View File
@@ -795,6 +795,53 @@ test "overscroll validates as scroll-scoped with a closed value vocabulary" {
}
}
test "tooltip-delay validates as tooltip-scoped beside anchor, and anchor accepts tooltip" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Valid: an anchored tooltip with a literal delay, a zero delay
// (instant show), a binding (resolved by the engines at build), and
// an anchored tooltip with no delay at all (the token default).
const valid_sources = [_][]const u8{
"<stack>\n <button on-press=\"pressed\">Bold</button>\n <tooltip anchor=\"above\" tooltip-delay=\"500\">Bold the selection</tooltip>\n</stack>",
"<stack>\n <button on-press=\"pressed\">Bold</button>\n <tooltip anchor=\"above\" tooltip-delay=\"0\">Bold the selection</tooltip>\n</stack>",
"<stack>\n <button on-press=\"pressed\">Bold</button>\n <tooltip anchor=\"below\" tooltip-delay=\"{delay}\">Bold the selection</tooltip>\n</stack>",
"<stack>\n <button on-press=\"pressed\">Bold</button>\n <tooltip anchor=\"above\">Bold the selection</tooltip>\n</stack>",
};
for (valid_sources) |source| {
var parser = markup.Parser.init(arena, source);
try testing.expectEqual(@as(?markup.MarkupErrorInfo, null), markup.validate(try parser.parse()));
}
const cases = [_]struct { source: []const u8, message: []const u8 }{
// Hover intent exists only where the runtime owns visibility;
// anywhere else the delay is silently inert.
.{
.source = "<column>\n <button on-press=\"pressed\" tooltip-delay=\"500\">Bold</button>\n</column>",
.message = markup.tooltip_delay_element_message,
},
// A delay beside no anchor shapes a hover-show that never
// happens: a static tooltip paints whenever the view renders it.
.{
.source = "<column>\n <tooltip tooltip-delay=\"500\">Bold the selection</tooltip>\n</column>",
.message = markup.tooltip_delay_dependent_attr_message,
},
// Anchor stays scoped to its registry set; tooltip joining it
// must not open other elements.
.{
.source = "<column>\n <badge anchor=\"above\">3</badge>\n</column>",
.message = markup.anchor_element_message,
},
};
for (cases) |case| {
var parser = markup.Parser.init(arena, case.source);
const info = markup.validate(try parser.parse()) orelse return error.TestUnexpectedResult;
try testing.expectEqualStrings(case.message, info.message);
try testing.expect(info.line > 0);
}
}
test "overflow validates as text-scoped with a closed value vocabulary" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
+10 -1
View File
@@ -1666,8 +1666,17 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
// Optional bools (`expanded`): the attribute's PRESENCE
// makes the state non-null; the value sets it.
.optional => @field(options, field) = value.truthy(),
// Range-checked against the FIELD's own integer type
// before the cast (the grid-lines teaching, generalized):
// expression values are i64, so a literal or model
// binding can deliver 2147483648 to the i32
// tooltip-delay — an unchecked @intCast TRAPPED on it
// instead of failing the build. The field type is the
// honest upper bound: no semantic millisecond cap is
// invented on top of it, matching resize-duration, whose
// only bound is likewise its u32.
.int => @field(options, field) = switch (value) {
.integer => |int| if (int < 0)
.integer => |int| if (int < 0 or int > std.math.maxInt(FieldType))
return self.failVoid(node, "expected a non-negative whole number")
else
@intCast(int),
@@ -716,6 +716,84 @@ test "resize-duration and resize-easing on split stamp the layout-tween declarat
try testing.expectEqual(canvas.Easing.standard, plain.resize_easing);
}
test "anchor and tooltip-delay on tooltip stamp the hover-intent declaration" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = Model{};
// An anchored tooltip declaring its own delay, an anchored tooltip
// on the token default, and a bare static tooltip that keeps the
// classic paints-when-rendered leaf — so existing documents lower
// byte-identically.
var view = try InboxMarkup.init(arena, "<column>\n <stack>\n <text>Bold</text>\n <tooltip anchor=\"above\" tooltip-delay=\"250\">Bold the selection</tooltip>\n </stack>\n <stack>\n <text>Link</text>\n <tooltip anchor=\"below\">Insert a link</tooltip>\n </stack>\n <tooltip>Copied!</tooltip>\n</column>");
var ui = InboxUi.init(arena);
const tree = try ui.finalize(try view.build(&ui, &model));
const declared = findByText(tree.root, .tooltip, "Bold the selection").?;
try testing.expectEqual(@as(i32, 250), declared.tooltip_delay_ms);
try testing.expectEqual(canvas.WidgetAnchorPlacement.above, declared.layout.anchor.?.placement);
const defaulted = findByText(tree.root, .tooltip, "Insert a link").?;
try testing.expectEqual(@as(i32, -1), defaulted.tooltip_delay_ms);
try testing.expectEqual(canvas.WidgetAnchorPlacement.below, defaulted.layout.anchor.?.placement);
const static = findByText(tree.root, .tooltip, "Copied!").?;
try testing.expectEqual(@as(i32, -1), static.tooltip_delay_ms);
try testing.expectEqual(@as(?canvas.WidgetAnchor, null), static.layout.anchor);
}
test "tooltip-delay past i32 max fails the build instead of trapping" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = Model{};
// One past maxInt(i32): the option field's own type is the bound,
// so the cast that used to trap is now the grid-lines teaching
// error. The value parses fine as a 64-bit literal — the failure
// must come from the attribute seam, not the expression parser.
var overflow_view = try InboxMarkup.init(arena, "<column>\n <stack>\n <text>Bold</text>\n <tooltip anchor=\"above\" tooltip-delay=\"2147483648\">Bold the selection</tooltip>\n </stack>\n</column>");
var overflow_ui = InboxUi.init(arena);
try testing.expectError(error.MarkupBuild, overflow_view.build(&overflow_ui, &model));
try testing.expectEqualStrings("expected a non-negative whole number", overflow_view.diagnostic.message);
// Boundary values pass: 0 (the instant-show escape hatch) and the
// type's exact max.
var boundary_view = try InboxMarkup.init(arena, "<column>\n <stack>\n <text>Bold</text>\n <tooltip anchor=\"above\" tooltip-delay=\"0\">Bold the selection</tooltip>\n </stack>\n <stack>\n <text>Link</text>\n <tooltip anchor=\"below\" tooltip-delay=\"2147483647\">Insert a link</tooltip>\n </stack>\n</column>");
var boundary_ui = InboxUi.init(arena);
const tree = try boundary_ui.finalize(try boundary_view.build(&boundary_ui, &model));
try testing.expectEqual(@as(i32, 0), findByText(tree.root, .tooltip, "Bold the selection").?.tooltip_delay_ms);
try testing.expectEqual(@as(i32, std.math.maxInt(i32)), findByText(tree.root, .tooltip, "Insert a link").?.tooltip_delay_ms);
}
test "tooltip-delay model binding past i32 max fails the build instead of trapping" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Bindings deliver i64 model values straight into the same cast
// seam the literal path uses, so an out-of-range model value must
// produce the same teaching error — per-build, since only the
// value (not the document) is wrong.
const DelayModel = struct { delay: i64 = 0 };
const DelayMarkup = markup_view.MarkupView(DelayModel, Msg);
const source = "<column>\n <stack>\n <text>Bold</text>\n <tooltip anchor=\"above\" tooltip-delay=\"{delay}\">Bold the selection</tooltip>\n </stack>\n</column>";
var overflow_view = try DelayMarkup.init(arena, source);
var overflow_ui = InboxUi.init(arena);
const overflow_model = DelayModel{ .delay = @as(i64, std.math.maxInt(i32)) + 1 };
try testing.expectError(error.MarkupBuild, overflow_view.build(&overflow_ui, &overflow_model));
try testing.expectEqualStrings("expected a non-negative whole number", overflow_view.diagnostic.message);
// The same document lowers when the model holds an in-range value.
var ok_view = try DelayMarkup.init(arena, source);
var ok_ui = InboxUi.init(arena);
const ok_model = DelayModel{ .delay = 250 };
const tree = try ok_ui.finalize(try ok_view.build(&ok_ui, &ok_model));
try testing.expectEqual(@as(i32, 250), findByText(tree.root, .tooltip, "Bold the selection").?.tooltip_delay_ms);
}
test "resize-easing value vocabulary mirrors the live Easing enum" {
// The validator's std-only mirror of the enum's member names; a new
// member cannot ship without its markup spelling.
+15 -1
View File
@@ -249,7 +249,13 @@ pub const elements = [_]ElementInfo{
.{ .code = 36, .name = "switch", .widget_kind = "switch_control", .takes_text = true, .a11y_name = .control },
.{ .code = 37, .name = "table-cell", .widget_kind = "data_cell", .takes_text = true },
.{ .code = 38, .name = "toggle-button", .widget_kind = "toggle_button", .takes_text = true, .icon_attr = true, .a11y_name = .control },
.{ .code = 39, .name = "tooltip", .widget_kind = "tooltip", .takes_text = true, .hit_target = false },
// Tooltip is anchorable: `anchor="above|below"` floats it against
// its PARENT's frame (the trigger, or the stack wrapping trigger +
// tooltip), and the RUNTIME owns its visibility — hidden until the
// hover-intent delay on the trigger fires (see `tooltip-delay`).
// Without `anchor` it stays the classic static text leaf that
// paints whenever the view renders it.
.{ .code = 39, .name = "tooltip", .widget_kind = "tooltip", .takes_text = true, .hit_target = false, .anchorable = true },
// Value controls and text entry.
.{ .code = 40, .name = "checkbox", .widget_kind = "checkbox", .a11y_name = .control },
.{ .code = 41, .name = "radio", .widget_kind = "radio", .a11y_name = .control },
@@ -450,6 +456,14 @@ pub const attrs = [_]AttrInfo{
// silently inert). No `field`: the engines apply it to
// `ElementOptions.style`, not a flat option field.
.{ .code = 79, .name = "quiet-hover", .class = .flag, .group = .option },
// Hover-intent show delay for ANCHORED tooltips (tooltip only, and
// only beside `anchor`; the validator scopes both), in whole
// milliseconds. The runtime shows an anchored tooltip after its
// trigger has been hovered this long on the recorded frame clock —
// 0 shows the instant the trigger is hovered. Negative is
// unreachable from markup; absent keeps the token default
// (`ControlMetricTokens.tooltip_show_delay_ms`).
.{ .code = 80, .name = "tooltip-delay", .class = .whole, .group = .option, .field = "tooltip_delay" },
};
// ----------------------------------------------------------------- events
+5 -4
View File
@@ -22,7 +22,7 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// the new fingerprint ONLY for additions; renames/renumbers are
// schema-version-bump events, not silent edits.
try testing.expectEqual(@as(usize, 65), schema.elements.len);
try testing.expectEqual(@as(usize, 79), schema.attrs.len);
try testing.expectEqual(@as(usize, 80), schema.attrs.len);
try testing.expectEqual(@as(usize, 10), schema.events.len);
// The element table runs through the span composite (64) and the
// bubble-reactions composite (65); the reaction pill's dock rides
@@ -36,10 +36,11 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// resize-duration (71) and resize-easing (72), the chart axis/hover
// attributes x-labels (73), y-labels (74), and hover-details (75),
// the later span additions scale (76) and underline (77), the
// split enter-from attribute resize-origin (78), and the
// quiet-surface hover knob quiet-hover (79).
// split enter-from attribute resize-origin (78), the quiet-surface
// hover knob quiet-hover (79), and the anchored-tooltip hover-intent
// delay tooltip-delay (80).
try testing.expectEqual(
@as(u64, 0x3b439a78a91600b6),
@as(u64, 0x5348b0f92c314d2a),
tableFingerprint(schema.AttrInfo, &schema.attrs),
);
try testing.expectEqual(
+12
View File
@@ -841,6 +841,18 @@ pub const Widget = struct {
/// origin: mounts land at their value, exactly as before. Ignored
/// on splits that already have a retained fraction.
resize_origin: f32 = -1,
/// Hover-intent show delay for an ANCHORED `.tooltip` widget
/// (`tooltip_delay:` in the builder, `tooltip-delay=` in markup),
/// in milliseconds. An anchored tooltip is runtime-owned hover
/// chrome: it stays hidden until its trigger (the anchor parent's
/// subtree) has been hovered this long on the recorded frame
/// clock, shows while the hover holds, and hides when the pointer
/// leaves — the model never hears hover. 0 shows the instant the
/// trigger is hovered. Negative (the default) follows the token
/// default (`ControlMetricTokens.tooltip_show_delay_ms`). Ignored
/// on every other kind, and on tooltips without an anchor (those
/// stay static leaves that paint whenever the view renders them).
tooltip_delay_ms: i32 = -1,
/// Window-drag surface (`window-drag="true"` / `.window_drag`): a
/// pointer press that lands here — or falls through plain text /
/// icons / decorations onto it — moves the WINDOW instead of
@@ -364,6 +364,17 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type {
const previous_state = self.views[view_index].canvasWidgetRenderState();
self.views[view_index].canvas_widget_focused_id = target.id;
self.views[view_index].canvas_widget_focus_visible_id = focus_visible_id;
// Pointer-contract provenance: a programmatic ring
// (editables only) never carries the keyboard's
// standing reveal intent into a layout adoption.
self.views[view_index].canvas_widget_focus_visible_keyboard = false;
// The pointer contract extends to tooltips: this move
// hides a focus-owned tooltip and reveals nothing (see
// updateCanvasTooltipIntentForProgrammaticFocusMove).
// Inside the changed-guard on purpose — re-focusing the
// widget whose focus-shown tooltip is up with the ring
// intact is not a move, and leaves it alone.
try CanvasWidgetEventMethods().updateCanvasTooltipIntentForProgrammaticFocusMove(self, view_index);
// A focus change repaints; record the automation input so
// the completing frame publishes (same contract as select
// and text edits). Callers that dispatch a follow-up input
@@ -382,12 +393,18 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type {
// the way a Tab-then-key would: escalate exactly this target
// kind to the ring register before dispatching. Every other
// kind keeps the quiet programmatic focus it always had.
// The escalation deliberately skips the tooltip machine —
// it is still programmatic focus (no reveal), and the focus
// MOVE above already hid any focus-owned tooltip.
if (self.views[view_index].canvasWidgetNodeIndexById(id)) |node_index| {
const widget = self.views[view_index].widget_layout_nodes[node_index].widget;
const plain_list_row = widget.kind == .list_item and widget.semantics.role != .treeitem;
if (plain_list_row and self.views[view_index].canvas_widget_focus_visible_id != id) {
const previous_state = self.views[view_index].canvasWidgetRenderState();
self.views[view_index].canvas_widget_focus_visible_id = id;
// Still programmatic (no reveal — see the comment
// above), so no keyboard provenance either.
self.views[view_index].canvas_widget_focus_visible_keyboard = false;
try CanvasWidgetEventMethods().invalidateForCanvasWidgetRenderStateChange(self, view_index, previous_state, self.views[view_index].canvasWidgetRenderState());
}
}
+15
View File
@@ -1134,6 +1134,21 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type {
if (self.views[index].canvasRenderAnimationsActive(frame_options.timestamp_ns)) {
self.invalidateFor(.state, self.views[index].frame);
}
// An armed tooltip show delay — and a running anchor-gap
// transit grace — only fire on a presented frame's
// timestamp, so frames must keep coming while either is
// pending: the render-animation pump's policy. This
// per-frame leg takes over from the FIRST invalidation,
// which the intent choke point kicks the moment any
// deadline arms (reconcileCanvasTooltipIntent step 5 —
// a deadline armed by an event that repainted nothing
// would otherwise wait forever for a frame nothing
// requested). Settled shown tooltips and the warm
// window need no pump: both step on journaled input
// timestamps.
if (self.views[index].canvasTooltipIntentArmed()) {
self.invalidateFor(.state, self.views[index].frame);
}
} else {
self.views[index].recordCanvasFrame(canvas_frame);
}
+254 -66
View File
@@ -132,80 +132,89 @@ fn pickerOptions() PickerApp.Options {
};
}
const Fixture = struct {
harness: *core.TestHarness(),
app_state: *PickerApp,
app: core.App,
/// The TEA harness shape every anchored fixture in this file shares,
/// parameterized over the app so the tooltip-coexistence picker below
/// reuses the picker's dispatch helpers verbatim.
fn AppFixture(comptime AppType: type, comptime appOptions: fn () AppType.Options) type {
return struct {
harness: *core.TestHarness(),
app_state: *AppType,
app: core.App,
fn create() !Fixture {
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
errdefer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try PickerApp.create(std.heap.page_allocator, pickerOptions());
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
return .{ .harness = harness, .app_state = app_state, .app = app };
}
const Self = @This();
fn destroy(self: Fixture) void {
self.app_state.destroy();
self.harness.destroy(std.testing.allocator);
}
fn widgetIdByText(self: Fixture, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
return findIn(self.app_state.tree.?.root, kind, text);
}
fn findIn(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget.id;
for (widget.children) |child| {
if (findIn(child, kind, text)) |id| return id;
fn create() !Self {
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
errdefer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try AppType.create(std.heap.page_allocator, appOptions());
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
return .{ .harness = harness, .app_state = app_state, .app = app };
}
return null;
}
fn retainedFrame(self: Fixture, id: canvas.ObjectId) !?geometry.RectF {
const layout = try self.harness.runtime.canvasWidgetLayout(1, canvas_label);
const node = layout.findById(id) orelse return null;
if (node.widget.semantics.hidden) return null;
return node.frame;
}
fn destroy(self: Self) void {
self.app_state.destroy();
self.harness.destroy(std.testing.allocator);
}
fn pointer(self: Fixture, kind: support.platform.GpuSurfaceInputKind, point: geometry.PointF) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = canvas_label,
.kind = kind,
.x = point.x,
.y = point.y,
} });
}
fn widgetIdByText(self: Self, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
return findIn(self.app_state.tree.?.root, kind, text);
}
fn click(self: Fixture, point: geometry.PointF) !void {
try self.pointer(.pointer_down, point);
try self.pointer(.pointer_up, point);
}
fn findIn(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget.id;
for (widget.children) |child| {
if (findIn(child, kind, text)) |id| return id;
}
return null;
}
fn clickWidget(self: Fixture, id: canvas.ObjectId) !void {
const frame = (try self.retainedFrame(id)) orelse return error.TestUnexpectedResult;
try self.click(frame.center());
}
fn retainedFrame(self: Self, id: canvas.ObjectId) !?geometry.RectF {
const layout = try self.harness.runtime.canvasWidgetLayout(1, canvas_label);
const node = layout.findById(id) orelse return null;
if (node.widget.semantics.hidden) return null;
return node.frame;
}
fn key(self: Fixture, name: []const u8) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = canvas_label,
.kind = .key_down,
.key = name,
} });
}
};
fn pointer(self: Self, kind: support.platform.GpuSurfaceInputKind, point: geometry.PointF) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = canvas_label,
.kind = kind,
.x = point.x,
.y = point.y,
} });
}
fn click(self: Self, point: geometry.PointF) !void {
try self.pointer(.pointer_down, point);
try self.pointer(.pointer_up, point);
}
fn clickWidget(self: Self, id: canvas.ObjectId) !void {
const frame = (try self.retainedFrame(id)) orelse return error.TestUnexpectedResult;
try self.click(frame.center());
}
fn key(self: Self, name: []const u8) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.label = canvas_label,
.kind = .key_down,
.key = name,
} });
}
};
}
const Fixture = AppFixture(PickerApp, pickerOptions);
test "anchored picker: trigger opens, menu floats, item click picks and closes" {
const fixture = try Fixture.create();
@@ -676,3 +685,182 @@ test "the per-view anchored budget rejects a surface per row loudly" {
harness.runtime.setCanvasWidgetLayout(1, "canvas", over_budget),
);
}
// ------------------------------------------- tooltip + menu coexistence
//
// With `<tooltip>` anchorable beside `dropdown-menu`, one trigger stack
// can float TWO anchored surfaces at once: Tab earns the focus-shown
// tooltip, ArrowDown opens the menu beneath it. The tests below pin the
// keymap against that coexistence — the menu-specific lookups must scan
// FOR menu kinds instead of kind-checking whatever floats topmost, and
// Escape peels one surface per press, topmost (the tooltip) first.
const CoexistModel = struct {
open: bool = false,
picked: u32 = 99,
picks: u32 = 0,
toggles: u32 = 0,
dismissals: u32 = 0,
};
const CoexistMsg = union(enum) {
toggle_picker,
close_picker,
pick: u32,
noop,
};
const CoexistApp = ui_app_model.UiApp(CoexistModel, CoexistMsg);
fn coexistUpdate(model: *CoexistModel, msg: CoexistMsg) void {
switch (msg) {
.toggle_picker => {
model.open = !model.open;
model.toggles += 1;
},
.close_picker => {
model.open = false;
model.dismissals += 1;
},
.pick => |index| {
model.picked = index;
model.picks += 1;
model.open = false;
},
.noop => {},
}
}
/// The coexistence stack in the order that shadows: select trigger,
/// then the anchored dropdown-menu, then the anchored tooltip LAST —
/// while focus-shown, the tooltip is the topmost anchored child of the
/// very stack that anchors the open menu. The tooltip carries an
/// explicit key so its identity survives the open/close rebuilds that
/// insert and remove the menu ahead of it.
fn coexistView(ui: *CoexistApp.Ui, model: *const CoexistModel) CoexistApp.Ui.Node {
const trigger = ui.el(.select, .{ .text = "Repo", .width = 160, .on_press = .toggle_picker }, .{});
const tooltip = ui.el(.tooltip, .{ .key = .{ .int = 7 }, .text = "Choose the repository", .anchor = .above }, .{});
const picker = if (model.open) ui.stack(.{ .height = 28 }, .{
trigger,
ui.el(.dropdown_menu, .{
.key = .{ .int = 3 },
.anchor = .below,
.anchor_alignment = .stretch,
.width = 160,
.height = 90,
.on_dismiss = .close_picker,
}, .{
ui.el(.menu_item, .{ .key = .{ .int = 0 }, .text = "Alpha", .height = 26, .selected = model.picked == 0, .on_press = CoexistMsg{ .pick = 0 } }, .{}),
ui.el(.menu_item, .{ .key = .{ .int = 1 }, .text = "Beta", .height = 26, .selected = model.picked == 1, .on_press = CoexistMsg{ .pick = 1 } }, .{}),
}),
tooltip,
}) else ui.stack(.{ .height = 28 }, .{ trigger, tooltip });
return ui.column(.{ .gap = 8, .padding = 12 }, .{
picker,
ui.button(.{ .on_press = .noop }, "After"),
});
}
fn coexistOptions() CoexistApp.Options {
return .{
.name = "ui-app-coexist",
.scene = picker_scene,
.canvas_label = canvas_label,
.update = coexistUpdate,
.view = coexistView,
};
}
const CoexistFixture = AppFixture(CoexistApp, coexistOptions);
/// Tab onto the select earns the focus-shown tooltip, then ArrowDown
/// opens the menu beneath it. Returns the trigger id after asserting
/// the coexistence really holds: menu mounted AND tooltip shown.
fn coexistOpenWithTooltip(fixture: CoexistFixture) !canvas.ObjectId {
const trigger_id = fixture.widgetIdByText(.select, "Repo").?;
try fixture.key("tab");
try std.testing.expectEqual(trigger_id, fixture.harness.runtime.views[0].canvas_widget_focus_visible_id);
const tooltip_id = fixture.widgetIdByText(.tooltip, "Choose the repository").?;
try std.testing.expectEqual(tooltip_id, fixture.harness.runtime.views[0].canvas_tooltip_shown_id);
// ArrowDown on the closed trigger presses it (the model-owned
// open); the arrow is no activation key, so the focus-shown
// tooltip survives the press AND the rebuild that mounts the menu.
try fixture.key("arrowdown");
try std.testing.expect(fixture.app_state.model.open);
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.toggles);
try std.testing.expectEqual(trigger_id, fixture.harness.runtime.views[0].canvas_widget_focused_id);
try std.testing.expectEqual(tooltip_id, fixture.harness.runtime.views[0].canvas_tooltip_shown_id);
try std.testing.expect((try fixture.retainedFrame(tooltip_id)) != null);
try std.testing.expect((try fixture.retainedFrame(fixture.widgetIdByText(.menu_item, "Alpha").?)) != null);
return trigger_id;
}
test "tooltip coexistence: arrows walk into the open menu beneath the focus-shown tooltip" {
const fixture = try CoexistFixture.create();
defer fixture.destroy();
_ = try coexistOpenWithTooltip(fixture);
// The next ArrowDown walks INTO the menu — the tooltip floating
// topmost must not shadow the owned-menu lookup into re-pressing
// the trigger (which would toggle the picker closed).
const alpha_id = fixture.widgetIdByText(.menu_item, "Alpha").?;
try fixture.key("arrowdown");
try std.testing.expectEqual(alpha_id, fixture.harness.runtime.views[0].canvas_widget_focused_id);
try std.testing.expect(fixture.app_state.model.open);
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.toggles);
// Entering the menu moved focus off the trigger: the focus-shown
// tooltip hides through its own blur cause, not through any menu
// machinery.
try std.testing.expectEqual(@as(canvas.ObjectId, 0), fixture.harness.runtime.views[0].canvas_tooltip_shown_id);
}
test "tooltip coexistence: Tab departure closes the menu the tooltip floats over" {
const fixture = try CoexistFixture.create();
defer fixture.destroy();
const trigger_id = try coexistOpenWithTooltip(fixture);
const tooltip_id = fixture.harness.runtime.views[0].canvas_tooltip_shown_id;
// Tab is focus departure: it must find and dismiss the MENU even
// though the tooltip is the topmost anchored child, consume the
// Tab, and leave the keyboard on the trigger. The tooltip is not
// the departure's business: focus never left the trigger, so the
// focus-shown tooltip stays.
try fixture.key("tab");
try std.testing.expect(!fixture.app_state.model.open);
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.dismissals);
try std.testing.expectEqual(@as(u32, 0), fixture.app_state.model.picks);
try std.testing.expectEqual(trigger_id, fixture.harness.runtime.views[0].canvas_widget_focused_id);
try std.testing.expectEqual(tooltip_id, fixture.harness.runtime.views[0].canvas_tooltip_shown_id);
}
test "tooltip coexistence: escape peels the tooltip first, then the menu, one per press" {
const fixture = try CoexistFixture.create();
defer fixture.destroy();
const trigger_id = try coexistOpenWithTooltip(fixture);
const tooltip_id = fixture.harness.runtime.views[0].canvas_tooltip_shown_id;
// Escape dismisses exactly one surface per press, topmost
// (last-mounted) first: the tooltip goes while the menu stays
// open and the keyboard stays on the trigger.
try fixture.key("escape");
try std.testing.expectEqual(@as(canvas.ObjectId, 0), fixture.harness.runtime.views[0].canvas_tooltip_shown_id);
try std.testing.expect((try fixture.retainedFrame(tooltip_id)) == null);
try std.testing.expect(fixture.app_state.model.open);
try std.testing.expectEqual(@as(u32, 0), fixture.app_state.model.dismissals);
try std.testing.expectEqual(trigger_id, fixture.harness.runtime.views[0].canvas_widget_focused_id);
// The next Escape reaches the menu through the model's on_dismiss,
// still without committing, and the keyboard stays on the trigger.
try fixture.key("escape");
try std.testing.expect(!fixture.app_state.model.open);
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.dismissals);
try std.testing.expectEqual(@as(u32, 0), fixture.app_state.model.picks);
try std.testing.expectEqual(@as(u32, 99), fixture.app_state.model.picked);
try std.testing.expectEqual(trigger_id, fixture.harness.runtime.views[0].canvas_widget_focused_id);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn RuntimeCanvasWidgetScrollDrivers(comptime Runtime: type) type {
const dirty = try self.views[index].applyCanvasWidgetScrollDriverOffset(node_index, event.offset_y) orelse return;
const previous_cursor = self.views[index].canvas_widget_cursor;
self.views[index].reconcileCanvasWidgetRenderStateAfterScroll(null);
try CanvasWidgetEventMethods().reconcileCanvasWidgetRenderStateAfterScrollWithTooltipIntent(self, index, null);
if (previous_cursor != self.views[index].canvas_widget_cursor) {
try CanvasWidgetEventMethods().syncCanvasWidgetCursorForView(self, index);
}
+45 -1
View File
@@ -96,6 +96,40 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type {
// and force-pushing the clamp into the live bounce (visible
// jitter). Non-driver platforms clamp exactly as before.
canvas_widget_runtime.clampCanvasWidgetLayoutScrollOffsets(reconciled_nodes[0..reconciled_layout.nodes.len], null);
// Runtime-owned tooltip visibility normalizes BEFORE the
// diff: the retained tree carries the intent machine's
// hidden stamps on anchored tooltips while the source
// declares authored visibility, so diffing them as-is
// reported a spurious visibility invalidation (a dirty
// repaint region for chrome that never changed) on EVERY
// rebuild containing a hidden anchored tooltip. The stamp
// uses the prune VERDICT against the reconciled tree — the
// shown id adoption will actually keep — WITHOUT mutating
// the live intent registers yet: everything between here
// and a retained tree is fallible (`diffWithTokens` can
// overflow its invalidation scratch, and
// `copyWidgetLayoutTree` rejects node/anchored-surface
// limits and every retained-pool budget before it resets
// the pools), and a register prune applied ahead of a
// failure left the OLD tree stamped visible with cleared
// registers — a tooltip no transition could ever hide
// again. The register mutation lands inside
// `copyWidgetLayoutTree`'s own prune, after the fallible
// steps succeed, where it applies this same verdict; an
// unchanged rebuild still diffs clean, a SHOWN tooltip
// stays visibly shown across the rebuild (no hide-then-show
// frame pair), and a rebuild that breaks the shown binding
// still diffs the hide honestly.
const prospective_shown_tooltip_id = self.views[index].canvasTooltipShownIdSurvivingLayout(reconciled_layout);
// Pre-adoption tooltip bindings for the standing hover and
// keyboard focus-visible owners, captured while the OLD
// tree is still retained (the copy below replaces it): the
// adoption reconcile at the bottom compares them against
// the adopted tree to catch a tooltip mounted/replaced/
// rekeyed beneath a stable owner — a change neither the
// hover re-hit-test nor the register prune can see.
const adoption_tooltip_bindings = CanvasWidgetEventMethods(Runtime).captureCanvasTooltipAdoptionBindings(self, index);
self.views[index].applyCanvasTooltipVisibilityToNodesForShownId(reconciled_nodes[0..reconciled_layout.nodes.len], prospective_shown_tooltip_id);
const invalidations = try canvas.WidgetLayoutTree.diffWithTokens(previous_layout, reconciled_layout, tokens, &self.canvas_widget_invalidations_scratch);
const previous_render_state = self.views[index].canvasWidgetRenderState();
const next_render_state = CanvasWidgetEventMethods(Runtime).canvasWidgetRenderStateAfterLayout(previous_render_state, reconciled_layout);
@@ -152,6 +186,16 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type {
// the user was looking at, and the tween walks it to the
// declared pose one presented frame at a time.
try applyCanvasWidgetDisclosureTweenPlan(self, index, disclosure_plan);
// Re-hit-test the stationary pointer against the ADOPTED
// tree, after every pose restore above settles the frames
// the user actually sees and BEFORE the display refresh
// publishes them: the prune at the top validates only
// tooltip/owner identity and hover survives by ID, so a
// rebuild that MOVES a same-ID trigger away from (or
// under) the stationary pointer must step hover ownership
// and the tooltip intent machine exactly like the
// point-blind scroll paths do.
try CanvasWidgetEventMethods(Runtime).reconcileCanvasWidgetInteractionAfterLayoutAdoption(self, index, adoption_tooltip_bindings);
const requested_frame = try CanvasWidgetDisplayMethods(Runtime).refreshCanvasWidgetDisplayListIfOwned(self, index);
if ((layout_dirty or widget_revision_changed) and !requested_frame) try CanvasFrameMethods(Runtime).requestCanvasFrameForView(self, index);
return self.views[index].info();
@@ -266,7 +310,7 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type {
const dirty = try self.views[index].stepCanvasWidgetKineticScroll(dt_ms) orelse return self.views[index].info();
const previous_cursor = self.views[index].canvas_widget_cursor;
self.views[index].reconcileCanvasWidgetRenderStateAfterScroll(null);
try CanvasWidgetEventMethods(Runtime).reconcileCanvasWidgetRenderStateAfterScrollWithTooltipIntent(self, index, null);
if (previous_cursor != self.views[index].canvas_widget_cursor) try CanvasWidgetEventMethods(Runtime).syncCanvasWidgetCursorForView(self, index);
try CanvasWidgetEventMethods(Runtime).invalidateForCanvasWidgetDirty(self, index, dirty);
_ = try CanvasWidgetDisplayMethods(Runtime).refreshCanvasWidgetDisplayListIfOwned(self, index);
+13
View File
@@ -222,6 +222,18 @@ pub const Runtime = struct {
next_window_id: platform.WindowId = 2,
next_view_id: platform.ViewId = 1,
invalidated: bool = true,
/// Whether the platform currently reports the app ACTIVE (frontmost
/// on macOS — the state the `.app_activated`/`.app_deactivated`
/// lifecycle events flip). Stamped in the dispatch loop from those
/// journaled events, so replay reproduces it deterministically.
/// Defaults true: a launching app is treated as active until the
/// platform says otherwise, because some hosts deliver input before
/// the first activation event and suppressing intent until one
/// arrived would deaden hover on hosts that never send it. The
/// tooltip intent machine consults this: an INACTIVE app reveals
/// and arms nothing — including a rebuild the app performs from its
/// own deactivation callback.
app_active: bool = true,
/// Whether the app's stop hook (`App.stop`) has been delivered.
/// Normally the platform's `.app_shutdown` event delivers it; the
/// run loop's exit path checks this flag and delivers a missed stop
@@ -743,6 +755,7 @@ pub const Runtime = struct {
const refreshCanvasWidgetDisplayList = CanvasWidgetDisplayMethods.refreshCanvasWidgetDisplayList;
const CanvasWidgetEventMethods = runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(Runtime);
pub const advanceCanvasTooltipIntentForFrame = CanvasWidgetEventMethods.advanceCanvasTooltipIntentForFrame;
pub const routeCanvasWidgetPointerInput = CanvasWidgetEventMethods.routeCanvasWidgetPointerInput;
pub const routeCanvasWidgetKeyboardInput = CanvasWidgetEventMethods.routeCanvasWidgetKeyboardInput;
pub const routeCanvasWidgetTextInput = CanvasWidgetEventMethods.routeCanvasWidgetTextInput;
+24 -1
View File
@@ -12,6 +12,7 @@ const launch_timing = @import("launch_timing.zig");
const runtime_clock = @import("clock.zig");
const shell_layout = @import("shell_layout.zig");
const runtime_builtin_bridge = @import("builtin_bridge.zig");
const runtime_canvas_widget_events = @import("canvas_widget_events.zig");
const runtime_canvas_widget_context_menu = @import("canvas_widget_context_menu.zig");
const runtime_canvas_widget_scroll_drivers = @import("canvas_widget_scroll_drivers.zig");
const runtime_gpu_surface_events = @import("gpu_surface_events.zig");
@@ -76,6 +77,10 @@ pub fn RuntimeFlow(comptime Runtime: type) type {
return runtime_canvas_widget_context_menu.RuntimeCanvasWidgetContextMenu(Runtime);
}
fn CanvasWidgetEventMethods() type {
return runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(Runtime);
}
fn AutomationWidgetMethods() type {
return runtime_automation_widget_dispatch.RuntimeAutomationWidgetDispatch(Runtime);
}
@@ -226,10 +231,25 @@ pub fn RuntimeFlow(comptime Runtime: type) type {
log(self, "app.start", "app started", &.{trace.string("app", app.name)});
},
.app_activated => {
self.app_active = true;
try dispatchEvent(self, app, .{ .lifecycle = .activate });
emitAppLifecycleEvent(self, "app:activate") catch |err| log(self, "app.activate.emit_failed", @errorName(err), &.{});
},
.app_deactivated => {
// The app-active register flips FIRST — before the
// tooltip reset and before the app hears the
// lifecycle event — so a model that rebuilds FROM
// its deactivation callback adopts under a runtime
// that already knows the app is inactive: the
// adoption arm's reveal/arm paths are gated on this
// register and stay silent.
self.app_active = false;
// Deactivation drops every tooltip conversation in
// every window (see the seam's own rationale) BEFORE
// the app hears the lifecycle event, so a model that
// rebuilds on deactivate adopts against clean
// tooltip state instead of a stale shown slot.
try CanvasWidgetEventMethods().resetCanvasTooltipIntentForAppDeactivation(self);
try dispatchEvent(self, app, .{ .lifecycle = .deactivate });
emitAppLifecycleEvent(self, "app:deactivate") catch |err| log(self, "app.deactivate.emit_failed", @errorName(err), &.{});
},
@@ -305,7 +325,10 @@ pub fn RuntimeFlow(comptime Runtime: type) type {
}
},
.window_focused => |window_id| {
if (WindowViewMethods().findWindowIndexById(self, window_id)) |index| WindowViewMethods().setFocusedIndex(self, index);
// setFocusedIndex is the window-key seam: the
// window LOSING key here also drops its views'
// tooltip conversations there.
if (WindowViewMethods().findWindowIndexById(self, window_id)) |index| try WindowViewMethods().setFocusedIndex(self, index);
self.invalidated = true;
},
.frame_requested => try frame(self, app),
+37 -1
View File
@@ -61,6 +61,10 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
// so accordion reveals replay frame for frame exactly
// like split fractions do.
try self.advanceCanvasWidgetDisclosureTweenForFrame(index, frame_event.timestamp_ns);
// The anchored-tooltip hover-intent delay fires on the
// same recorded clock: a dwell past the delay shows its
// tooltip on a deterministic frame, replayed exactly.
try self.advanceCanvasTooltipIntentForFrame(index, frame_event.timestamp_ns);
try dispatchPendingCanvasWidgetScrollEvents(self, app, index);
// A settling tween notes its ONE split-resize event with
// no input in flight; drain it here so the controlled
@@ -180,6 +184,16 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
if (runtimeFindViewIndex(self, input_event.window_id, input_event.label)) |index| {
self.views[index].recordGpuSurfaceInputTimestamp(input_event.timestamp_ns);
}
// The whole consumed stream still feeds the tooltip
// intent choke point: every pointer-carrying event
// updates the stored position (a later point-blind
// reconcile must hit-test where the pointer really is),
// the secondary down resets the machine before the menu
// presents ("pointer-down dismisses" holds for EVERY
// button — no tooltip floats behind or over the native
// menu), and a consumed cancel is still the pointer
// leaving the view.
try CanvasWidgetEventMethods().reconcileCanvasTooltipIntentForConsumedPointerInput(self, input_event);
if (input_event.kind == .pointer_down) {
try setFocusedView(self, input_event.window_id, input_event.label);
self.invalidated = true;
@@ -244,7 +258,17 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
// click on the native titlebar. Dismissal above still ran:
// clicking the header closes an open surface first.
window_drag_started = try CanvasWidgetEventMethods().startCanvasWidgetWindowDragFromPointer(self, input_event, pointer_event.*);
if (!window_drag_started) {
if (window_drag_started) {
// The drag consumed the down, but "pointer-down
// dismisses" still holds — and the down still
// carried a position the intent machine must
// record: the OS owning the pointer from here must
// not strand an armed or shown tooltip (the
// matching up may never arrive), and a later
// point-blind reconcile must hit-test where the
// pointer really went down.
try CanvasWidgetEventMethods().reconcileCanvasTooltipIntentForConsumedPointerInput(self, input_event);
} else {
try CanvasWidgetEventMethods().updateCanvasWidgetControlFromPointer(self, pointer_event.*);
try CanvasWidgetEventMethods().updateCanvasWidgetInteractionFromPointer(self, pointer_event.*);
// The text pass may stamp a clear edit onto the
@@ -301,6 +325,11 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type {
// the event (Escape's clear included), so the app dispatch
// below hears exactly the edit the retained editor performed.
if (widget_keyboard_event) |*keyboard_event| {
// Keyboard activation counts as a press for tooltips:
// Space/Enter on the focused trigger dismisses its
// armed/shown tooltip before the control mutation and
// app dispatch observe the input.
try CanvasWidgetEventMethods().updateCanvasTooltipIntentForKeyboardActivation(self, keyboard_event.*);
try CanvasWidgetEventMethods().updateCanvasWidgetControlFromKeyboard(self, keyboard_event.*);
try CanvasWidgetEventMethods().updateCanvasWidgetTextFromKeyboard(self, keyboard_event);
}
@@ -612,11 +641,18 @@ fn setFocusedView(self: anytype, window_id: platform.WindowId, label: []const u8
for (self.views[0..self.view_count], 0..) |*view, view_index| {
if (view.window_id != window_id) continue;
const previous_state = view.canvasWidgetRenderState();
const was_focused = view.focused;
view.focused = std.mem.eql(u8, view.label, label);
const next_state = view.canvasWidgetRenderState();
if (!runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(@TypeOf(self.*)).canvasWidgetRenderStatesEqual(previous_state, next_state)) {
try runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(@TypeOf(self.*)).invalidateForCanvasWidgetRenderStateChange(self, view_index, previous_state, next_state);
}
// A view losing focus drops its tooltip state and re-stamps
// hidden — input landing in a sibling view must not leave the
// blurred view's tooltip floating.
if (was_focused and !view.focused) {
try runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(@TypeOf(self.*)).resetCanvasTooltipIntentForViewBlur(self, view_index);
}
}
for (self.webviews[0..self.webview_count]) |*webview| {
if (webview.window_id == window_id) webview.focused = std.mem.eql(u8, webview.label, label);
+86
View File
@@ -425,8 +425,85 @@ pub const RuntimeView = struct {
scroll_driver_count: usize = 0,
canvas_widget_focused_id: canvas.ObjectId = 0,
canvas_widget_focus_visible_id: canvas.ObjectId = 0,
/// True when `canvas_widget_focus_visible_id` was written by the
/// KEYBOARD focus contract (`setCanvasWidgetFocusFromKeyboard`) —
/// the one focus path that reveals tooltips. Every other
/// focus-visible writer stamps false: pointer focus on editables
/// (the caret contract), programmatic/automation focus, the
/// automation key escalation on plain list rows, and the rebuild
/// and dismissal focus-return seams — all of which deliberately
/// skip the reveal (Base UI's focus-visible guard against
/// click-focus opens, and the reveals-are-transition-edges rule).
/// The layout-adoption reconcile reads this to decide whether a
/// tooltip newly bound beneath the standing focus-visible owner
/// inherits the keyboard's immediate reveal or must wait for the
/// next real focus arrival.
canvas_widget_focus_visible_keyboard: bool = false,
canvas_widget_hovered_id: canvas.ObjectId = 0,
canvas_widget_pressed_id: canvas.ObjectId = 0,
/// Hover-intent state for ANCHORED tooltips — runtime-owned
/// presentation chrome; the model never hears hover. `armed` is
/// the tooltip whose trigger is hovered while its show delay runs;
/// `shown` is the tooltip currently painted; `warm_until` is the
/// shared warm window after a pointer-hovered tooltip hides on
/// pointer leave (reaching another
/// trigger before it passes shows that tooltip immediately, the
/// dense-toolbar polish). Every timestamp lives on the RECORDED
/// input/frame clock (`canvasRenderAnimationStartNsForView` at
/// pointer dispatch, `GpuSurfaceFrameEvent.timestamp_ns` at frame
/// advance — never a wall clock), so a recorded hover-dwell
/// session replays its tooltip show/hide frames byte-identically.
canvas_tooltip_armed_id: canvas.ObjectId = 0,
canvas_tooltip_deadline_ns: u64 = 0,
canvas_tooltip_shown_id: canvas.ObjectId = 0,
canvas_tooltip_warm_until_ns: u64 = 0,
/// The TRIGGER each intent slot was earned through (the hover
/// target that armed the delay / the widget whose hover or
/// focus-visible showed the tooltip). Ownership is a live claim,
/// not a memento: a rebuild that removes, rekeys, disables, or
/// re-parents the owner invalidates the slot in
/// `pruneCanvasTooltipIntent` — the tooltip node alone surviving
/// is not enough to keep explaining a control that no longer
/// exists.
canvas_tooltip_armed_owner_id: canvas.ObjectId = 0,
canvas_tooltip_shown_owner_id: canvas.ObjectId = 0,
/// The pointer's last position while it held the shown tooltip —
/// on the owning trigger (seeded at arm/show) or inside the shown
/// tooltip's own frame. It is the apex of the transit corridor
/// (`canvasTooltipTravelRegionContains`) a leave-move is judged
/// against, so the corridor always fans out from where the pointer
/// actually left. Meaningful only while a pointer-shown tooltip is
/// up; every value it is compared against comes from journaled
/// pointer events, so replay sees identical corridors.
canvas_tooltip_pointer_from: geometry.PointF = geometry.PointF.zero(),
/// Nonzero while the pointer has left both the trigger and the
/// shown tooltip's frame but is still inside the transit corridor
/// between them: the deadline (recorded clock) by which it must
/// arrive. Each in-corridor move re-arms it, so slow deliberate
/// transits stay open (WCAG 1.4.13 hoverable content) while a
/// pointer that parks in the gap resolves deterministically on the
/// frame clock. 0 means no transit is in flight.
canvas_tooltip_transit_deadline_ns: u64 = 0,
/// True when the shown tooltip was revealed by keyboard
/// focus-visible rather than pointer hover. Focus-shown tooltips
/// follow shadcn's Base UI-backed defaults: they open instantly on
/// focus-visible, hide on blur, ignore pointer hover leaving OTHER
/// triggers, and never open the pointer's warm window when they
/// hide (deliberate keyboard motion is not a pointer sweep).
canvas_tooltip_shown_from_focus: bool = false,
/// The pointer's last JOURNALED position over this view — every
/// phase that carries a trustworthy point (hover, move, down, up,
/// wheel) updates it; null before the first pointer event and after
/// a `.cancel` (the pointer-exit AppKit/GTK/Win32 hosts emit, and
/// gesture cancels). Point-blind scroll reconciliation (kinetic
/// steps, native drivers, keyboard scrolling) re-hit-tests this
/// position against the post-scroll tree — the pointer did not
/// move, so where it last stood is where it still is — and a null
/// here means those paths must CLOSE pointer tooltip intent rather
/// than guess (see
/// reconcileCanvasWidgetRenderStateAfterScrollWithTooltipIntent).
/// Journaled input only, so replay sees identical positions.
canvas_last_pointer_position: ?geometry.PointF = null,
/// Pointer position while the hovered widget draws hover-detail
/// chrome (a `.chart` with hover details opted in); null everywhere
/// else. Feeds `WidgetRenderState.hover_point`, so the display list
@@ -612,6 +689,15 @@ pub const RuntimeView = struct {
pub const canvasWidgetDismissibleSurfaceIndexForTarget = CanvasWidgetTreeMethods.canvasWidgetDismissibleSurfaceIndexForTarget;
pub const canvasWidgetAnchoredDismissibleChildIndex = CanvasWidgetTreeMethods.canvasWidgetAnchoredDismissibleChildIndex;
pub const canvasWidgetOwnedMenuSurfaceIndex = CanvasWidgetTreeMethods.canvasWidgetOwnedMenuSurfaceIndex;
pub const canvasWidgetOwnedTooltipIndex = CanvasWidgetTreeMethods.canvasWidgetOwnedTooltipIndex;
pub const canvasWidgetOwnedTooltipIdForOwner = CanvasWidgetTreeMethods.canvasWidgetOwnedTooltipIdForOwner;
pub const applyCanvasTooltipVisibility = CanvasWidgetTreeMethods.applyCanvasTooltipVisibility;
pub const applyCanvasTooltipVisibilityToNodes = CanvasWidgetTreeMethods.applyCanvasTooltipVisibilityToNodes;
pub const applyCanvasTooltipVisibilityToNodesForShownId = CanvasWidgetTreeMethods.applyCanvasTooltipVisibilityToNodesForShownId;
pub const pruneCanvasTooltipIntent = CanvasWidgetTreeMethods.pruneCanvasTooltipIntent;
pub const pruneCanvasTooltipIntentForLayout = CanvasWidgetTreeMethods.pruneCanvasTooltipIntentForLayout;
pub const canvasTooltipShownIdSurvivingLayout = CanvasWidgetTreeMethods.canvasTooltipShownIdSurvivingLayout;
pub const canvasTooltipIntentArmed = CanvasWidgetTreeMethods.canvasTooltipIntentArmed;
pub const canvasWidgetMenuSurfaceEntryId = CanvasWidgetTreeMethods.canvasWidgetMenuSurfaceEntryId;
pub const canvasWidgetAnchorTriggerFocusId = CanvasWidgetTreeMethods.canvasWidgetAnchorTriggerFocusId;
pub const canvasWidgetTopmostAnchoredDismissibleIndex = CanvasWidgetTreeMethods.canvasWidgetTopmostAnchoredDismissibleIndex;
+339 -20
View File
@@ -295,6 +295,14 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
self.widget_tokens,
);
// Anchored-tooltip hover intent survives the rebuild the way
// hover/press ids do: drop state whose tooltip unmounted,
// then re-stamp runtime-owned visibility BEFORE semantics
// collect, so the a11y snapshot (and the replay fingerprint
// riding it) always reflects the intent machine.
self.pruneCanvasTooltipIntent();
self.applyCanvasTooltipVisibility();
const semantics = try self.widgetLayoutTree().collectSemantics(&self.widget_semantics_nodes);
applyCanvasWidgetSourceScrollSemantics(self.widget_semantics_nodes[0..semantics.len], &index_scratch.semantics);
self.widget_semantics_node_count = semantics.len;
@@ -302,9 +310,15 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
const return_id = if (focus_return_id != 0 and self.widgetLayoutTree().focusTargetById(focus_return_id) != null) focus_return_id else 0;
self.canvas_widget_focused_id = return_id;
self.canvas_widget_focus_visible_id = return_id;
// The focus-return ring is not a keyboard ARRIVAL:
// reveals fire only on focus-visible transitions (the
// dismissal seam's rule), so the returned ring never
// grants adoption-time tooltip reveals either.
self.canvas_widget_focus_visible_keyboard = false;
}
if (self.canvas_widget_focus_visible_id != 0 and (self.canvas_widget_focus_visible_id != self.canvas_widget_focused_id or self.widgetLayoutTree().focusTargetById(self.canvas_widget_focus_visible_id) == null)) {
self.canvas_widget_focus_visible_id = 0;
self.canvas_widget_focus_visible_keyboard = false;
}
if (self.canvas_widget_hovered_id != 0 and !canvasWidgetInteractionTargetExists(self.widgetLayoutTree(), self.canvas_widget_hovered_id)) {
self.canvas_widget_hovered_id = 0;
@@ -367,9 +381,11 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
if (self.canvas_widget_focused_id != 0 and layout.focusTargetById(self.canvas_widget_focused_id) == null) {
self.canvas_widget_focused_id = 0;
self.canvas_widget_focus_visible_id = 0;
self.canvas_widget_focus_visible_keyboard = false;
}
if (self.canvas_widget_focus_visible_id != 0 and (self.canvas_widget_focus_visible_id != self.canvas_widget_focused_id or layout.focusTargetById(self.canvas_widget_focus_visible_id) == null)) {
self.canvas_widget_focus_visible_id = 0;
self.canvas_widget_focus_visible_keyboard = false;
}
var next_hovered_id = self.canvas_widget_hovered_id;
@@ -407,6 +423,10 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// would leave Escape dead. A focused editable with live IME
/// composition always wins: Escape cancels the composition and
/// never dismisses a surface, not even through the fallback.
/// With SEVERAL surfaces anchored on one stack (the select
/// trigger's focus-shown tooltip floating over its open menu),
/// each Escape peels exactly one, topmost (last-mounted) first —
/// the tooltip goes, then the menu.
pub fn dismissCanvasWidgetSurfaceFromEscape(self: *RuntimeView, focused_id: canvas.ObjectId) anyerror!?CanvasWidgetSurfaceDismissal {
if (focused_id != 0) {
if (self.canvasWidgetNodeIndexById(focused_id)) |focused_index| {
@@ -424,12 +444,14 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// transient choice, so moving the keyboard on closes it WITHOUT
/// committing, exactly like a click outside. Scoped to menu
/// surfaces only: Tab through a persistent popover's form fields
/// must not tear the popover down.
/// must not tear the popover down. The lookup scans FOR menu
/// kinds rather than kind-checking whatever floats topmost — a
/// trigger can anchor a tooltip beside its menu, and the
/// focus-visible tooltip shadowing the open menu left Tab unable
/// to close it.
pub fn dismissCanvasWidgetMenuSurfaceForFocusDeparture(self: *RuntimeView, focused_id: canvas.ObjectId) anyerror!?CanvasWidgetSurfaceDismissal {
const focused_index = self.canvasWidgetNodeIndexById(focused_id) orelse return null;
const surface_index = self.canvasWidgetDismissibleSurfaceIndexForTarget(focused_index) orelse return null;
const kind = self.widget_layout_nodes[surface_index].widget.kind;
if (kind != .menu_surface and kind != .dropdown_menu) return null;
const surface_index = canvasWidgetSurfaceIndexForTargetInScope(self, focused_index, .menu) orelse return null;
return self.dismissCanvasWidgetSurfaceAtIndex(surface_index);
}
@@ -443,9 +465,16 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
return self.dismissCanvasWidgetSurfaceAtIndex(surface_index);
}
/// Outside-click light dismissal targets INTERACTIVE surfaces
/// only. A tooltip's whole lifecycle already belongs to the
/// intent machine's own causes on any outside down — hover
/// leave, focus moving with the click, the press itself — so
/// letting the topmost tooltip absorb this gesture would both
/// double-cover those and leave the menu beneath it floating
/// after the user clicked away.
pub fn dismissCanvasWidgetSurfaceForPointerOutsideFocusedTarget(self: *RuntimeView, focused_id: canvas.ObjectId, route: []const canvas.WidgetEventRouteEntry) anyerror!?CanvasWidgetSurfaceDismissal {
const focused_index = self.canvasWidgetNodeIndexById(focused_id) orelse return null;
const surface_index = self.canvasWidgetDismissibleSurfaceIndexForTarget(focused_index) orelse return null;
const surface_index = canvasWidgetSurfaceIndexForTargetInScope(self, focused_index, .interactive) orelse return null;
if (self.canvasWidgetRouteDescendsFromIndex(route, surface_index)) return null;
// Clicking the ANCHOR region of an anchored surface (the
// trigger, or the stack that wraps trigger + surface) is the
@@ -466,6 +495,46 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
if (surface.semantics.hidden) return null;
const dirty = self.canvasWidgetDirtyBounds(surface_index, surface.frame) orelse surface.frame;
self.widget_layout_nodes[surface_index].widget.semantics.hidden = true;
// A dismissed anchored tooltip leaves the intent machine too,
// or the next visibility stamp would undo the dismissal. No
// warm window: Escape is a deliberate dismissal, not the
// pointer moving on to the next trigger.
if (surface.kind == .tooltip) {
if (self.canvas_tooltip_shown_id == surface.id) {
// Covers the focus-shown path too: Escape on the
// focused trigger clears the reason flag with the
// slot, and focus (still on the trigger) does not
// re-reveal — reveals fire only on focus-visible
// TRANSITIONS, so tabbing away and back re-earns it.
// The one non-transition reveal — the adoption
// binding-reconcile, which honors a STANDING
// keyboard ring when a rebuild swaps the tooltip it
// owns — is blocked by consuming that standing
// intent here: when the ring rests on the dismissed
// tooltip's owner, the dismissal spends
// `canvas_widget_focus_visible_keyboard` (its only
// readers are the tooltip reveal gates; the ring
// itself renders from `canvas_widget_focus_visible_id`
// and stays painted), so a rebuild that rekeys the
// tooltip cannot resurrect it one frame after
// Escape. Same design as the keyboard-activation
// dismissal seam in canvas_widget_events.zig.
if (self.canvas_widget_focus_visible_id != 0 and
self.canvas_widget_focus_visible_id == self.canvas_tooltip_shown_owner_id)
{
self.canvas_widget_focus_visible_keyboard = false;
}
self.canvas_tooltip_shown_id = 0;
self.canvas_tooltip_shown_owner_id = 0;
self.canvas_tooltip_transit_deadline_ns = 0;
self.canvas_tooltip_shown_from_focus = false;
}
if (self.canvas_tooltip_armed_id == surface.id) {
self.canvas_tooltip_armed_id = 0;
self.canvas_tooltip_armed_owner_id = 0;
self.canvas_tooltip_deadline_ns = 0;
}
}
if (self.canvasWidgetIdDescendsFromIndex(self.canvas_widget_focused_id, surface_index)) {
// A dismissal that swallows the focus returns it to the
// surface's own trigger when the surface is anchored (the
@@ -474,8 +543,15 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
const return_id = self.canvasWidgetAnchorTriggerFocusId(surface_index) orelse 0;
self.canvas_widget_focused_id = return_id;
self.canvas_widget_focus_visible_id = return_id;
// Same rule as the rebuild's focus return: a returned
// ring is not a keyboard arrival, so it earns no
// reveal — here or at a later layout adoption.
self.canvas_widget_focus_visible_keyboard = false;
}
if (self.canvasWidgetIdDescendsFromIndex(self.canvas_widget_focus_visible_id, surface_index)) {
self.canvas_widget_focus_visible_id = 0;
self.canvas_widget_focus_visible_keyboard = false;
}
if (self.canvasWidgetIdDescendsFromIndex(self.canvas_widget_focus_visible_id, surface_index)) self.canvas_widget_focus_visible_id = 0;
if (self.canvasWidgetIdDescendsFromIndex(self.canvas_widget_hovered_id, surface_index)) {
self.canvas_widget_hovered_id = 0;
self.canvas_widget_cursor = .arrow;
@@ -487,20 +563,59 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
return .{ .id = surface.id, .dirty = dirty };
}
/// Which anchored dismissible surfaces a lookup means to see. A
/// widget stack can anchor SEVERAL surfaces at once (a select
/// trigger with both its dropdown-menu and a tooltip), so every
/// consumer names the population it is really after — grabbing
/// "the anchored child" and kind-checking the winner let a
/// focus-visible tooltip shadow the open menu mounted before it.
pub const CanvasWidgetAnchoredSurfaceScope = enum {
/// Every dismissible surface, tooltips included. Escape and
/// the automation/accessibility dismiss actions peel
/// whatever floats TOPMOST, one surface per gesture.
any,
/// Surfaces that can hold interaction and keyboard focus —
/// everything but tooltips, which are hover chrome the
/// intent machine owns: outside-click dismissal and focus
/// scoping.
interactive,
/// Menu surfaces only (`menu_surface`/`dropdown_menu`): the
/// open-select keymap and Tab's focus-departure close.
menu,
};
fn canvasWidgetAnchoredSurfaceKindInScope(kind: canvas.WidgetKind, comptime scope: CanvasWidgetAnchoredSurfaceScope) bool {
if (!canvasWidgetDismissibleSurfaceKind(kind)) return false;
return switch (scope) {
.any => true,
.interactive => kind != .tooltip,
.menu => kind == .menu_surface or kind == .dropdown_menu,
};
}
pub fn canvasWidgetDismissibleSurfaceIndexForTarget(self: *const RuntimeView, target_index: usize) ?usize {
return canvasWidgetSurfaceIndexForTargetInScope(self, target_index, .any);
}
/// The nearest in-scope surface up the target's chain: the first
/// visible dismissible ancestor, or an in-scope anchored surface
/// HANGING OFF an ancestor (the ancestor is its anchor) — Escape
/// on the focused trigger, or on the stack wrapping trigger +
/// surface, closes its own menu even though the surface is a
/// descendant, not an ancestor, of the focus. A visible ancestor
/// surface OUTSIDE the scope shields rather than defers: the
/// keyboard living in a persistent popover must not reach past
/// it to a menu further out.
fn canvasWidgetSurfaceIndexForTargetInScope(self: *const RuntimeView, target_index: usize, comptime scope: CanvasWidgetAnchoredSurfaceScope) ?usize {
if (target_index >= self.widget_layout_node_count) return null;
var current: ?usize = target_index;
while (current) |index| {
if (index >= self.widget_layout_node_count) return null;
const widget = self.widget_layout_nodes[index].widget;
if (canvasWidgetDismissibleSurfaceKind(widget.kind) and !widget.semantics.hidden) return index;
// An anchored dismissible surface HANGING OFF this
// ancestor (the ancestor is its anchor) is the nearest
// floating surface: Escape on the focused trigger — or on
// the stack wrapping trigger + surface — closes its own
// menu even though the surface is a descendant, not an
// ancestor, of the focus.
if (self.canvasWidgetAnchoredDismissibleChildIndex(index)) |surface_index| return surface_index;
if (canvasWidgetDismissibleSurfaceKind(widget.kind) and !widget.semantics.hidden) {
return if (canvasWidgetAnchoredSurfaceKindInScope(widget.kind, scope)) index else null;
}
if (canvasWidgetAnchoredChildIndexInScope(self, index, scope)) |surface_index| return surface_index;
current = self.widget_layout_nodes[index].parent_index;
}
return null;
@@ -509,11 +624,19 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
/// The topmost (last-mounted) visible anchored dismissible surface
/// whose anchor is `anchor_index`, or null.
pub fn canvasWidgetAnchoredDismissibleChildIndex(self: *const RuntimeView, anchor_index: usize) ?usize {
return canvasWidgetAnchoredChildIndexInScope(self, anchor_index, .any);
}
/// The topmost visible anchored child of `anchor_index` whose
/// kind is IN SCOPE — the scope filters DURING the scan, so an
/// out-of-scope sibling mounted later (the tooltip above the
/// menu) never masks the surface the caller asked for.
fn canvasWidgetAnchoredChildIndexInScope(self: *const RuntimeView, anchor_index: usize, comptime scope: CanvasWidgetAnchoredSurfaceScope) ?usize {
var found: ?usize = null;
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |node, index| {
if (node.parent_index != anchor_index) continue;
if (!canvas.widgetIsAnchored(node.widget)) continue;
if (!canvasWidgetDismissibleSurfaceKind(node.widget.kind)) continue;
if (!canvasWidgetAnchoredSurfaceKindInScope(node.widget.kind, scope)) continue;
if (node.widget.semantics.hidden) continue;
found = index;
}
@@ -533,10 +656,164 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
}
fn canvasWidgetAnchoredMenuChildIndex(self: *const RuntimeView, anchor_index: usize) ?usize {
const surface_index = self.canvasWidgetAnchoredDismissibleChildIndex(anchor_index) orelse return null;
const kind = self.widget_layout_nodes[surface_index].widget.kind;
if (kind != .menu_surface and kind != .dropdown_menu) return null;
return surface_index;
return canvasWidgetAnchoredChildIndexInScope(self, anchor_index, .menu);
}
/// The anchored tooltip a trigger owns: the last-mounted anchored
/// `.tooltip` child of the trigger itself or of its parent — the
/// stack wrapping trigger + tooltip, mirroring the anchored-menu
/// ownership shape. Deliberately IGNORES the hidden flag: the
/// runtime itself stamps non-shown anchored tooltips hidden, and
/// arming must find them to show them.
pub fn canvasWidgetOwnedTooltipIndex(self: *const RuntimeView, trigger_index: usize) ?usize {
return canvasWidgetOwnedTooltipIndexInNodes(self.widget_layout_nodes[0..self.widget_layout_node_count], trigger_index);
}
/// The ID of the anchored tooltip `owner_id` owns in the
/// CURRENT retained tree, or 0 (no such owner / no owned
/// tooltip). The layout-adoption reconcile compares this value
/// across a rebuild — captured against the outgoing tree in
/// `setCanvasWidgetLayout`, re-read against the adopted one —
/// to see a binding that changed beneath a STABLE owner: a
/// tooltip mounted, replaced, rekeyed, or reparented under a
/// trigger whose own ID survived produces no hover or focus
/// delta, so only this comparison can arm (or reveal) it.
pub fn canvasWidgetOwnedTooltipIdForOwner(self: *const RuntimeView, owner_id: canvas.ObjectId) canvas.ObjectId {
if (owner_id == 0) return 0;
const owner_index = self.canvasWidgetNodeIndexById(owner_id) orelse return 0;
const tooltip_index = self.canvasWidgetOwnedTooltipIndex(owner_index) orelse return 0;
return self.widget_layout_nodes[tooltip_index].widget.id;
}
/// Stamp hover-intent visibility onto every ANCHORED tooltip
/// node: hidden unless it is the intent machine's shown tooltip.
/// Anchored tooltips are runtime-owned chrome, so the stamp
/// overrides authored visibility; static (non-anchored) tooltips
/// are never touched. Runs at tree adoption (each rebuild) and
/// after every intent transition.
pub fn applyCanvasTooltipVisibility(self: *RuntimeView) void {
self.applyCanvasTooltipVisibilityToNodes(self.widget_layout_nodes[0..self.widget_layout_node_count]);
}
/// The same stamp over an arbitrary node slice, against the
/// view's LIVE shown register.
pub fn applyCanvasTooltipVisibilityToNodes(self: *const RuntimeView, nodes: []canvas.WidgetLayoutNode) void {
applyCanvasTooltipVisibilityToNodesForShownId(self, nodes, self.canvas_tooltip_shown_id);
}
/// The stamp against an EXPLICIT shown id. The rebuild path
/// normalizes the RECONCILED scratch tree with it BEFORE diffing
/// against the retained tree (see `setCanvasWidgetLayout`),
/// passing the PROSPECTIVE prune verdict from
/// `canvasTooltipShownIdSurvivingLayout` rather than mutating
/// the live registers first: the retained side carries the
/// intent machine's hidden stamps while the source declares
/// authored visibility, so diffing them un-normalized reported
/// the runtime's own stamp as a spurious visibility
/// invalidation on every rebuild that contained a hidden
/// anchored tooltip — and mutating the registers before the
/// fallible adoption steps left a FAILED adoption with the old
/// tree stamped visible and cleared registers (an unhideable
/// tooltip).
pub fn applyCanvasTooltipVisibilityToNodesForShownId(self: *const RuntimeView, nodes: []canvas.WidgetLayoutNode, shown_id: canvas.ObjectId) void {
_ = self;
for (nodes) |*node| {
if (node.widget.kind != .tooltip) continue;
if (!canvas.widgetIsAnchored(node.widget)) continue;
node.widget.semantics.hidden = node.widget.id == 0 or node.widget.id != shown_id;
}
}
/// Drop intent state whose tooltip OR owning trigger left the
/// tree on a rebuild — the interaction-id pruning policy
/// hovered/pressed ids follow, applied to both ends of the
/// tooltip binding. The tooltip node surviving is not enough:
/// a trigger that vanished, was rekeyed (a new id is a new
/// widget), re-parented away from its tooltip, or disabled
/// (disabled widgets leave both hover and focus routing, so
/// nothing could ever hide the tooltip again) invalidates the
/// slot, and the pruned slot re-stamps hidden through the
/// `applyCanvasTooltipVisibility` call that follows adoption.
/// The warm window survives rebuilds that keep the bindings
/// alive (warmth belongs to the pointer, not to any one
/// widget), but a pruned binding closes it: instant re-shows
/// earned against widgets the rebuild replaced are not earned
/// at all.
pub fn pruneCanvasTooltipIntent(self: *RuntimeView) void {
self.pruneCanvasTooltipIntentForLayout(self.widgetLayoutTree());
}
/// The same prune against an arbitrary layout. Runs inside
/// `copyWidgetLayoutTree` against the freshly retained tree —
/// AFTER every fallible adoption step has succeeded, which is
/// what keeps the registers transactional: the rebuild path's
/// pre-diff stamp reads only the VERDICT
/// (`canvasTooltipShownIdSurvivingLayout`) so a failed adoption
/// leaves both the old tree and the registers that can hide its
/// tooltip intact.
pub fn pruneCanvasTooltipIntentForLayout(self: *RuntimeView, layout: canvas.WidgetLayoutTree) void {
if (self.canvas_tooltip_armed_id != 0 and !canvasTooltipIntentBindingAlive(layout, self.canvas_tooltip_armed_id, self.canvas_tooltip_armed_owner_id, false)) {
self.canvas_tooltip_armed_id = 0;
self.canvas_tooltip_armed_owner_id = 0;
self.canvas_tooltip_deadline_ns = 0;
self.canvas_tooltip_warm_until_ns = 0;
}
if (self.canvas_tooltip_shown_id != 0 and !canvasTooltipIntentBindingAlive(layout, self.canvas_tooltip_shown_id, self.canvas_tooltip_shown_owner_id, self.canvas_tooltip_shown_from_focus)) {
self.canvas_tooltip_shown_id = 0;
self.canvas_tooltip_shown_owner_id = 0;
self.canvas_tooltip_shown_from_focus = false;
self.canvas_tooltip_warm_until_ns = 0;
self.canvas_tooltip_transit_deadline_ns = 0;
}
}
/// The shown-tooltip prune VERDICT against a prospective
/// layout, WITHOUT the register mutation: the shown id that
/// would survive adopting `layout`, or 0 when the rebuild kills
/// the binding. `setCanvasWidgetLayout` stamps the reconciled
/// scratch tree with this ahead of the diff (so the diff
/// reports the hide a binding-breaking rebuild really causes,
/// and an unchanged rebuild diffs clean) while the live
/// registers stay untouched until the fallible adoption steps —
/// the diff itself and the retained-pool validation/copy —
/// succeed; `copyWidgetLayoutTree`'s own prune then applies the
/// same verdict for real. A failed adoption therefore leaves
/// the OLD tree with the registers that own its stamps: the
/// tooltip that is still painted is still hideable.
pub fn canvasTooltipShownIdSurvivingLayout(self: *const RuntimeView, layout: canvas.WidgetLayoutTree) canvas.ObjectId {
if (self.canvas_tooltip_shown_id == 0) return 0;
if (!canvasTooltipIntentBindingAlive(layout, self.canvas_tooltip_shown_id, self.canvas_tooltip_shown_owner_id, self.canvas_tooltip_shown_from_focus)) return 0;
return self.canvas_tooltip_shown_id;
}
/// Both ends of a tooltip intent slot are still live: the
/// tooltip node exists and is anchored, its recorded owner
/// still exists AND still resolves to this very tooltip
/// (`canvasWidgetOwnedTooltipIndex`, the inverse of the arming
/// walk), and the owner remains reachable by the routing that
/// earned the slot — focus targeting for focus-shown tooltips,
/// hover/interaction targeting for pointer ones. Both
/// predicates already reject disabled and hidden widgets, so
/// "the trigger can no longer be left" implies "the tooltip
/// must not stay".
fn canvasTooltipIntentBindingAlive(layout: canvas.WidgetLayoutTree, tooltip_id: canvas.ObjectId, owner_id: canvas.ObjectId, from_focus: bool) bool {
const tooltip_index = canvasWidgetNodeIndexByIdInNodes(layout.nodes, tooltip_id) orelse return false;
const tooltip_widget = layout.nodes[tooltip_index].widget;
if (tooltip_widget.kind != .tooltip or !canvas.widgetIsAnchored(tooltip_widget)) return false;
const owner_index = canvasWidgetNodeIndexByIdInNodes(layout.nodes, owner_id) orelse return false;
const owned_index = canvasWidgetOwnedTooltipIndexInNodes(layout.nodes, owner_index) orelse return false;
if (owned_index != tooltip_index) return false;
if (from_focus) return layout.focusTargetById(owner_id) != null;
return canvasWidgetInteractionTargetExists(layout, owner_id);
}
/// True while the intent machine needs presented frames to keep
/// coming: an armed show delay and a running transit grace both
/// fire only on a frame timestamp, so the frame pump
/// re-invalidates until they resolve (the render-animation
/// pump's policy exactly).
pub fn canvasTooltipIntentArmed(self: *const RuntimeView) bool {
return self.canvas_tooltip_armed_id != 0 or self.canvas_tooltip_transit_deadline_ns != 0;
}
/// Keyboard entry point into an anchored menu surface: the marked
@@ -611,9 +888,14 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
return false;
}
/// Tab cycling scopes to the INTERACTIVE surface around the
/// focus: a tooltip can never hold a focus target, so scoping to
/// one (the trigger's visible tooltip shadowing its popover or
/// menu) would always come up empty and spill the keyboard out
/// of the trap into the page's global walk.
pub fn canvasWidgetScopedFocusTarget(self: *const RuntimeView, current_id: canvas.ObjectId, direction: canvas.WidgetFocusDirection) ?canvas.WidgetFocusTarget {
const current_index = self.canvasWidgetNodeIndexById(current_id) orelse return null;
const surface_index = self.canvasWidgetDismissibleSurfaceIndexForTarget(current_index) orelse return null;
const surface_index = canvasWidgetSurfaceIndexForTargetInScope(self, current_index, .interactive) orelse return null;
return self.canvasWidgetFocusTargetInScope(surface_index, current_index, direction);
}
@@ -841,3 +1123,40 @@ pub fn RuntimeViewCanvasWidgetTree(comptime RuntimeView: type) type {
}
};
}
/// Node index by widget id over a bare node slice — the retained
/// tree's `canvasWidgetNodeIndexById` generalized so the tooltip
/// binding checks can run against the RECONCILED scratch tree before
/// adoption (the pre-diff visibility normalization) exactly as they
/// run against the retained one.
fn canvasWidgetNodeIndexByIdInNodes(nodes: []const canvas.WidgetLayoutNode, id: canvas.ObjectId) ?usize {
if (id == 0) return null;
for (nodes, 0..) |node, index| {
if (node.widget.id == id) return index;
}
return null;
}
/// The anchored tooltip a trigger owns, over a bare node slice: the
/// last-mounted anchored `.tooltip` child of the trigger itself or of
/// its parent — the stack-wraps-trigger-plus-tooltip ownership shape,
/// mirroring the anchored-menu walk. Deliberately IGNORES the hidden
/// flag: the runtime itself stamps non-shown anchored tooltips hidden,
/// and arming must find them to show them.
fn canvasWidgetOwnedTooltipIndexInNodes(nodes: []const canvas.WidgetLayoutNode, trigger_index: usize) ?usize {
if (trigger_index >= nodes.len) return null;
if (canvasWidgetAnchoredTooltipChildIndexInNodes(nodes, trigger_index)) |tooltip_index| return tooltip_index;
const parent_index = nodes[trigger_index].parent_index orelse return null;
return canvasWidgetAnchoredTooltipChildIndexInNodes(nodes, parent_index);
}
fn canvasWidgetAnchoredTooltipChildIndexInNodes(nodes: []const canvas.WidgetLayoutNode, anchor_index: usize) ?usize {
var found: ?usize = null;
for (nodes, 0..) |node, index| {
if (node.parent_index != anchor_index) continue;
if (node.widget.kind != .tooltip) continue;
if (!canvas.widgetIsAnchored(node.widget)) continue;
found = index;
}
return found;
}
+61 -15
View File
@@ -3,6 +3,7 @@ const geometry = @import("geometry");
const validation = @import("validation.zig");
const shell_layout = @import("shell_layout.zig");
const runtime_state = @import("state.zig");
const runtime_canvas_widget_events = @import("canvas_widget_events.zig");
const app_manifest = @import("app_manifest");
const platform = @import("../platform/root.zig");
@@ -37,7 +38,7 @@ pub fn RuntimeWindowStorage(comptime Runtime: type) type {
pub fn focusWindow(self: *Runtime, window_id: platform.WindowId) anyerror!void {
const index = Self.findWindowIndexById(self, window_id) orelse return error.WindowNotFound;
try self.options.platform.services.focusWindow(window_id);
Self.setFocusedIndex(self, index);
try Self.setFocusedIndex(self, index);
self.invalidated = true;
}
@@ -78,7 +79,7 @@ pub fn RuntimeWindowStorage(comptime Runtime: type) type {
const window_options = options.windowOptions(id, self.windows[index].info.label);
const native_info = try self.options.platform.services.createWindow(window_options);
native_created = true;
Self.applyNativeInfo(self, index, native_info);
try Self.applyNativeInfo(self, index, native_info);
if (self.windows[index].source) |window_source| {
try self.options.platform.services.loadWindowWebView(id, window_source);
}
@@ -131,30 +132,32 @@ pub fn RuntimeWindowStorage(comptime Runtime: type) type {
return copySourceInto(&self.loaded_source_storage, source);
}
pub fn applyNativeInfo(self: *Runtime, index: usize, native_info: platform.WindowInfo) void {
pub fn applyNativeInfo(self: *Runtime, index: usize, native_info: platform.WindowInfo) anyerror!void {
self.windows[index].info.frame = native_info.frame;
self.windows[index].info.scale_factor = native_info.scale_factor;
self.windows[index].info.open = native_info.open;
self.windows[index].info.focused = native_info.focused;
if (!self.windows[index].main_frame_set) {
self.windows[index].main_frame = geometry.RectF.init(0, 0, native_info.frame.width, native_info.frame.height);
}
if (native_info.focused) Self.setFocusedIndex(self, index);
if (native_info.focused)
try Self.setFocusedIndex(self, index)
else
try Self.setWindowFocused(self, index, false);
}
pub fn updateWindowState(self: *Runtime, state: platform.WindowState) !void {
const existing_index = Self.findWindowIndexById(self, state.id);
const index = existing_index orelse try Self.reserveWindow(self, state.id, state.label, state.title, null, true);
var info = self.windows[index].info;
info.frame = state.frame;
info.scale_factor = state.scale_factor;
info.open = state.open;
info.focused = state.focused;
self.windows[index].info = info;
self.windows[index].info.frame = state.frame;
self.windows[index].info.scale_factor = state.scale_factor;
self.windows[index].info.open = state.open;
if (!self.windows[index].main_frame_set) {
self.windows[index].main_frame = geometry.RectF.init(0, 0, state.frame.width, state.frame.height);
}
if (state.focused) Self.setFocusedIndex(self, index);
if (state.focused)
try Self.setFocusedIndex(self, index)
else
try Self.setWindowFocused(self, index, false);
}
pub fn runtimeWindowStateForPersistence(self: *const Runtime, state: platform.WindowState) platform.WindowState {
@@ -224,9 +227,52 @@ pub fn RuntimeWindowStorage(comptime Runtime: type) type {
self.shell_layout_count -= 1;
}
pub fn setFocusedIndex(self: *Runtime, focused_index: usize) void {
for (self.windows[0..self.window_count], 0..) |*window, index| {
window.info.focused = index == focused_index;
/// THE window-key seam: every path that moves key-window status
/// — the platform's `window_focused` event, a frame-change echo
/// carrying `focused`, the app's own `focusWindow`, and native
/// adoption at creation — lands here, so the key-LOSS
/// consequence cannot be skipped by feeding only one ingress.
/// A window that transitions focused→unfocused drops the
/// tooltip conversation in all of its canvas views (the
/// `.view_blur` contract: focus-shown and pointer-owned alike
/// hide and re-stamp hidden); `view.focused` itself is
/// deliberately untouched so per-window focus memory survives
/// and the re-key restores focus where it was without revealing
/// anything.
pub fn setFocusedIndex(self: *Runtime, focused_index: usize) anyerror!void {
for (0..self.window_count) |index| {
try Self.setWindowFocused(self, index, index == focused_index);
}
}
/// The ONE writer of a tracked window's `focused` flag: the
/// key-LOSS consequence fires on the flag's own focused→
/// unfocused edge, HERE, so it cannot depend on which platform
/// event carried the loss or in what order. macOS announces a
/// key change as one GAIN (`window_focused`), and the dethroning
/// loop in `setFocusedIndex` observes the old window's edge —
/// but Windows and GTK announce the LOSS first (a state echo
/// carrying `focused = false` for the window the user left,
/// before any gain for the next one), and a loss written past
/// this seam would leave the later gain nothing to observe:
/// the tooltip stayed painted, and a11y-visible, in the
/// inactive window. Callers pass the flag they were told;
/// the transition logic lives only here.
///
/// The two writes that deliberately stay OUTSIDE the seam:
/// `reserveWindow`'s creation-time init (a fresh slot has no
/// prior state — no edge exists) and `closeWindow`'s
/// transactional flip in window_views.zig (its views are
/// removed with the window on success, so there is no tooltip
/// left to reset, and its rollback on platform failure must
/// not have fired one).
pub fn setWindowFocused(self: *Runtime, index: usize, focused: bool) anyerror!void {
const CanvasWidgetEventMethods = runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(Runtime);
const window = &self.windows[index];
const was_focused = window.info.focused;
window.info.focused = focused;
if (was_focused and !focused) {
try CanvasWidgetEventMethods.resetCanvasTooltipIntentForWindowKeyLoss(self, window.info.id);
}
}
+13
View File
@@ -534,11 +534,18 @@ pub fn RuntimeWindowViewRuntime(comptime Runtime: type) type {
for (self.views[0..self.view_count], 0..) |*view, view_index| {
if (view.window_id != window_id) continue;
const previous_state = view.canvasWidgetRenderState();
const was_focused = view.focused;
view.focused = std.mem.eql(u8, view.label, label);
const next_state = view.canvasWidgetRenderState();
if (!CanvasWidgetEventMethods.canvasWidgetRenderStatesEqual(previous_state, next_state)) {
try CanvasWidgetEventMethods.invalidateForCanvasWidgetRenderStateChange(self, view_index, previous_state, next_state);
}
// A view losing focus drops its tooltip state and
// re-stamps hidden — no stale tooltip may keep floating
// in a view the keyboard just left.
if (was_focused and !view.focused) {
try CanvasWidgetEventMethods.resetCanvasTooltipIntentForViewBlur(self, view_index);
}
}
for (self.webviews[0..self.webview_count]) |*webview| {
if (webview.window_id == window_id) webview.focused = std.mem.eql(u8, webview.label, label);
@@ -552,11 +559,17 @@ pub fn RuntimeWindowViewRuntime(comptime Runtime: type) type {
for (self.views[0..self.view_count], 0..) |*view, view_index| {
if (view.window_id != window_id) continue;
const previous_state = view.canvasWidgetRenderState();
const was_focused = view.focused;
view.focused = false;
const next_state = view.canvasWidgetRenderState();
if (!CanvasWidgetEventMethods.canvasWidgetRenderStatesEqual(previous_state, next_state)) {
try CanvasWidgetEventMethods.invalidateForCanvasWidgetRenderStateChange(self, view_index, previous_state, next_state);
}
// Window-level focus loss blurs every view: same tooltip
// reset as a per-view focus move.
if (was_focused) {
try CanvasWidgetEventMethods.resetCanvasTooltipIntentForViewBlur(self, view_index);
}
}
for (self.webviews[0..self.webview_count]) |*webview| {
if (webview.window_id == window_id) webview.focused = false;
+6
View File
@@ -105,6 +105,12 @@ pub fn RuntimeWindowViews(comptime Runtime: type) type {
// open=false event, and the open->closed TRANSITION — which
// dispatches the `window_closed` app event — must stay
// reserved for closes the app did not initiate.
// The `focused` flip deliberately bypasses the
// `setWindowFocused` seam (see window_storage.zig): on
// success the window's views — and any tooltip state in
// them — are removed below, and the rollback on platform
// failure must not have fired a key-loss tooltip reset for
// a window that never lost key.
const was_open = self.windows[index].info.open;
const was_focused = self.windows[index].info.focused;
self.windows[index].info.open = false;
+104
View File
@@ -174,6 +174,29 @@ const Harness = struct {
return node.frame.normalized().center();
}
fn pointerMove(self: *Harness, point: geometry.PointF, timestamp_ns: u64) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.window_id = 1,
.label = canvas_label,
.kind = .pointer_move,
.timestamp_ns = timestamp_ns,
.x = point.x,
.y = point.y,
} });
}
/// Present one frame on an explicit RECORDED timestamp — the clock
/// the tooltip hover-intent delay (and every tween) steps on.
fn frameAt(self: *Harness, frame_index: u64, timestamp_ns: u64) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(480, 360),
.scale_factor = 1,
.frame_index = frame_index,
.timestamp_ns = timestamp_ns,
} });
}
fn pointerClick(self: *Harness, point: geometry.PointF, timestamp_ns: u64) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
.window_id = 1,
@@ -693,6 +716,87 @@ test "a recorded markup session replays byte-identically with verified fingerpri
try std.testing.expectEqual(recorded.fingerprint, harness.runtime.sessionStateFingerprint());
}
/// Record the tooltip hover-dwell session: raw pointer hovers with
/// test-fixed timestamps arm the Add button's anchored tooltip
/// (tooltip-delay="200" in the fixture markup), explicit frame events
/// on the recorded clock carry the dwell past the deadline (show),
/// and a final hover off the trigger hides it — every transition on
/// journaled time, so two recordings are byte-identical.
fn recordTooltipDwellSession(buffer: *JournalBuffer) !u64 {
const recorder = try std.heap.page_allocator.create(runtime_ns.SessionRecorder);
defer std.heap.page_allocator.destroy(recorder);
recorder.* = runtime_ns.SessionRecorder.init(buffer.sink());
recorder.begin(.{ .platform_name = "test", .app_name = "ts-markup-e2e", .window_width = 480, .window_height = 360 });
const h = try Harness.createRecorded(recorder);
defer h.destroy();
const add_button = h.findId(.button, "Add").?;
const tooltip_id = h.findId(.tooltip, "Add a task").?;
const view = &h.harness.runtime.views[try h.viewIndex()];
// The anchored tooltip adopts hidden; hovering the trigger arms the
// 200ms declared delay without painting anything.
try h.pointerMove(try h.aim(add_button), 10_000_000);
try std.testing.expectEqual(tooltip_id, view.canvas_tooltip_armed_id);
try h.frameAt(2, 60_000_000);
try std.testing.expectEqual(@as(canvas.ObjectId, 0), view.canvas_tooltip_shown_id);
// The first frame at/past the deadline (10ms + 200ms) shows the
// tooltip — a deterministic frame on the recorded clock.
try h.frameAt(3, 215_000_000);
try std.testing.expectEqual(tooltip_id, view.canvas_tooltip_shown_id);
// Leaving the trigger hides it; the hide frame is recorded too.
try h.pointerMove(.{ .x = 4, .y = 350 }, 260_000_000);
try std.testing.expectEqual(@as(canvas.ObjectId, 0), view.canvas_tooltip_shown_id);
try h.frameAt(4, 280_000_000);
recorder.finish();
try std.testing.expect(!recorder.failed);
return h.harness.runtime.sessionStateFingerprint();
}
test "a recorded tooltip hover dwell replays its show and hide frames byte-identically" {
const buffer = try std.heap.page_allocator.create(JournalBuffer);
defer std.heap.page_allocator.destroy(buffer);
buffer.len = 0;
const fingerprint = try recordTooltipDwellSession(buffer);
// Determinism pin: the same driven dwell records byte-identical
// journal bytes — hover timestamps, the show frame, the hide frame,
// and every per-frame fingerprint checkpoint included.
const second = try std.heap.page_allocator.create(JournalBuffer);
defer std.heap.page_allocator.destroy(second);
second.len = 0;
const fingerprint_again = try recordTooltipDwellSession(second);
try std.testing.expectEqual(fingerprint, fingerprint_again);
try std.testing.expectEqualSlices(u8, buffer.journalBytes(), second.journalBytes());
// Replay into a fresh app: the journaled hovers re-arm the intent
// machine, the journaled frame timestamps re-fire the delay, and the
// per-frame fingerprint checkpoints verify the tooltip's show and
// hide frames — on the recorded clock, never a wall clock.
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
.size = geometry.SizeF.init(480, 360),
});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try std.testing.allocator.create(App);
defer std.testing.allocator.destroy(app_state);
app_state.* = Adapter.init(std.heap.page_allocator, .{}, boardOptions());
defer app_state.deinit();
const report = try runtime_ns.replaySession(&harness.runtime, app_state.app(), buffer.journalBytes(), .{
.verify = true,
.require_same_platform = false,
});
try std.testing.expect(report.ok());
try std.testing.expect(report.events_replayed > 0);
try std.testing.expect(report.checkpoints_verified > 0);
try std.testing.expectEqual(fingerprint, harness.runtime.sessionStateFingerprint());
}
// ------------------------------------------------- two live cores
/// A minimal host stub for the status core's boot request (the markup
+4 -1
View File
@@ -9,7 +9,10 @@
<text>draft [{draft}]</text>
</row>
<row gap="4">
<button on-press="add">Add</button>
<stack>
<button on-press="add">Add</button>
<tooltip anchor="above" tooltip-delay="200">Add a task</tooltip>
</stack>
<button on-press="cycle">Cycle</button>
<button on-press="clear">Clear</button>
</row>
+4
View File
@@ -214,6 +214,10 @@ fn writeVocabJson(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !void {
try writeDocList(&js, &markup_docs.reactions_attr_docs);
try js.objectField("dropdown-menu");
try writeDocList(&js, &markup_docs.anchor_attr_docs);
// Tooltip shares the anchor attribute family (the second anchorable
// element), so its page resolves the same scoped rows.
try js.objectField("tooltip");
try writeDocList(&js, &markup_docs.anchor_attr_docs);
try js.objectField("template");
try writeDocList(&js, &markup_docs.template_attr_docs);
try js.objectField("for");
+5 -4
View File
@@ -65,7 +65,7 @@ pub const element_docs = [_]Doc{
.{ .name = "select", .doc = "Select trigger only (no options attribute): content is the current value, placeholder while empty, on-press opens. Compose the options as an ANCHORED dropdown-menu of menu-items under an if, beside the trigger in a stack (anchor=\"below\" + on-dismiss; model-owned open state)." },
.{ .name = "switch", .doc = "Switch control; label is the text content, bind checked, dispatch with on-toggle." },
.{ .name = "toggle-button", .doc = "Pressed-state toggle button; label is the text content, dispatch with on-toggle." },
.{ .name = "tooltip", .doc = "Tooltip text leaf; content supports {} interpolation." },
.{ .name = "tooltip", .doc = "Tooltip text leaf; content supports {} interpolation. anchor=\"above|below\" floats it against its parent (put it beside its trigger in a stack) and hands visibility to the RUNTIME: it shows after the trigger has been hovered tooltip-delay milliseconds (default 600; a shared 400ms warm window after a pointer-hovered tooltip hides on leave shows the next one instantly), shows immediately when the trigger gains keyboard focus, and hides on pointer leave, focus departure, Escape, or a press of the trigger (only the pointer-leave hide warms) - the model never hears hover. Without anchor it stays a static leaf that paints whenever the view renders it." },
.{ .name = "input", .doc = "Single-line text entry; text and placeholder bindings, edits via on-input, enter via on-submit." },
.{ .name = "combobox", .doc = "Text entry with menu affordance (no options attribute); edits via on-input, open via on-press — compose the options like select's anchored dropdown-menu pattern (filter the for-each source from the model as the user types)." },
.{ .name = "skeleton", .doc = "Loading placeholder block; size with width and height." },
@@ -132,6 +132,7 @@ pub const attribute_docs = [_]Doc{
.{ .name = "resize-easing", .doc = "split only, beside a nonzero resize-duration: easing curve of the layout tween - linear, standard (the default), emphasized, or spring. Easing without a duration is a teaching error (it would be silently inert)." },
.{ .name = "resize-origin", .doc = "split only, beside a nonzero resize-duration: the fraction a freshly MOUNTED split's pane boundary slides in from toward its declared value (children keep the value's layout; the pane clips reveal them) - a pane expanding out of an unmounted collapsed state slides in instead of popping. An origin without a duration is a teaching error (it would be silently inert)." },
.{ .name = "quiet-hover", .doc = "Pressable (hit-target) elements only: the quiet-surface knob for image-forward content tiles - the pointer resting on the element paints NO hover wash (the wash belongs to acting controls like rows, menu items, and buttons). Only the hover fill goes quiet: press and selection fills, the focus ring, cursor intent, and hit testing keep their own channels." },
.{ .name = "tooltip-delay", .doc = "tooltip only, beside anchor: hover-intent show delay in milliseconds (a plain number or one {binding}) - the runtime shows the anchored tooltip after its trigger has been hovered this long on the recorded frame clock. 0 shows the instant the trigger is hovered; absent follows the 600ms token default. A shared 400ms warm window after a pointer-hovered tooltip hides on leave skips the delay on the next trigger (no other hide cause warms); keyboard-focus reveals are always immediate. A delay without an anchor is a teaching error (it would be silently inert)." },
.{ .name = "background", .doc = "Background color token (literal ColorTokens field name: background, surface, surface_subtle, ...)." },
.{ .name = "foreground", .doc = "Foreground/text color token (literal ColorTokens field name, e.g. text, text_muted, success, warning, info)." },
.{ .name = "accent", .doc = "Accent color token (literal ColorTokens field name, e.g. accent, destructive, success, warning, info)." },
@@ -255,9 +256,9 @@ pub const reactions_attr_docs = [_]Doc{
};
pub const anchor_attr_docs = [_]Doc{
.{ .name = "anchor", .doc = "dropdown-menu: floats the surface against its PARENT's frame instead of the flow (literal below or above; either side auto-flips at the window edges). Late z-pass above the whole tree, window-clipped — never cropped by a scroll pane, never reflows siblings. Put the dropdown beside its trigger inside a stack." },
.{ .name = "anchor-alignment", .doc = "dropdown-menu (with anchor): horizontal alignment against the anchor - start, end, or stretch (stretch also widens the surface to at least the anchor's width, the select-menu look)." },
.{ .name = "anchor-offset", .doc = "dropdown-menu (with anchor): literal gap in points between the anchor edge and the surface (default 4)." },
.{ .name = "anchor", .doc = "dropdown-menu and tooltip: floats the surface against its PARENT's frame instead of the flow (literal below or above; either side auto-flips at the window edges). Late z-pass above the whole tree, window-clipped — never cropped by a scroll pane, never reflows siblings. Put the surface beside its trigger inside a stack. An anchored tooltip's visibility is runtime-owned (hover intent on the trigger; see tooltip-delay), unlike the model-owned dropdown." },
.{ .name = "anchor-alignment", .doc = "dropdown-menu and tooltip (with anchor): horizontal alignment against the anchor - start, end, or stretch (stretch also widens the surface to at least the anchor's width, the select-menu look)." },
.{ .name = "anchor-offset", .doc = "dropdown-menu and tooltip (with anchor): literal gap in points between the anchor edge and the surface (default 4)." },
};
pub const event_docs = [_]Doc{