Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79384ca47d | |||
| 6a3b9afe12 | |||
| 1e05adf242 | |||
| 4017669f95 | |||
| 8464fc9f1a | |||
| 6dfb4a682f | |||
| 04ccd28c50 | |||
| 641827e4e3 | |||
| 1c5f8cd837 | |||
| 629fac6261 | |||
| f375900aa8 | |||
| c6581da6a2 | |||
| 9c78b01388 | |||
| c01ad1b470 | |||
| 4fd2ce69e2 | |||
| ca1074c50f | |||
| 859d0aeedd | |||
| a9a72789e5 | |||
| e85fd6d854 | |||
| 390f5e8bb3 | |||
| 92fcf58567 | |||
| ef92834ebb | |||
| defe3194d2 | |||
| e92fec270f | |||
| 4dfcd9dac1 | |||
| 4fd9964a8d | |||
| 5c85550b70 | |||
| 76082aecb1 |
@@ -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).
|
||||
@@ -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"]} />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user