schema and rendering fixes (#367)
* canvas: allow checkbox and radio label content * canvas: add interaction washes to actionable surfaces * canvas: keep selection control geometry and fills consistent * canvas: separate switch thumb and track colors * canvas: preserve resolved colors through disabled washes * canvas: skip zero-width badge and button strokes * canvas: tint destructive alerts and align compact content * docs: regenerate canvas control previews * canvas: preserve text attributes on text-bearing controls * canvas: fast-path idle layout container rendering * canvas: keep authored row fills as the rest state * bench: recalibrate full-view toggle budget * canvas: complete schema and rendering fixes - Enable checkbox and radio labels consistently across schema, markup engines, accessibility, docs, and previews. - Unify actionable state, disabled-color, variant-accent, and zero-width stroke behavior across controls and surfaces. - Correct compact control geometry and destructive alert styling/alignment with comprehensive golden and gate coverage. * canvas: address schema review findings * canvas: fix actionable state routing * canvas: fix drag menus and disabled control tokens
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 854 B After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# Checkbox
|
||||
|
||||
A binary value control: the label rides the `text` attribute — checkbox is not a text-bearing element, so text content between the tags is rejected with a teaching error (`label="..."` alone names one for accessibility without a visible label). The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch).
|
||||
A binary value control. Put its visible label between the tags (the `text` attribute is the equivalent binding-friendly form); `label="..."` alone supplies an accessible name without drawing a label. The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch).
|
||||
|
||||
<ComponentPreview name="checkbox" alt="Checkboxes rendered by the engine" caption="checked, unchecked, and disabled checkboxes" />
|
||||
|
||||
@@ -12,9 +12,9 @@ A binary value control: the label rides the `text` attribute — checkbox is not
|
||||
|
||||
```html
|
||||
<column gap="12">
|
||||
<checkbox checked="{accepted}" on-toggle="toggle_terms" text="Accept terms and conditions" />
|
||||
<checkbox checked="{reports}" on-toggle="toggle_reports" text="Send usage reports" />
|
||||
<checkbox checked="true" disabled="true" text="Managed by your organization" />
|
||||
<checkbox checked="{accepted}" on-toggle="toggle_terms">Accept terms and conditions</checkbox>
|
||||
<checkbox checked="{reports}" on-toggle="toggle_reports">Send usage reports</checkbox>
|
||||
<checkbox checked="true" disabled="true">Managed by your organization</checkbox>
|
||||
</column>
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# Radio
|
||||
|
||||
The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio's label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation).
|
||||
The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio takes its visible label as element content; the `text` attribute is the equivalent binding-friendly form. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation).
|
||||
|
||||
<ComponentPreview name="radio-group" alt="A radio group rendered by the engine" caption="a radio group with one selected and one disabled option" />
|
||||
|
||||
@@ -12,11 +12,11 @@ The single-choice value control, grouped by a `radio-group`. Give the group an a
|
||||
|
||||
```html
|
||||
<radio-group gap="12" label="Density">
|
||||
<radio checked="{density == default}" on-change="set_default" text="Default" />
|
||||
<radio checked="{density == default}" on-change="set_default">Default</radio>
|
||||
<row>
|
||||
<radio checked="{density == comfortable}" on-change="set_comfortable" text="Comfortable" />
|
||||
<radio checked="{density == comfortable}" on-change="set_comfortable">Comfortable</radio>
|
||||
</row>
|
||||
<radio checked="{density == compact}" disabled="true" text="Compact" />
|
||||
<radio checked="{density == compact}" disabled="true">Compact</radio>
|
||||
</radio-group>
|
||||
```
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ pub fn draft(model: *const Model) []const u8 {
|
||||
|
||||
On a `list-item`, `on-submit` grows a second home beyond text entry: with a submit handler bound, plain Enter on a ring-focused row dispatches it as the row's PRIMARY action (open the record, play the track — the desktop list convention), while Space keeps the row's select activation (`on-press`). Rows without a submit handler resolve Enter exactly as before — both activation keys select. It is the keyboard mirror of `on-double-press`: bind both to the same Msg and pointer and keyboard users share one primary action (the soundboard's Zig track rows bind `on_press` select, `on_double_press` play, `on_submit` play). How Enter reaches the row at all — and why arrows may not — is [keyboard routing](#keyboard-routing-focus-registers-quiet-list-rows-and-the-app-level-fallback).
|
||||
|
||||
Presses follow one rule: a click lands on the nearest pressable widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest pressable ancestor, and dragging still selects text. Any element with a bound `on-press`, `on-double-press`, or `on-toggle` is pressable (the handler makes it a hit target), so a pressable row is just `<panel on-press="open:{id}">` — or `<row on-press=...>` — with plain text children: no overlays, no duplicated handlers. Nested pressables resolve to the deepest one (a button inside a pressable row wins); editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.
|
||||
Presses follow one rule: a click lands on the nearest press-claiming widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest interactive ancestor, and dragging still selects text. Any element with `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` becomes a hit target and press claimer, so a row can bind `on-press` for clicks or `on-drag` for spatial dragging and child text still resolves to that row without overlays or duplicated handlers. Nested claimers resolve to the deepest one; editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.
|
||||
|
||||
## Keyboard routing: focus registers, quiet list rows, and the app-level fallback
|
||||
|
||||
@@ -328,7 +328,7 @@ On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScr
|
||||
|
||||
Scroll regions pin at their content edges by default on every path — no rubber-band bounce; kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: `overscroll="rubber_band"` in markup or `ElementOptions.overscroll = .rubber_band` in Zig views (the native macOS scroller gets elastic edges, the engine physics overscroll under resistance and spring back). The `ScrollPhysics.overscroll` design token flips the app-wide default; per-region values override it, and `overscroll="none"` pins a region regardless of the token.
|
||||
|
||||
Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the pressable element it answers, holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:
|
||||
Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the interactive element it answers — a hit-target kind or an element made interactive by `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` — holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:
|
||||
|
||||
```html
|
||||
<list-item on-press="open:{entry.id}" label="{entry.title}">
|
||||
|
||||
@@ -54,11 +54,11 @@
|
||||
},
|
||||
{
|
||||
"name": "checkbox",
|
||||
"doc": "Value control; bind checked, dispatch with on-toggle."
|
||||
"doc": "Text-bearing value control; the visible label is text content (or text=), bind checked, dispatch with on-toggle."
|
||||
},
|
||||
{
|
||||
"name": "radio",
|
||||
"doc": "Single-choice value control; bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility."
|
||||
"doc": "Text-bearing single-choice value control; the visible label is text content (or text=), bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility."
|
||||
},
|
||||
{
|
||||
"name": "toggle",
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
<column grow="1" gap="12" padding="16">
|
||||
<!-- Single selection is radio semantics: the group renders selection
|
||||
from {f == filter} equalities, each radio's on-toggle sets the
|
||||
model field, and the visible label rides text= (radio is not a
|
||||
text-bearing element). -->
|
||||
model field, and the visible label is element content. -->
|
||||
<radio-group gap="8" label="Filter">
|
||||
<for each="filters" as="f">
|
||||
<radio checked="{f == filter}" on-toggle="set_filter:{f}" text="{f}" />
|
||||
<radio checked="{f == filter}" on-toggle="set_filter:{f}">{f}</radio>
|
||||
</for>
|
||||
</radio-group>
|
||||
<if test="{habit_count}">
|
||||
|
||||
@@ -153,7 +153,7 @@ Zero app code: on macOS every non-virtualized `scroll` region — and every wind
|
||||
|
||||
Authors write ONE menu; the platform decides how it presents. The default is the real OS context menu at the pointer — `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux — and the selection dispatches the item's typed `Msg`. On hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today), the SAME declared items present automatically as an anchored canvas surface at the click point, with the standard anchored-surface behavior (Escape and outside-click dismiss, late z-pass, window clipping). Never two authored menus, never a canvas imitation where the OS menu exists.
|
||||
|
||||
Markup declares the menu as a `<context-menu>` element — a DIRECT child of the pressable element whose right-click it answers (a hit target, or an element with a bound `on-press`/`on-hold`). It is metadata, not content: it renders nothing in the row's flow. Children are `menu-item`s (`on-press` required, `disabled` optional, the text content is the label) and bare `<separator/>`s, with `if`/`else`/`for` around them to swap or repeat items — a menu whose items all evaporate at runtime simply declares no menu (the All Notes row pattern). Conditional MENUS are spelled as conditional ITEMS: the `<context-menu>` itself takes no attributes and cannot sit behind a structure tag. No submenus: the platform channel carries flat items (label, enabled, separator) only.
|
||||
Markup declares the menu as a `<context-menu>` element — a DIRECT child of the interactive element whose right-click it answers (a hit-target kind, or an element made interactive by `on-press`/`on-double-press`/`on-toggle`/`on-hold`/`on-drag`). It is metadata, not content: it renders nothing in the row's flow. Children are `menu-item`s (`on-press` required, `disabled` optional, the text content is the label) and bare `<separator/>`s, with `if`/`else`/`for` around them to swap or repeat items — a menu whose items all evaporate at runtime simply declares no menu (the All Notes row pattern). Conditional MENUS are spelled as conditional ITEMS: the `<context-menu>` itself takes no attributes and cannot sit behind a structure tag. No submenus: the platform channel carries flat items (label, enabled, separator) only.
|
||||
|
||||
```html
|
||||
<list-item on-press="open_note:{n.id}" label="{n.title}">
|
||||
@@ -208,7 +208,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
|
||||
| `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 radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches `on-change` when bound, then `on-toggle`, then `on-press` for compatibility — reactivating the already-checked radio has no new `on-change` edge, though a legacy fallback still receives the activation; 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) |
|
||||
| `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); checkbox/radio visible labels are text content (`<checkbox>Done</checkbox>`), with `text="..."` as the equivalent binding-friendly form and `label=` alone naming one for accessibility without drawing a label; a radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches `on-change` when bound, then `on-toggle`, then `on-press` for compatibility — reactivating the already-checked radio has no new `on-change` edge, though a legacy fallback still receives the activation; 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) |
|
||||
| `text-field`, `input`, `search-field`, `combobox`, `textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a default `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into `submit-on-enter="true"`: plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed) |
|
||||
| `status-bar` | status bar | text leaf: content only, no children |
|
||||
| `separator`, `spacer` | separator, flexible space | `separator` is axis-aware: a horizontal rule in a `column`, a thin vertical divider in a `row`; give `spacer` a `grow` |
|
||||
@@ -221,7 +221,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
|
||||
| `stepper` > `step` | composite stage track | `active="{index}"` (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes `key`, `global-key`, `label` |
|
||||
| `timeline` > `timeline-item` | composite ledger list | items only inside a timeline (for/if fine); items are leaves — `title` (required), `description`, `meta`, `indicator`, `variant`, `connector="false"` on the last item, `selected`; `on-press` makes the whole item pressable with a trailing chevron |
|
||||
| `chart` > `series` | composite data chart | series only inside a chart, and only series (the set is static — data varies through bindings); each series is a leaf — `values="{binding}"` (required) names a model `[]const f32` iterable, `kind` is `line`/`area`/`bar` (literal), `color` a token name, `label` the semantics name; chart takes `y-min`, `y-max`, `grid-lines`, `baseline`, `x-labels`, `y-labels`, `hover-details`, `stroke-width`, box options, `label` — see "Charts" |
|
||||
| `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit target or an element with `on-press`/`on-hold`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" |
|
||||
| `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit-target kind or an element with `on-press`/`on-double-press`/`on-toggle`/`on-hold`/`on-drag`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" |
|
||||
| `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`, and optional `submit-on-enter`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `<spacer grow="1"/>` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) |
|
||||
|
||||
Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `icon_button` (`<button icon="...">` with empty content is the declarative icon button), `data_grid` (per-column cell templates), `popover`/`menu_surface` (anchored to runtime geometry), `segmented_control` (use `tabs`/`toggle-group`: `<button>` children of `<tabs>` lower to segmented triggers automatically, so the active tab lifts per the house treatment). Charts ARE expressible: `<chart>` with `<series values="{binding}">` children binding model f32 iterables — see the Charts section (`.band` series and dynamic series composition stay with `ui.chart`). Built-in vector icons ARE expressible: `<icon name="search"/>` (closed, compile-checked name set; `Ui.icon` is the Zig-view equivalent). App-authored icons: `canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))` parses any SVG in the common 24x24 stroke-icon dialect at comptime; register the parsed table once at boot with `canvas.icons.registerAppIcons(&table)` and draw by name via `ui.appIcon(.{...}, "logo")` or `ElementOptions.icon` — registered names render exactly like built-ins on every draw path. Markup `<icon>`/`<button icon>` stay built-in-only (the compiled engine validates names at comptime, where runtime registrations cannot exist — engine parity). Runtime images ARE expressible: `<image image="{cover}" width="120" height="80" label="Cover art"/>` and `<avatar image="{user_image}">CT</avatar>` bind a `u64` ImageId model field/fn (the id is just model data; 0 draws nothing / keeps the initials fallback) — see the Images section; the `image` binding is required on the leaf (an unbound `<image>` is dead markup) and stays avatar+image scoped.
|
||||
@@ -521,7 +521,7 @@ Scroll offsets follow the same mirror discipline as text: the Msg carries the of
|
||||
|
||||
A handler or update error DEGRADES, it does not exit the app: dispatch catches it, records it in a bounded ring (`runtime.dispatchErrors()`, the `error event=... name=...` lines and `dispatch_errors=` count in automation snapshots, and a `dispatch.error` trace record at error level), and the app keeps running. Trace-sink capacity failures likewise never fail dispatch — dropped records are counted (`dropped_trace_records=`), not fatal. Design for it: an arm that can fail should still surface its own status in the model; the error ring is the safety net, not the UX.
|
||||
|
||||
Presses follow ONE rule: a click lands on the nearest pressable widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest pressable ancestor, and dragging still selects text. "Pressable" means an interactive kind (button, checkbox, list-item, ...) or ANY element with a bound `on-press`/`on-double-press`/`on-toggle` — the handler itself makes the element a hit target, so a pressable row is just `<panel on-press="open:{id}">` (or `<row on-press=...>`, `ui.row(.{ .on_press = ... })`) with plain text children: no empty-text overlays, no duplicating the handler onto every text leaf. Nested pressables resolve to the deepest one (a button inside a pressable row wins over the row); editable text fields, scroll containers, and modal surfaces (dialog, drawer, sheet, popover, menus) always claim their own presses, so a click in a field inside a pressable row places the caret instead of activating the row. The value/text handlers (`on-change`, `on-submit`, `on-input`) still only belong on controls — both engines and `markup check` reject them on layout/decoration elements with a teaching error.
|
||||
Presses follow ONE rule: a click lands on the nearest press-claiming widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest interactive ancestor, and dragging still selects text. An interactive element is a control kind (button, checkbox, list-item, ...) or ANY element with a bound `on-press`/`on-double-press`/`on-toggle`/`on-hold`/`on-drag`; these handlers make the element a hit target and press claimer. Thus a row can bind `on-press` for clicks or `on-drag` for spatial dragging, and child text still resolves to that row without overlays or duplicated handlers. Nested claimers resolve to the deepest one; editable text fields, scroll containers, and modal surfaces (dialog, drawer, sheet, popover, menus) always claim their own presses. The value/text handlers (`on-change`, `on-submit`, `on-input`) still only belong on controls — both engines and `markup check` reject them on layout/decoration elements with a teaching error.
|
||||
|
||||
### Keys: quiet list rows and the app-level fallback
|
||||
|
||||
|
||||
@@ -740,7 +740,7 @@ pub const cursorForWidgetTarget = widget_runtime.cursorForWidgetTarget;
|
||||
/// the single source of truth the runtime, both markup engines, and the
|
||||
/// markup validator's element list all derive from). Kind-level only: the
|
||||
/// widget-level predicate is `widgetIsHitTarget`, which also admits any
|
||||
/// widget carrying a bound press/toggle handler.
|
||||
/// widget carrying a bound press/toggle/drag handler.
|
||||
pub const widgetKindHitTarget = @import("widget_access.zig").widgetKindHitTarget;
|
||||
/// Widget-level keyboard focusability without geometry/ancestor-clip
|
||||
/// filtering. Runtime roving-focus groups use this to identify a logical
|
||||
@@ -749,7 +749,7 @@ pub const widgetKindHitTarget = @import("widget_access.zig").widgetKindHitTarget
|
||||
/// rejects clipped targets.
|
||||
pub const widgetIsFocusable = @import("widget_access.zig").isFocusable;
|
||||
/// Widget-level hit-target-ness: kind-level `widgetKindHitTarget` plus
|
||||
/// any widget with a bound press/toggle handler (stamped into
|
||||
/// any widget with a bound press/toggle/drag handler (stamped into
|
||||
/// `semantics.actions` by the builder and both markup engines).
|
||||
pub const widgetIsHitTarget = @import("widget_access.zig").isHitTarget;
|
||||
pub const widgetIsHoverMsgHitTarget = @import("widget_access.zig").isHoverMsgHitTarget;
|
||||
|
||||
@@ -1149,11 +1149,11 @@ pub const anchor_offset_value_message = "anchor-offset takes a literal number: t
|
||||
pub const anchor_dependent_attr_message = "anchor-alignment and anchor-offset only apply together with anchor - add anchor=\"below\" (or \"above\") to float this surface";
|
||||
|
||||
/// Elements whose widget KIND the engine never hit-tests: layout and
|
||||
/// decoration only. A bound `on-press`/`on-double-press`/`on-toggle`
|
||||
/// makes any element a hit target (widget-level: the handler stamps the
|
||||
/// press/toggle action, and presses on non-interactive content inside it
|
||||
/// fall through to it), so those three are legal everywhere; the
|
||||
/// remaining value/text handlers
|
||||
/// decoration only. A bound `on-press`/`on-double-press`/`on-toggle`/
|
||||
/// `on-hold`/`on-drag` makes any element a hit target (widget-level: the
|
||||
/// handler stamps a press/toggle/drag action, and presses on
|
||||
/// non-interactive content inside it fall through to it), so those are
|
||||
/// legal everywhere; the remaining value/text handlers
|
||||
/// (`on-change`/`on-submit`/`on-input`) have no behavior to bind to on
|
||||
/// these elements and stay validation errors. Registry-derived from the
|
||||
/// `hit_target` element predicate, which mirrors the engine's kind
|
||||
@@ -1177,7 +1177,7 @@ pub const autofocus_element_message = "autofocus is only supported on focusable
|
||||
|
||||
pub const submit_on_enter_element_message = "submit-on-enter is only supported on textarea - it makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; single-line fields already submit on Enter, and other elements have no multiline Enter policy";
|
||||
|
||||
pub const non_hit_target_handler_message = "on-change/on-submit/on-input never fire here: this element has no control or text behavior - put them on a control (input, checkbox, slider) inside it (on-press/on-double-press/on-toggle are fine anywhere: a bound press handler makes any element pressable, and clicks on plain text or icons inside it fall through to it)";
|
||||
pub const non_hit_target_handler_message = "on-change/on-submit/on-input never fire here: this element has no control or text behavior - put them on a control (input, checkbox, slider) inside it (on-press/on-double-press/on-toggle/on-hold/on-drag are fine anywhere: they make any element interactive, and presses on plain text or icons inside it fall through to it)";
|
||||
|
||||
/// Elements whose widget kind layers its children on top of each other
|
||||
/// (every child gets the full content box), so `gap` can never space
|
||||
@@ -1594,12 +1594,17 @@ fn inlineSeparatorNode(source: []const u8, start: usize, end: usize) MarkupNode
|
||||
}
|
||||
|
||||
/// Whether an element can HOST a context-menu: right-click resolution
|
||||
/// walks the hit route, so the host must be a hit target — or carry a
|
||||
/// bound on-press/on-hold, which makes any element pressable. Shared by
|
||||
/// the validator and both engines; comptime-callable.
|
||||
/// walks the hit route, so the host must be a hit target — or carry any
|
||||
/// handler that stamps a press/toggle/drag action and therefore makes an
|
||||
/// otherwise structural element a hit target. Shared by the validator
|
||||
/// and both engines; comptime-callable.
|
||||
pub fn contextMenuHostEligible(node: MarkupNode) bool {
|
||||
if (!nameInList(node.name, &known_non_hit_target_element_names)) return true;
|
||||
return node.attr("on-press") != null or node.attr("on-hold") != null;
|
||||
return node.attr("on-press") != null or
|
||||
node.attr("on-double-press") != null or
|
||||
node.attr("on-toggle") != null or
|
||||
node.attr("on-hold") != null or
|
||||
node.attr("on-drag") != null;
|
||||
}
|
||||
|
||||
fn a11yNodeHasName(node: MarkupNode) bool {
|
||||
@@ -1922,7 +1927,7 @@ pub const series_color_message = "series color takes a literal color token name
|
||||
pub const series_label_message = "series label expects text (a literal or one {binding}) - it names the series in the chart's semantics summary";
|
||||
pub const series_children_message = "series is a leaf - it takes no children; the values binding carries its data";
|
||||
pub const context_menu_parent_message = "context-menu must be a DIRECT child of the element whose right-click it answers - a conditional menu goes inside: wrap the menu-items in if/else, not the context-menu itself";
|
||||
pub const context_menu_host_message = "context-menu attaches to the element that takes the right-click, and this element is never a hit target - put the menu on the pressable element (list-item, button, panel, ...) or bind on-press on this one";
|
||||
pub const context_menu_host_message = "context-menu attaches to the element that takes the right-click, and this element is never a hit target - put the menu on an interactive element (list-item, button, panel, ...) or bind on-press/on-double-press/on-toggle/on-hold/on-drag on this one";
|
||||
pub const context_menu_single_message = "an element takes at most one context-menu - one right-click, one menu; swap its items with if/else INSIDE the menu";
|
||||
pub const context_menu_attrs_message = "context-menu takes no attributes - presentation belongs to the platform (the OS menu where the host has one, the anchored fallback surface elsewhere)";
|
||||
pub const context_menu_children_message = "context-menu takes menu-item and separator children (if/else/for around them are fine) - the items present through the platform's menu, so other elements cannot render there";
|
||||
|
||||
@@ -391,17 +391,19 @@ const Decoder = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Decode an NSUI document. Refuses unknown versions and codes loudly
|
||||
/// (never a silent skip) and hostile depth/length input safely. The
|
||||
/// result is CANONICALIZED (typed values stamped), ready for either
|
||||
/// engine or further tooling; line/column positions are derived from the
|
||||
/// carried spans when present.
|
||||
/// Decode an NSUI document. Schema v1 migrates structurally to v2: the
|
||||
/// wire layout is unchanged, and every v1-valid checkbox/radio remains
|
||||
/// valid after those elements gain optional text content. Other unknown
|
||||
/// versions and codes refuse loudly (never a silent skip), and hostile
|
||||
/// depth/length input stays bounded. The result is CANONICALIZED (typed
|
||||
/// values stamped), ready for either engine or further tooling;
|
||||
/// line/column positions are derived from the carried spans when present.
|
||||
pub fn decode(arena: std.mem.Allocator, bytes: []const u8, diagnostic: *CodecDiagnostic) DecodeError!markup.MarkupDocument {
|
||||
var decoder = Decoder{ .arena = arena, .bytes = bytes, .diagnostic = diagnostic };
|
||||
const header = try decoder.take(magic.len);
|
||||
if (!std.mem.eql(u8, header, magic)) return decoder.fail(bad_magic_message);
|
||||
const version = try decoder.int(u16);
|
||||
if (version != schema.schema_version) return decoder.fail(bad_version_message);
|
||||
if (version != 1 and version != schema.schema_version) return decoder.fail(bad_version_message);
|
||||
const flags = try decoder.int(u16);
|
||||
decoder.spans = flags & flag_spans != 0;
|
||||
decoder.provenance = flags & flag_provenance != 0;
|
||||
|
||||
@@ -225,7 +225,7 @@ test "NSUI golden bytes for a minimal document" {
|
||||
const bytes = try nsui.encode(arena, document, .{ .spans = false, .provenance = false }, &diagnostic);
|
||||
const expected = [_]u8{
|
||||
'N', 'S', 'U', 'I', // magic
|
||||
1, 0, // schema_version = 1
|
||||
2, 0, // schema_version = 2
|
||||
0, 0, // flags: no spans, no provenance
|
||||
0, 0, // template count = 0
|
||||
1, // root present
|
||||
@@ -319,6 +319,16 @@ test "NSUI refuses what it does not know, loudly" {
|
||||
var diagnostic = nsui.CodecDiagnostic{};
|
||||
const bytes = try nsui.encode(arena, document, .{}, &diagnostic);
|
||||
|
||||
// V1 migrates structurally to v2. Its wire layout is unchanged, and
|
||||
// every document valid under the old checkbox/radio content rules is
|
||||
// also valid under the new optional-text rules.
|
||||
{
|
||||
const migrated = try arena.dupe(u8, bytes);
|
||||
std.mem.writeInt(u16, migrated[4..6], 1, .little);
|
||||
const decoded = try nsui.decode(arena, migrated, &diagnostic);
|
||||
try testing.expectEqualStrings("row", decoded.root.?.name);
|
||||
}
|
||||
|
||||
// Unknown schema version.
|
||||
{
|
||||
const mutated = try arena.dupe(u8, bytes);
|
||||
@@ -375,7 +385,7 @@ test "NSUI JSON dump derives from the decoded document" {
|
||||
defer out.deinit();
|
||||
try nsui.writeJson(decoded, hash, &out.writer);
|
||||
const json = out.written();
|
||||
try testing.expect(std.mem.indexOf(u8, json, "\"schemaVersion\":1") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, json, "\"schemaVersion\":2") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, json, "\"node\":\"row\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, json, "\"code\":1") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, json, "\"name\":\"gap\"") != null);
|
||||
@@ -402,8 +412,8 @@ test "NSUI refuses hostile nesting depth" {
|
||||
try testing.expectEqualStrings(nsui.depth_message, diagnostic.message);
|
||||
}
|
||||
|
||||
test "the schema version constant is 1 and the registry backs the wire" {
|
||||
try testing.expectEqual(@as(u16, 1), schema.schema_version);
|
||||
test "the schema version constant is 2 and the registry backs the wire" {
|
||||
try testing.expectEqual(@as(u16, 2), schema.schema_version);
|
||||
// Every registry element/attr the encoder can meet has a nonzero code
|
||||
// (0 is the wire's "no entry" marker).
|
||||
for (schema.elements) |entry| try testing.expect(entry.code != 0);
|
||||
|
||||
@@ -514,7 +514,11 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
|
||||
}
|
||||
if (comptime (interpreter.elementTakesText(kind) and !composite_children)) {
|
||||
var built = ui.el(kind, options, .{});
|
||||
built.widget.text = interpolatedText(inner, entries, ui, model, scope);
|
||||
// Interpreter parity: content wins when present; an empty
|
||||
// element keeps its already-resolved text= attribute.
|
||||
if (comptime inner.children.len > 0) {
|
||||
built.widget.text = interpolatedText(inner, entries, ui, model, scope);
|
||||
}
|
||||
// Avatars clip their runtime image to the avatar circle,
|
||||
// exactly like `Ui.avatar` and the interpreter (a no-op
|
||||
// while the id is 0 and the initials fallback renders).
|
||||
|
||||
@@ -99,6 +99,38 @@ const zero_card_padding_markup =
|
||||
;
|
||||
const ZeroCardPaddingCompiled = canvas.CompiledMarkupView(fixture.Model, fixture.Msg, zero_card_padding_markup);
|
||||
|
||||
const selection_label_content_markup =
|
||||
\\<column gap="8">
|
||||
\\ <checkbox checked="true" on-toggle="add">Done</checkbox>
|
||||
\\ <checkbox on-toggle="add" text="Later" />
|
||||
\\ <radio-group label="Density">
|
||||
\\ <radio checked="true" on-change="add">Default</radio>
|
||||
\\ <radio on-change="add" text="Compact" />
|
||||
\\ </radio-group>
|
||||
\\</column>
|
||||
;
|
||||
const SelectionLabelContentCompiled = canvas.CompiledMarkupView(fixture.Model, fixture.Msg, selection_label_content_markup);
|
||||
|
||||
test "checkbox and radio element content builds identically in both markup engines" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const model = fixture.testModel();
|
||||
|
||||
var interpreter = try InboxInterpreter.init(arena, selection_label_content_markup);
|
||||
var interpreter_ui = InboxUi.init(arena);
|
||||
const interpreted = try interpreter_ui.finalize(try interpreter.build(&interpreter_ui, &model));
|
||||
var compiled_ui = InboxUi.init(arena);
|
||||
const compiled = try compiled_ui.finalize(SelectionLabelContentCompiled.build(&compiled_ui, &model));
|
||||
|
||||
try expectSameTree(fixture.Msg, interpreted, compiled);
|
||||
try expectSameTexts(interpreted.root, compiled.root);
|
||||
try testing.expect(fixture.findByText(compiled.root, .checkbox, "Done") != null);
|
||||
try testing.expect(fixture.findByText(compiled.root, .checkbox, "Later") != null);
|
||||
try testing.expect(fixture.findByText(compiled.root, .radio, "Default") != null);
|
||||
try testing.expect(fixture.findByText(compiled.root, .radio, "Compact") != null);
|
||||
}
|
||||
|
||||
test "explicit zero card padding survives interpreted and compiled markup" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
@@ -130,6 +162,26 @@ fn compileInbox(arena: std.mem.Allocator, model: *const fixture.Model) !InboxUi.
|
||||
}
|
||||
|
||||
const ContextMenuCompiled = canvas.CompiledMarkupView(fixture.Model, fixture.Msg, fixture.context_menu_markup_source);
|
||||
const DragContextMenuCompiled = canvas.CompiledMarkupView(fixture.Model, fixture.Msg, fixture.drag_context_menu_markup_source);
|
||||
|
||||
test "compiled drag-only context-menu host matches the interpreter" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const model = fixture.testModel();
|
||||
|
||||
var interpreter_view = try InboxInterpreter.init(arena, fixture.drag_context_menu_markup_source);
|
||||
var interpreter_ui = InboxUi.init(arena);
|
||||
const interpreted = try interpreter_ui.finalize(try interpreter_view.build(&interpreter_ui, &model));
|
||||
var compiled_ui = InboxUi.init(arena);
|
||||
const compiled = try compiled_ui.finalize(DragContextMenuCompiled.build(&compiled_ui, &model));
|
||||
|
||||
try expectSameTree(fixture.Msg, interpreted, compiled);
|
||||
try expectSameTexts(interpreted.root, compiled.root);
|
||||
try testing.expect(compiled.root.semantics.actions.drag);
|
||||
try testing.expect(canvas.widgetClaimsPress(compiled.root));
|
||||
try testing.expectEqual(fixture.Msg.add, compiled.msgForContextMenu(compiled.root.id, 0).?);
|
||||
}
|
||||
|
||||
test "compiled context-menus build the interpreter's declared items and handler entries exactly" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
|
||||
@@ -510,6 +510,7 @@ test "the a11y lint: unnamed controls, radiogroups, and text entry are errors" {
|
||||
// select's face content.
|
||||
const clean = [_][]const u8{
|
||||
"<row>\n <checkbox on-toggle=\"select\">Done</checkbox>\n</row>",
|
||||
"<radio-group label=\"Density\">\n <radio checked=\"true\">Default</radio>\n</radio-group>",
|
||||
"<row>\n <checkbox text=\"Done\" on-toggle=\"select\" />\n</row>",
|
||||
"<row>\n <checkbox label=\"Done\" on-toggle=\"select\" />\n</row>",
|
||||
"<row>\n <button icon=\"trash\" label=\"Delete\" on-press=\"remove\"></button>\n</row>",
|
||||
@@ -639,7 +640,7 @@ test "collectA11yErrors reports every a11y error in one pass with validate's pos
|
||||
try testing.expectEqual(@as(usize, 0), markup.collectA11yErrors(try clean_parser.parse(), &storage).len);
|
||||
}
|
||||
|
||||
test "press and toggle handlers are legal on layout elements (press fall-through makes them pressable)" {
|
||||
test "press toggle and drag handlers are legal on layout elements (press fall-through makes them interactive)" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
@@ -651,6 +652,7 @@ test "press and toggle handlers are legal on layout elements (press fall-through
|
||||
"<column>\n <row on-press=\"select\" gap=\"8\">\n <text>press me</text>\n </row>\n</column>",
|
||||
"<column on-press=\"add\">\n <text>x</text>\n</column>",
|
||||
"<column>\n <stack on-toggle=\"flip\">\n <text>x</text>\n </stack>\n</column>",
|
||||
"<column>\n <row on-drag=\"drag:{open_count}\" label=\"Draggable\">\n <text>x</text>\n </row>\n</column>",
|
||||
"<row>\n <icon name=\"search\" on-press=\"go\" />\n</row>",
|
||||
"<row>\n <badge on-press=\"open\">3</badge>\n</row>",
|
||||
};
|
||||
|
||||
@@ -512,9 +512,15 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
|
||||
}
|
||||
}
|
||||
if (elementTakesText(kind) and !composite_children) {
|
||||
const text = try self.interpolatedText(ui, scope, inner);
|
||||
var built = ui.el(kind, options, .{});
|
||||
built.widget.text = text;
|
||||
// Element content wins when present; an empty element
|
||||
// keeps the already-resolved text= attribute in options.
|
||||
// Checkbox/radio joined this path when they became text
|
||||
// leaves, so replacing unconditionally would regress the
|
||||
// binding-friendly attribute that already worked.
|
||||
if (inner.children.len > 0) {
|
||||
built.widget.text = try self.interpolatedText(ui, scope, inner);
|
||||
}
|
||||
// Avatars clip their runtime image to the avatar circle,
|
||||
// exactly like `Ui.avatar` (a no-op while the id is 0 and
|
||||
// the initials fallback renders).
|
||||
|
||||
@@ -28,6 +28,16 @@ pub const Msg = union(enum) {
|
||||
toggle: u32,
|
||||
set_filter: Filter,
|
||||
draft: canvas.TextInputEvent,
|
||||
drag: DragPayload,
|
||||
};
|
||||
|
||||
pub const DragPayload = struct {
|
||||
sourceId: usize,
|
||||
phase: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewWidth: f32,
|
||||
viewHeight: f32,
|
||||
};
|
||||
|
||||
pub const Model = struct {
|
||||
@@ -1208,6 +1218,35 @@ pub const context_menu_markup_source =
|
||||
\\</column>
|
||||
;
|
||||
|
||||
pub const drag_context_menu_markup_source =
|
||||
\\<row on-drag="drag:{open_count}" label="Draggable row">
|
||||
\\ <text>Drag me</text>
|
||||
\\ <context-menu>
|
||||
\\ <menu-item on-press="add">Duplicate</menu-item>
|
||||
\\ </context-menu>
|
||||
\\</row>
|
||||
;
|
||||
|
||||
test "a drag-only layout element can host a context menu" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const model = testModel();
|
||||
|
||||
var parser = canvas.ui_markup.Parser.init(arena, drag_context_menu_markup_source);
|
||||
try testing.expectEqual(@as(?canvas.ui_markup.MarkupErrorInfo, null), canvas.ui_markup.validate(try parser.parse()));
|
||||
|
||||
var view = try InboxMarkup.init(arena, drag_context_menu_markup_source);
|
||||
var ui = InboxUi.init(arena);
|
||||
const tree = try ui.finalize(try view.build(&ui, &model));
|
||||
try testing.expect(tree.root.semantics.actions.drag);
|
||||
try testing.expect(canvas.widgetIsHitTarget(tree.root));
|
||||
try testing.expect(canvas.widgetClaimsPress(tree.root));
|
||||
try testing.expectEqual(@as(usize, 1), tree.root.context_menu.len);
|
||||
try testing.expectEqualStrings("Duplicate", tree.root.context_menu[0].label);
|
||||
try testing.expectEqual(Msg.add, tree.msgForContextMenu(tree.root.id, 0).?);
|
||||
}
|
||||
|
||||
test "markup context-menus lower to declared platform-menu items on their host" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
@@ -50,7 +50,7 @@ const std = @import("std");
|
||||
/// documents, journals, serialized contracts) carry it; readers reach
|
||||
/// older artifacts through document→document migrations, never silent
|
||||
/// reinterpretation.
|
||||
pub const schema_version: u16 = 1;
|
||||
pub const schema_version: u16 = 2;
|
||||
|
||||
/// Value type-class of an attribute: what shape of value the engines
|
||||
/// accept for it. `option` values name a Zig enum member (the enum itself
|
||||
@@ -128,8 +128,8 @@ pub const EventInfo = struct {
|
||||
/// ever receive this event.
|
||||
dismissible_only: bool = false,
|
||||
/// This handler binds control/text behavior a non-hit-target element
|
||||
/// does not have, so it is a dead handler there (press/toggle are
|
||||
/// exempt: a bound press handler makes any element pressable).
|
||||
/// does not have, so it is a dead handler there (press/toggle/drag
|
||||
/// are exempt: their handlers make any element interactive).
|
||||
dead_on_non_hit_target: bool = false,
|
||||
};
|
||||
|
||||
@@ -261,8 +261,8 @@ pub const elements = [_]ElementInfo{
|
||||
// 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 },
|
||||
.{ .code = 40, .name = "checkbox", .widget_kind = "checkbox", .takes_text = true, .a11y_name = .control },
|
||||
.{ .code = 41, .name = "radio", .widget_kind = "radio", .takes_text = true, .a11y_name = .control },
|
||||
.{ .code = 42, .name = "slider", .widget_kind = "slider", .a11y_name = .control },
|
||||
.{ .code = 43, .name = "progress", .widget_kind = "progress" },
|
||||
.{ .code = 44, .name = "text-field", .widget_kind = "text_field", .a11y_name = .editable },
|
||||
|
||||
@@ -174,15 +174,15 @@ pub fn widgetKindClaimsPress(kind: WidgetKind) bool {
|
||||
|
||||
/// Whether a press gesture stops at this widget instead of falling
|
||||
/// through to the nearest claiming ancestor: an interactive kind
|
||||
/// (`widgetKindClaimsPress`), or ANY widget with a bound press/toggle
|
||||
/// handler (`on_press`/`on_toggle` stamp `semantics.actions`, and
|
||||
/// (`widgetKindClaimsPress`), or ANY widget with a bound press/toggle/drag
|
||||
/// handler (`on_press`/`on_toggle`/`on_drag` stamp `semantics.actions`, and
|
||||
/// engine-owned `command` dispatch only exists on kinds already claiming).
|
||||
/// Disabled widgets never claim — the hit test skips them too, so a press
|
||||
/// on a disabled control keeps today's behavior of landing on whatever is
|
||||
/// around it.
|
||||
pub fn widgetClaimsPress(widget: Widget) bool {
|
||||
if (widget.id == 0 or widget.state.disabled) return false;
|
||||
if (widget.semantics.actions.press or widget.semantics.actions.toggle) return true;
|
||||
if (widget.semantics.actions.press or widget.semantics.actions.toggle or widget.semantics.actions.drag) return true;
|
||||
return widgetKindClaimsPress(widget.kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -438,15 +438,16 @@ test "icon widgets render built-in vector icons as tinted path commands" {
|
||||
}
|
||||
}
|
||||
|
||||
test "checkbox check mark strokes one anti-aliased vector path" {
|
||||
test "checkbox check mark and label render through their pinned part slots" {
|
||||
const checkbox = Widget{
|
||||
.id = 63,
|
||||
.kind = WidgetKind.checkbox,
|
||||
.frame = geometry.RectF.init(0, 0, 24, 24),
|
||||
.frame = geometry.RectF.init(0, 0, 80, 24),
|
||||
.value = 1,
|
||||
.text = "Done",
|
||||
};
|
||||
const tokens = DesignTokens{};
|
||||
var commands: [8]CanvasCommand = undefined;
|
||||
var commands: [9]CanvasCommand = undefined;
|
||||
var builder = Builder.init(&commands);
|
||||
try emitWidgetTree(&builder, checkbox, tokens);
|
||||
const display_list = builder.displayList();
|
||||
@@ -465,18 +466,22 @@ test "checkbox check mark strokes one anti-aliased vector path" {
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (display_list.findCommandById(widgetPartId(63, 6)).?.command) {
|
||||
.draw_text => |text| try std.testing.expectEqualStrings("Done", text.text),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
// Rasterized, the diagonal carries partial-coverage edge pixels —
|
||||
// anti-aliasing a binary point-in-capsule test can never produce —
|
||||
// and the whole render pins byte-identical.
|
||||
var render_commands: [8]RenderCommand = undefined;
|
||||
// Rasterized, the diagonal carries partial-coverage edge pixels and
|
||||
// the label is present at its stable part slot. The whole 80x24
|
||||
// checkbox-plus-label render pins byte-identical.
|
||||
var render_commands: [9]RenderCommand = undefined;
|
||||
const plan = try (DisplayList{ .commands = display_list.commands }).renderPlan(&render_commands);
|
||||
var pixels: [24 * 24 * 4]u8 = undefined;
|
||||
var pixels: [80 * 24 * 4]u8 = undefined;
|
||||
@memset(&pixels, 0);
|
||||
const surface = try ReferenceRenderSurface.init(24, 24, &pixels);
|
||||
const surface = try ReferenceRenderSurface.init(80, 24, &pixels);
|
||||
try surface.renderPass(.{
|
||||
.commands = plan.commands,
|
||||
.surface_size = geometry.SizeF.init(24, 24),
|
||||
.surface_size = geometry.SizeF.init(80, 24),
|
||||
.full_repaint = true,
|
||||
}, Color.rgb8(255, 255, 255));
|
||||
// Sample strictly inside the accent fill (the 16px box spans y 4-20;
|
||||
@@ -487,12 +492,12 @@ test "checkbox check mark strokes one anti-aliased vector path" {
|
||||
while (y < 18) : (y += 1) {
|
||||
var x: usize = 2;
|
||||
while (x < 14) : (x += 1) {
|
||||
const value = pixels[(y * 24 + x) * 4];
|
||||
const value = pixels[(y * 80 + x) * 4];
|
||||
if (value > 60 and value < 200) partial += 1;
|
||||
}
|
||||
}
|
||||
try std.testing.expect(partial >= 4);
|
||||
try std.testing.expectEqual(@as(u64, 10271374105851145327), support.referenceSurfaceSignature(&pixels));
|
||||
try std.testing.expectEqual(@as(u64, 516383874562490834), support.referenceSurfaceSignature(&pixels));
|
||||
}
|
||||
|
||||
test "a builder accumulating two widget trees keeps each checkbox mark's own geometry" {
|
||||
@@ -545,6 +550,240 @@ test "a builder accumulating two widget trees keeps each checkbox mark's own geo
|
||||
}
|
||||
}
|
||||
|
||||
test "compact radio and switch chrome stays circular and disabled selection fills wash" {
|
||||
const tokens = DesignTokens{};
|
||||
const disabled_fill = Color.rgba(
|
||||
tokens.colors.surface.r,
|
||||
tokens.colors.surface.g,
|
||||
tokens.colors.surface.b,
|
||||
tokens.colors.surface.a * tokens.states.disabled_alpha,
|
||||
);
|
||||
const controls = [_]struct { kind: WidgetKind, frame: geometry.RectF }{
|
||||
.{ .kind = .checkbox, .frame = geometry.RectF.init(0, 0, 80, 28) },
|
||||
.{ .kind = .radio, .frame = geometry.RectF.init(0, 0, 80, 28) },
|
||||
};
|
||||
for (controls) |control| {
|
||||
const widget = Widget{
|
||||
.id = 67,
|
||||
.kind = control.kind,
|
||||
.frame = control.frame,
|
||||
.size = .sm,
|
||||
.state = .{ .disabled = true },
|
||||
};
|
||||
var commands: [8]CanvasCommand = undefined;
|
||||
var builder = Builder.init(&commands);
|
||||
try emitWidgetTree(&builder, widget, tokens);
|
||||
switch (builder.displayList().findCommandById(widgetPartId(67, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| {
|
||||
try expectFillColor(disabled_fill, fill.fill);
|
||||
if (control.kind == .radio) {
|
||||
try std.testing.expectApproxEqAbs(fill.rect.height * 0.5, fill.radius.top_left, 0.001);
|
||||
}
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = Widget{
|
||||
.id = 68,
|
||||
.kind = .switch_control,
|
||||
.frame = geometry.RectF.init(0, 0, 80, 28),
|
||||
.size = .sm,
|
||||
};
|
||||
var toggle_commands: [8]CanvasCommand = undefined;
|
||||
var toggle_builder = Builder.init(&toggle_commands);
|
||||
try emitWidgetTree(&toggle_builder, toggle, tokens);
|
||||
switch (toggle_builder.displayList().findCommandById(widgetPartId(68, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try std.testing.expectApproxEqAbs(fill.rect.height * 0.5, fill.radius.top_left, 0.001),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (toggle_builder.displayList().findCommandById(widgetPartId(68, 3)).?.command) {
|
||||
.fill_rounded_rect => |fill| try std.testing.expectApproxEqAbs(fill.rect.height * 0.5, fill.radius.top_left, 0.001),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "selection controls honor explicit disabled background and foreground tokens" {
|
||||
const disabled_background = Color.rgb8(34, 68, 102);
|
||||
const disabled_foreground = Color.rgb8(238, 204, 170);
|
||||
const disabled_border = Color.rgb8(51, 119, 85);
|
||||
const tokens = DesignTokens{ .controls = .{
|
||||
.checkbox = .{ .disabled_background = disabled_background, .disabled_foreground = disabled_foreground, .border = disabled_border },
|
||||
.radio = .{ .disabled_background = disabled_background, .disabled_foreground = disabled_foreground, .border = disabled_border },
|
||||
.switch_control = .{ .disabled_background = disabled_background, .disabled_foreground = disabled_foreground, .border = disabled_border },
|
||||
} };
|
||||
const cases = [_]struct {
|
||||
kind: WidgetKind,
|
||||
foreground_slot: ObjectId,
|
||||
label_slot: ObjectId,
|
||||
}{
|
||||
.{ .kind = .checkbox, .foreground_slot = 4, .label_slot = 6 },
|
||||
.{ .kind = .radio, .foreground_slot = 4, .label_slot = 5 },
|
||||
.{ .kind = .switch_control, .foreground_slot = 3, .label_slot = 5 },
|
||||
};
|
||||
for (cases, 0..) |case, index| {
|
||||
const id: ObjectId = @intCast(80 + index);
|
||||
const widget = Widget{
|
||||
.id = id,
|
||||
.kind = case.kind,
|
||||
.frame = geometry.RectF.init(0, 0, 120, 28),
|
||||
.text = "Disabled",
|
||||
.value = 1,
|
||||
.state = .{ .disabled = true },
|
||||
};
|
||||
var commands: [10]CanvasCommand = undefined;
|
||||
var builder = Builder.init(&commands);
|
||||
try emitWidgetTree(&builder, widget, tokens);
|
||||
const list = builder.displayList();
|
||||
switch (list.findCommandById(widgetPartId(id, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(disabled_background, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (list.findCommandById(widgetPartId(id, 2)).?.command) {
|
||||
.stroke_rect => |stroke| try expectFillColor(disabled_border, stroke.stroke.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (list.findCommandById(widgetPartId(id, case.foreground_slot)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(disabled_foreground, fill.fill),
|
||||
.stroke_path => |stroke| try expectFillColor(disabled_foreground, stroke.stroke.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (list.findCommandById(widgetPartId(id, case.label_slot)).?.command) {
|
||||
.draw_text => |text| try std.testing.expectEqualDeep(disabled_foreground, text.color),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
// Like the slider register, stating either disabled channel opts the
|
||||
// control into color-swap mode: an unstated counterpart stays at full
|
||||
// rest strength instead of being alpha-washed behind the swap.
|
||||
const background_only_tokens = DesignTokens{ .controls = .{
|
||||
.checkbox = .{ .disabled_background = disabled_background },
|
||||
} };
|
||||
const background_only = Widget{
|
||||
.id = 83,
|
||||
.kind = .checkbox,
|
||||
.frame = geometry.RectF.init(0, 0, 120, 28),
|
||||
.text = "Background only",
|
||||
.value = 1,
|
||||
.state = .{ .disabled = true },
|
||||
};
|
||||
var background_only_commands: [10]CanvasCommand = undefined;
|
||||
var background_only_builder = Builder.init(&background_only_commands);
|
||||
try emitWidgetTree(&background_only_builder, background_only, background_only_tokens);
|
||||
switch (background_only_builder.displayList().findCommandById(widgetPartId(83, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(disabled_background, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (background_only_builder.displayList().findCommandById(widgetPartId(83, 4)).?.command) {
|
||||
.stroke_path => |stroke| try expectFillColor(background_only_tokens.colors.accent_text, stroke.stroke.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
const foreground_only_tokens = DesignTokens{ .controls = .{
|
||||
.checkbox = .{ .disabled_foreground = disabled_foreground },
|
||||
} };
|
||||
var foreground_only_commands: [10]CanvasCommand = undefined;
|
||||
var foreground_only_builder = Builder.init(&foreground_only_commands);
|
||||
try emitWidgetTree(&foreground_only_builder, background_only, foreground_only_tokens);
|
||||
switch (foreground_only_builder.displayList().findCommandById(widgetPartId(83, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(foreground_only_tokens.colors.accent, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (foreground_only_builder.displayList().findCommandById(widgetPartId(83, 4)).?.command) {
|
||||
.stroke_path => |stroke| try expectFillColor(disabled_foreground, stroke.stroke.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "radio and switch shape overrides beat circular house fallbacks" {
|
||||
const tokens = DesignTokens{
|
||||
.controls = .{
|
||||
.radio = .{ .radius = 5 },
|
||||
.switch_control = .{ .radius = 4 },
|
||||
},
|
||||
};
|
||||
const themed_radio = Widget{
|
||||
.id = 70,
|
||||
.kind = .radio,
|
||||
.frame = geometry.RectF.init(0, 0, 80, 28),
|
||||
};
|
||||
var themed_radio_commands: [8]CanvasCommand = undefined;
|
||||
var themed_radio_builder = Builder.init(&themed_radio_commands);
|
||||
try emitWidgetTree(&themed_radio_builder, themed_radio, tokens);
|
||||
switch (themed_radio_builder.displayList().findCommandById(widgetPartId(70, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(5), fill.radius),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
const authored_radio = Widget{
|
||||
.id = 72,
|
||||
.kind = .radio,
|
||||
.frame = geometry.RectF.init(0, 0, 80, 28),
|
||||
.style = .{ .radius = 3 },
|
||||
};
|
||||
var authored_radio_commands: [8]CanvasCommand = undefined;
|
||||
var authored_radio_builder = Builder.init(&authored_radio_commands);
|
||||
try emitWidgetTree(&authored_radio_builder, authored_radio, tokens);
|
||||
switch (authored_radio_builder.displayList().findCommandById(widgetPartId(72, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(3), fill.radius),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
const toggle = Widget{
|
||||
.id = 71,
|
||||
.kind = .switch_control,
|
||||
.frame = geometry.RectF.init(0, 0, 80, 28),
|
||||
};
|
||||
var toggle_commands: [8]CanvasCommand = undefined;
|
||||
var toggle_builder = Builder.init(&toggle_commands);
|
||||
try emitWidgetTree(&toggle_builder, toggle, tokens);
|
||||
inline for (.{ @as(ObjectId, 1), @as(ObjectId, 3) }) |slot| {
|
||||
switch (toggle_builder.displayList().findCommandById(widgetPartId(71, slot)).?.command) {
|
||||
.fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(4), fill.radius),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
var authored_toggle = toggle;
|
||||
authored_toggle.id = 73;
|
||||
authored_toggle.style.radius = 2;
|
||||
var authored_toggle_commands: [8]CanvasCommand = undefined;
|
||||
var authored_toggle_builder = Builder.init(&authored_toggle_commands);
|
||||
try emitWidgetTree(&authored_toggle_builder, authored_toggle, tokens);
|
||||
inline for (.{ @as(ObjectId, 1), @as(ObjectId, 3) }) |slot| {
|
||||
switch (authored_toggle_builder.displayList().findCommandById(widgetPartId(73, slot)).?.command) {
|
||||
.fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(2), fill.radius),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "an off switch keeps authored track and thumb colors on separate channels" {
|
||||
const track_color = Color.rgb8(12, 34, 56);
|
||||
const thumb_color = Color.rgb8(240, 244, 248);
|
||||
const toggle = Widget{
|
||||
.id = 69,
|
||||
.kind = .switch_control,
|
||||
.frame = geometry.RectF.init(0, 0, 80, 32),
|
||||
.style = .{
|
||||
.background = track_color,
|
||||
.accent_foreground = thumb_color,
|
||||
},
|
||||
};
|
||||
var commands: [8]CanvasCommand = undefined;
|
||||
var builder = Builder.init(&commands);
|
||||
try emitWidgetTree(&builder, toggle, .{});
|
||||
switch (builder.displayList().findCommandById(widgetPartId(69, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(track_color, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (builder.displayList().findCommandById(widgetPartId(69, 3)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(thumb_color, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "app-registered icons draw through the widget paths like built-ins" {
|
||||
var buffer = canvas.svg_icon.IconBuffer{};
|
||||
const parsed = try canvas.svg_icon.parse(
|
||||
@@ -693,7 +932,7 @@ test "disabled filled buttons match their reference edge treatment" {
|
||||
// the fill and fallback edge are each washed to half strength, their
|
||||
// overlap becomes darker and invents an outline around the disabled
|
||||
// control. Destructive is already the quiet borderless chip, so its
|
||||
// edge stays at width 0 in both states.
|
||||
// edge emits no command in either state.
|
||||
const tokens = DesignTokens{};
|
||||
const button = Widget{
|
||||
.id = 71,
|
||||
@@ -741,10 +980,7 @@ test "disabled filled buttons match their reference edge treatment" {
|
||||
var chip_commands: [8]CanvasCommand = undefined;
|
||||
var chip_builder = Builder.init(&chip_commands);
|
||||
try emitWidgetTree(&chip_builder, chip, tokens);
|
||||
switch (chip_builder.displayList().findCommandById(widgetPartId(71, 2)).?.command) {
|
||||
.stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
try std.testing.expect(chip_builder.displayList().findCommandById(widgetPartId(71, 2)) == null);
|
||||
}
|
||||
|
||||
test "geist disabled buttons use the reference swap and tertiary registers" {
|
||||
@@ -855,6 +1091,66 @@ test "button disabled border override does not require a disabled background" {
|
||||
try expectFillColor(disabled_border, buttonBorderFill(button, tokens));
|
||||
}
|
||||
|
||||
test "disabled colors wash resolved identity and secondary accents use the accent channel" {
|
||||
const style_mod = @import("widget_render_style.zig");
|
||||
const tokens = DesignTokens{};
|
||||
const accent = Color.rgb8(180, 24, 48);
|
||||
const accent_foreground = Color.rgb8(250, 226, 232);
|
||||
const foreground = Color.rgb8(36, 92, 148);
|
||||
const background = Color.rgb8(24, 56, 88);
|
||||
const washed_accent = Color.rgba(accent.r, accent.g, accent.b, tokens.states.disabled_alpha);
|
||||
const washed_accent_foreground = Color.rgba(accent_foreground.r, accent_foreground.g, accent_foreground.b, tokens.states.disabled_alpha);
|
||||
const washed_foreground = Color.rgba(foreground.r, foreground.g, foreground.b, tokens.states.disabled_alpha);
|
||||
const washed_background = Color.rgba(background.r, background.g, background.b, tokens.states.disabled_alpha);
|
||||
|
||||
const disabled = Widget{
|
||||
.kind = .text,
|
||||
.state = .{ .disabled = true },
|
||||
.style = .{
|
||||
.foreground = foreground,
|
||||
.accent_foreground = accent_foreground,
|
||||
},
|
||||
};
|
||||
try std.testing.expectEqualDeep(washed_foreground, style_mod.widgetForegroundColor(disabled, tokens, tokens.colors.text));
|
||||
try std.testing.expectEqualDeep(washed_accent_foreground, style_mod.widgetAccentForegroundColor(disabled, tokens, tokens.colors.accent_text));
|
||||
|
||||
const secondary = Widget{
|
||||
.kind = .button,
|
||||
.variant = .secondary,
|
||||
.style = .{ .accent = accent },
|
||||
};
|
||||
try std.testing.expectEqualDeep(accent, style_mod.buttonFillColor(secondary, tokens));
|
||||
try expectFillColor(accent, style_mod.buttonBorderFill(secondary, tokens));
|
||||
|
||||
const disabled_input = Widget{
|
||||
.kind = .text_field,
|
||||
.state = .{ .disabled = true },
|
||||
.style = .{ .background = background },
|
||||
};
|
||||
try expectFillColor(washed_background, style_mod.textInputFill(disabled_input, tokens, .{}));
|
||||
|
||||
const secondary_badge = Widget{
|
||||
.kind = .badge,
|
||||
.variant = .secondary,
|
||||
.style = .{ .accent = accent },
|
||||
};
|
||||
try std.testing.expectEqualDeep(accent, style_mod.badgeBackgroundColor(secondary_badge, tokens, .{}));
|
||||
try std.testing.expectEqualDeep(accent, style_mod.badgeBorderColor(secondary_badge, tokens, .{}));
|
||||
|
||||
const disabled_destructive_badge = Widget{
|
||||
.kind = .badge,
|
||||
.variant = .destructive,
|
||||
.state = .{ .disabled = true },
|
||||
.style = .{
|
||||
.accent = accent,
|
||||
.accent_foreground = accent_foreground,
|
||||
},
|
||||
};
|
||||
try std.testing.expectEqualDeep(washed_accent, style_mod.badgeBackgroundColor(disabled_destructive_badge, tokens, .{}));
|
||||
try std.testing.expectEqualDeep(washed_accent, style_mod.badgeBorderColor(disabled_destructive_badge, tokens, .{}));
|
||||
try std.testing.expectEqualDeep(washed_accent_foreground, style_mod.badgeTextColor(disabled_destructive_badge, tokens, .{}));
|
||||
}
|
||||
|
||||
/// The flush-group segment assertions, shared by the tree-walk and
|
||||
/// layout-walk halves of the test below so the two emission paths are
|
||||
/// pinned to the SAME bar: leading corners on the first segment,
|
||||
@@ -984,11 +1280,8 @@ test "detached button groups render chip members with the group table and the me
|
||||
// No seam clips anywhere: a chip has no shared boundary.
|
||||
try std.testing.expect(list.findCommandById(widgetPartId(2, 0)) == null);
|
||||
try std.testing.expect(list.findCommandById(widgetPartId(3, 0)) == null);
|
||||
// The chips are borderless: the border stroke carries zero width.
|
||||
switch (list.findCommandById(widgetPartId(2, 2)).?.command) {
|
||||
.stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
// The chips are borderless: no dead zero-width stroke command.
|
||||
try std.testing.expect(list.findCommandById(widgetPartId(2, 2)) == null);
|
||||
// Knockout ink on the selected label, the stated rest ink elsewhere.
|
||||
switch (list.findCommandById(widgetPartId(2, 4)).?.command) {
|
||||
.draw_text => |text| try std.testing.expectEqualDeep(Color.rgb8(255, 255, 255), text.color),
|
||||
@@ -1821,6 +2114,176 @@ test "the quiet-surface knob silences the hover wash and nothing else" {
|
||||
try std.testing.expect(focused_builder.displayList().findCommandById(widgetPartId(74, 2)) != null);
|
||||
}
|
||||
|
||||
test "actionable layout containers paint the row hover and pressed ladder in both emit paths" {
|
||||
const tokens = DesignTokens{};
|
||||
inline for (.{ WidgetKind.row, WidgetKind.column, WidgetKind.stack }) |kind| {
|
||||
const base = Widget{
|
||||
.id = 75,
|
||||
.kind = kind,
|
||||
.frame = geometry.RectF.init(0, 0, 160, 40),
|
||||
.semantics = .{ .actions = .{ .press = true } },
|
||||
};
|
||||
|
||||
var hovered = base;
|
||||
hovered.state.hovered = true;
|
||||
var commands: [4]CanvasCommand = undefined;
|
||||
var builder = Builder.init(&commands);
|
||||
try emitWidgetTree(&builder, hovered, tokens);
|
||||
switch (builder.displayList().findCommandById(widgetPartId(75, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
// The laid-out/live emitter resolves the same state and part slot.
|
||||
var nodes: [1]WidgetLayoutNode = undefined;
|
||||
const layout = try layoutWidgetTree(hovered, hovered.frame, &nodes);
|
||||
var layout_commands: [4]CanvasCommand = undefined;
|
||||
var layout_builder = Builder.init(&layout_commands);
|
||||
try layout.emitDisplayList(&layout_builder, tokens);
|
||||
switch (layout_builder.displayList().findCommandById(widgetPartId(75, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
var pressed = base;
|
||||
pressed.state.pressed = true;
|
||||
var pressed_commands: [4]CanvasCommand = undefined;
|
||||
var pressed_builder = Builder.init(&pressed_commands);
|
||||
try emitWidgetTree(&pressed_builder, pressed, tokens);
|
||||
switch (pressed_builder.displayList().findCommandById(widgetPartId(75, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_pressed, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
var quiet = hovered;
|
||||
quiet.style.quiet_hover = true;
|
||||
var quiet_commands: [4]CanvasCommand = undefined;
|
||||
var quiet_builder = Builder.init(&quiet_commands);
|
||||
try emitWidgetTree(&quiet_builder, quiet, tokens);
|
||||
try std.testing.expect(quiet_builder.displayList().findCommandById(widgetPartId(75, 1)) == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "drag-only layout containers own hover and pressed washes through child content" {
|
||||
const tokens = DesignTokens{};
|
||||
const label = [_]Widget{.{
|
||||
.id = 79,
|
||||
.kind = .text,
|
||||
.text = "Drag me",
|
||||
}};
|
||||
const draggable = Widget{
|
||||
.id = 78,
|
||||
.kind = .row,
|
||||
.frame = geometry.RectF.init(0, 0, 160, 40),
|
||||
.semantics = .{ .actions = .{ .drag = true } },
|
||||
.children = &label,
|
||||
};
|
||||
var nodes: [2]WidgetLayoutNode = undefined;
|
||||
const layout = try layoutWidgetTree(draggable, draggable.frame, &nodes);
|
||||
const label_node = layout.findById(79) orelse return error.TestUnexpectedResult;
|
||||
const raw_hit = layout.hitTest(label_node.frame.center()) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(ObjectId, 79), raw_hit.id);
|
||||
const hover_target = layout.hoverTargetForHit(raw_hit) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(ObjectId, 78), hover_target.id);
|
||||
const press_target = canvas.widgetPressTargetForHit(layout, raw_hit) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(ObjectId, 78), press_target.id);
|
||||
|
||||
var hover_commands: [4]CanvasCommand = undefined;
|
||||
var hover_builder = Builder.init(&hover_commands);
|
||||
try layout.emitDisplayListWithState(&hover_builder, tokens, .{ .hovered_id = hover_target.id });
|
||||
switch (hover_builder.displayList().findCommandById(widgetPartId(78, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
var press_commands: [4]CanvasCommand = undefined;
|
||||
var press_builder = Builder.init(&press_commands);
|
||||
try layout.emitDisplayListWithState(&press_builder, tokens, .{ .pressed_id = press_target.id });
|
||||
switch (press_builder.displayList().findCommandById(widgetPartId(78, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_pressed, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "an actionable container uses authored background only at rest" {
|
||||
const authored = Color.rgb8(28, 52, 76);
|
||||
const tokens = DesignTokens{};
|
||||
const base = Widget{
|
||||
.id = 77,
|
||||
.kind = .row,
|
||||
.frame = geometry.RectF.init(0, 0, 160, 40),
|
||||
.style = .{ .background = authored },
|
||||
.semantics = .{ .actions = .{ .press = true } },
|
||||
};
|
||||
var rest_commands: [4]CanvasCommand = undefined;
|
||||
var rest_builder = Builder.init(&rest_commands);
|
||||
try emitWidgetTree(&rest_builder, base, tokens);
|
||||
switch (rest_builder.displayList().findCommandById(widgetPartId(77, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(authored, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
var hovered = base;
|
||||
hovered.state.hovered = true;
|
||||
var hover_commands: [4]CanvasCommand = undefined;
|
||||
var hover_builder = Builder.init(&hover_commands);
|
||||
try emitWidgetTree(&hover_builder, hovered, tokens);
|
||||
switch (hover_builder.displayList().findCommandById(widgetPartId(77, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "house alert card and panel surfaces have visible hover and pressed fallbacks" {
|
||||
const themed_rest = Color.rgb8(28, 52, 76);
|
||||
const tokens = DesignTokens{ .controls = .{
|
||||
.alert = .{ .background = themed_rest },
|
||||
.card = .{ .background = themed_rest },
|
||||
.panel = .{ .background = themed_rest },
|
||||
} };
|
||||
const cases = [_]struct { kind: WidgetKind, fill_slot: u4 }{
|
||||
.{ .kind = .alert, .fill_slot = 1 },
|
||||
.{ .kind = .card, .fill_slot = 1 },
|
||||
.{ .kind = .panel, .fill_slot = 2 },
|
||||
};
|
||||
for (cases) |case| {
|
||||
const base = Widget{
|
||||
.id = 76,
|
||||
.kind = case.kind,
|
||||
.frame = geometry.RectF.init(0, 0, 160, 48),
|
||||
.semantics = .{ .actions = .{ .press = true } },
|
||||
};
|
||||
|
||||
var rest_commands: [8]CanvasCommand = undefined;
|
||||
var rest_builder = Builder.init(&rest_commands);
|
||||
try emitWidgetTree(&rest_builder, base, tokens);
|
||||
switch (rest_builder.displayList().findCommandById(widgetPartId(76, case.fill_slot)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(themed_rest, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
var hovered = base;
|
||||
hovered.state.hovered = true;
|
||||
var hovered_commands: [8]CanvasCommand = undefined;
|
||||
var hovered_builder = Builder.init(&hovered_commands);
|
||||
try emitWidgetTree(&hovered_builder, hovered, tokens);
|
||||
switch (hovered_builder.displayList().findCommandById(widgetPartId(76, case.fill_slot)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
var pressed = base;
|
||||
pressed.state.pressed = true;
|
||||
var pressed_commands: [8]CanvasCommand = undefined;
|
||||
var pressed_builder = Builder.init(&pressed_commands);
|
||||
try emitWidgetTree(&pressed_builder, pressed, tokens);
|
||||
switch (pressed_builder.displayList().findCommandById(widgetPartId(76, case.fill_slot)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_pressed, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "icon buttons draw registry names as vector icons and keep the glyph fallback" {
|
||||
const tokens = DesignTokens{};
|
||||
const vector = Widget{
|
||||
@@ -2524,6 +2987,85 @@ test "built-in alert renders house surface chrome and text" {
|
||||
}
|
||||
}
|
||||
|
||||
test "destructive alerts tint every channel and compact defaults align chrome with content" {
|
||||
const tokens = DesignTokens{
|
||||
.controls = .{
|
||||
// One shared alert table may state neutral defaults; the
|
||||
// destructive variant still owns its hue.
|
||||
.alert = .{
|
||||
.background = Color.rgb8(12, 18, 24),
|
||||
.foreground = Color.rgb8(235, 240, 245),
|
||||
.border = Color.rgb8(54, 64, 74),
|
||||
},
|
||||
},
|
||||
};
|
||||
const destructive = Widget{
|
||||
.id = 42,
|
||||
.kind = .alert,
|
||||
.frame = geometry.RectF.init(0, 0, 320, 68),
|
||||
.text = "Session expired",
|
||||
.variant = .destructive,
|
||||
.state = .{ .disabled = true },
|
||||
.style = .{ .accent = Color.rgb8(196, 32, 64) },
|
||||
};
|
||||
var destructive_commands: [12]CanvasCommand = undefined;
|
||||
var destructive_builder = Builder.init(&destructive_commands);
|
||||
try emitWidgetTree(&destructive_builder, destructive, tokens);
|
||||
const destructive_hue = destructive.style.accent.?;
|
||||
const destructive_fill = colorWithAlpha(destructive_hue, tokens.states.destructive_wash_alpha * tokens.states.disabled_alpha);
|
||||
const destructive_border = colorWithAlpha(destructive_hue, 0.5 * tokens.states.disabled_alpha);
|
||||
const destructive_ink = colorWithAlpha(destructive_hue, tokens.states.disabled_alpha);
|
||||
switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 1)).?.command) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(destructive_fill, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 2)).?.command) {
|
||||
.stroke_rect => |stroke| try expectFillColor(destructive_border, stroke.stroke.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 4)).?.command) {
|
||||
.stroke_path => |stroke| try expectFillColor(destructive_ink, stroke.stroke.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 10)).?.command) {
|
||||
.draw_text => |text| try std.testing.expectEqualDeep(destructive_ink, text.color),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
const description = [_]Widget{.{
|
||||
.id = 44,
|
||||
.kind = .text,
|
||||
.text = "Restart to update.",
|
||||
}};
|
||||
const compact = builtinComponentWidget(.alert, .{
|
||||
.id = 43,
|
||||
.frame = geometry.RectF.init(0, 0, 320, 80),
|
||||
.text = "Update available",
|
||||
.size = .sm,
|
||||
.children = &description,
|
||||
});
|
||||
// Stored kind default and token-resolved chrome share the 14px compact
|
||||
// inset; the 14px multiplicatively scaled icon plus 10px additive
|
||||
// text gap put title and child at x=38. The semantic title gap stays
|
||||
// 4px rather than shrinking with control chrome.
|
||||
try std.testing.expectEqual(@as(f32, 14), compact.layout.padding.top);
|
||||
try std.testing.expect(compact.layout.padding_is_kind_default);
|
||||
var compact_nodes: [2]WidgetLayoutNode = undefined;
|
||||
const compact_layout = try layoutWidgetTree(compact, compact.frame, &compact_nodes);
|
||||
const child = compact_layout.findById(44) orelse return error.TestUnexpectedResult;
|
||||
const text_size = widget_metrics.widgetBodyTextSize(compact, .{});
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 38), child.frame.x, 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 14) + widget_metrics.widgetLineHeight(text_size) + 4, child.frame.y, 0.001);
|
||||
|
||||
var compact_commands: [16]CanvasCommand = undefined;
|
||||
var compact_builder = Builder.init(&compact_commands);
|
||||
try compact_layout.emitDisplayList(&compact_builder, .{});
|
||||
switch (compact_builder.displayList().findCommandById(widgetPartId(43, 10)).?.command) {
|
||||
.draw_text => |text| try std.testing.expectApproxEqAbs(child.frame.x, text.origin.x, 0.001),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "built-in card renders house surface chrome and title" {
|
||||
const card = builtinComponentWidget(.card, .{
|
||||
.id = 44,
|
||||
@@ -3136,7 +3678,10 @@ test "built-in component primitive widgets render distinct house chrome" {
|
||||
try layout.emitDisplayList(&builder, .{});
|
||||
|
||||
const display_list = builder.displayList();
|
||||
try std.testing.expectEqual(@as(usize, 9), display_list.commandCount());
|
||||
// Eight live commands: avatar fill/text/stroke, badge fill/text (its
|
||||
// default zero-width edge emits nothing), separator, skeleton, and
|
||||
// spinner. This count deliberately excludes dead rasterizer work.
|
||||
try std.testing.expectEqual(@as(usize, 8), display_list.commandCount());
|
||||
try std.testing.expect(display_list.commands[0] == .fill_rounded_rect);
|
||||
switch (display_list.commands[1]) {
|
||||
.draw_text => |text| try std.testing.expectEqualStrings("NS", text.text),
|
||||
@@ -3147,16 +3692,16 @@ test "built-in component primitive widgets render distinct house chrome" {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(ColorTokens.light().accent, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (display_list.commands[5]) {
|
||||
switch (display_list.commands[4]) {
|
||||
.draw_text => |text| try std.testing.expectEqualStrings("Beta", text.text),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
try std.testing.expect(display_list.commands[6] == .fill_rect);
|
||||
try std.testing.expect(display_list.commands[7] == .fill_rounded_rect);
|
||||
try std.testing.expect(display_list.commands[5] == .fill_rect);
|
||||
try std.testing.expect(display_list.commands[6] == .fill_rounded_rect);
|
||||
// The spinner (house arc register): ONE stroked arc in the page
|
||||
// ink — no track — with the stroke scaling at 1/12 of the box
|
||||
// (28px box -> 2.333px stroke).
|
||||
switch (display_list.commands[8]) {
|
||||
switch (display_list.commands[7]) {
|
||||
.stroke_path => |arc| {
|
||||
try expectFillColor(ColorTokens.light().text, arc.stroke.fill);
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 28.0 * 2.0 / 24.0), arc.stroke.width, 0.001);
|
||||
|
||||
@@ -45,6 +45,7 @@ const widgetLineHeight = widget_metrics.widgetLineHeight;
|
||||
const widgetDefaultRowHeight = widget_metrics.widgetDefaultRowHeight;
|
||||
const widgetButtonInset = widget_metrics.widgetButtonInset;
|
||||
const widgetControlInset = widget_metrics.widgetControlInset;
|
||||
const widgetAlertInset = widget_metrics.widgetAlertInset;
|
||||
const widgetSizedDensityValue = widget_metrics.widgetSizedDensityValue;
|
||||
const densityValue = widget_metrics.densityValue;
|
||||
const widgetControlHeight = widget_metrics.widgetControlHeight;
|
||||
@@ -82,7 +83,7 @@ pub fn layoutWidgetDepth(
|
||||
};
|
||||
len.* += 1;
|
||||
|
||||
const layout_padding = if (widget.kind == .tabs) tabsLayoutPadding(widget, tokens) else widget.layout.padding;
|
||||
const layout_padding = widgetLayoutPadding(widget, tokens);
|
||||
const content = windowControlsClearedContent(frame.inset(layout_padding), widget, tokens);
|
||||
switch (widget.kind) {
|
||||
.row, .breadcrumb, .pagination, .radio_group, .toggle_group => try layoutAxisChildren(widget.children, content, .horizontal, index, depth, output, len, widget.layout, tokens),
|
||||
@@ -674,6 +675,19 @@ fn tabsLayoutPadding(widget: Widget, tokens: DesignTokens) geometry.InsetsF {
|
||||
return geometry.InsetsF.all(underlineTabsListInset(tokens));
|
||||
}
|
||||
|
||||
fn alertLayoutPadding(widget: Widget, tokens: DesignTokens) geometry.InsetsF {
|
||||
if (!widget.layout.padding_is_kind_default) return widget.layout.padding;
|
||||
return geometry.InsetsF.all(widgetAlertInset(widget, tokens));
|
||||
}
|
||||
|
||||
fn widgetLayoutPadding(widget: Widget, tokens: DesignTokens) geometry.InsetsF {
|
||||
return switch (widget.kind) {
|
||||
.tabs => tabsLayoutPadding(widget, tokens),
|
||||
.alert => alertLayoutPadding(widget, tokens),
|
||||
else => widget.layout.padding,
|
||||
};
|
||||
}
|
||||
|
||||
fn layoutAxisChildren(
|
||||
children: []const Widget,
|
||||
content: geometry.RectF,
|
||||
@@ -994,7 +1008,7 @@ fn widgetSubtreeHasTextSpans(widget: Widget, depth: usize) bool {
|
||||
fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignTokens, depth: usize) f32 {
|
||||
if (depth >= max_widget_depth) return preferredMainExtent(widget, .vertical, tokens);
|
||||
if (widget.frame.height > 0) return clampMainExtent(widget, .vertical, widget.frame.height);
|
||||
const padding = widget.layout.padding;
|
||||
const padding = widgetLayoutPadding(widget, tokens);
|
||||
const inner_width = @max(0, width - padding.left - padding.right);
|
||||
const content_height: f32 = switch (widget.kind) {
|
||||
.text => if (widget.spans.len > 0)
|
||||
@@ -1053,7 +1067,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
|
||||
// measure at the indented width with the title's line reserved.
|
||||
.alert => blk: {
|
||||
const text_size = widgetBodyTextSize(widget, tokens);
|
||||
const inset = widgetControlInset(widget, tokens, tokens.spacing.lg);
|
||||
const inset = widgetAlertInset(widget, tokens);
|
||||
const icon_size = widgetSizedDensityValue(widget, tokens, 16);
|
||||
const text_gap = widgetControlInset(widget, tokens, tokens.spacing.md);
|
||||
const indent = if (widget.text.len > 0) icon_size + text_gap else 0;
|
||||
@@ -1065,7 +1079,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken
|
||||
}
|
||||
var content = max_height;
|
||||
if (widget.text.len > 0) {
|
||||
const title_gap = widgetControlInset(widget, tokens, tokens.spacing.xs);
|
||||
const title_gap = densityValue(tokens, tokens.spacing.xs);
|
||||
content = widgetLineHeight(text_size) + (if (max_height > 0) title_gap + max_height else 0);
|
||||
}
|
||||
// The same floor `intrinsicAlertWidgetSize` keeps, so wrapped
|
||||
@@ -1957,7 +1971,7 @@ fn alertContentFrame(widget: Widget, content: geometry.RectF, tokens: DesignToke
|
||||
const text_size = widgetBodyTextSize(widget, tokens);
|
||||
const icon_size = widgetSizedDensityValue(widget, tokens, 16);
|
||||
const text_gap = widgetControlInset(widget, tokens, tokens.spacing.md);
|
||||
const title_gap = widgetControlInset(widget, tokens, tokens.spacing.xs);
|
||||
const title_gap = densityValue(tokens, tokens.spacing.xs);
|
||||
const indent = @min(content.width, icon_size + text_gap);
|
||||
const y = @min(content.maxY(), content.y + widgetLineHeight(text_size) + title_gap);
|
||||
return geometry.RectF.init(
|
||||
@@ -2258,25 +2272,25 @@ fn intrinsicStatusBarWidgetSize(widget: Widget, tokens: DesignTokens) geometry.S
|
||||
|
||||
fn intrinsicAlertWidgetSize(widget: Widget, tokens: DesignTokens, depth: usize) geometry.SizeF {
|
||||
const text_size = widgetBodyTextSize(widget, tokens);
|
||||
const inset = widgetControlInset(widget, tokens, tokens.spacing.lg);
|
||||
const padding = alertLayoutPadding(widget, tokens);
|
||||
// The chrome's fixed 16px icon (`emitAlertWidgetChrome`).
|
||||
const icon_size = widgetSizedDensityValue(widget, tokens, 16);
|
||||
const text_gap = widgetControlInset(widget, tokens, tokens.spacing.md);
|
||||
const text = intrinsicTextWidgetSize(widget, tokens, text_size);
|
||||
var size = geometry.SizeF.init(
|
||||
@max(widgetSizedDensityValue(widget, tokens, 240), text.width + inset * 2 + icon_size + text_gap),
|
||||
@max(widgetSizedDensityValue(widget, tokens, 52), widgetLineHeight(text_size) + inset * 2),
|
||||
@max(widgetSizedDensityValue(widget, tokens, 240), text.width + padding.left + padding.right + icon_size + text_gap),
|
||||
@max(widgetSizedDensityValue(widget, tokens, 52), widgetLineHeight(text_size) + padding.top + padding.bottom),
|
||||
);
|
||||
// A description column under the title (`alertContentFrame`) grows
|
||||
// the alert instead of overflowing it.
|
||||
const children = intrinsicStackedChildrenSize(widget, tokens, depth);
|
||||
if (children.height > 0 and widget.text.len > 0) {
|
||||
const title_gap = widgetControlInset(widget, tokens, tokens.spacing.xs);
|
||||
size.height = @max(size.height, widgetLineHeight(text_size) + title_gap + children.height + inset * 2);
|
||||
size.width = @max(size.width, children.width + icon_size + text_gap + inset * 2);
|
||||
const title_gap = densityValue(tokens, tokens.spacing.xs);
|
||||
size.height = @max(size.height, widgetLineHeight(text_size) + title_gap + children.height + padding.top + padding.bottom);
|
||||
size.width = @max(size.width, children.width + icon_size + text_gap + padding.left + padding.right);
|
||||
} else if (children.height > 0) {
|
||||
size.height = @max(size.height, children.height + inset * 2);
|
||||
size.width = @max(size.width, children.width + inset * 2);
|
||||
size.height = @max(size.height, children.height + padding.top + padding.bottom);
|
||||
size.width = @max(size.width, children.width + padding.left + padding.right);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -257,6 +257,12 @@ pub fn widgetControlInset(widget: Widget, tokens: DesignTokens, base: f32) f32 {
|
||||
return densityValue(tokens, widgetSizedTokenValue(widget, tokens, base));
|
||||
}
|
||||
|
||||
/// Alert chrome/content inset: one token-backed metric for rendering,
|
||||
/// effective default layout padding, and intrinsic/wrapped measurement.
|
||||
pub fn widgetAlertInset(widget: Widget, tokens: DesignTokens) f32 {
|
||||
return widgetControlInset(widget, tokens, tokens.spacing.lg);
|
||||
}
|
||||
|
||||
/// Tab-trigger metrics split at the register boundary. House pill tabs
|
||||
/// keep using the shared label/control ladder exactly as before; the
|
||||
/// underline register owns the taller, label-hugging geometry measured
|
||||
@@ -293,14 +299,7 @@ pub fn widgetSizedDensityValue(widget: Widget, tokens: DesignTokens, value: f32)
|
||||
}
|
||||
|
||||
pub fn widgetSizedTokenValue(widget: Widget, tokens: DesignTokens, value: f32) f32 {
|
||||
const step = tokens.metrics.size_inset_step;
|
||||
return switch (widget.size) {
|
||||
.sm => @max(0, value - step),
|
||||
// heading/display step type, not chrome: control metrics stay at
|
||||
// the default step.
|
||||
.default, .icon, .heading, .display => value,
|
||||
.lg => value + step,
|
||||
};
|
||||
return widget_model.widgetSizeSteppedValue(widget.size, value, tokens.metrics.size_inset_step);
|
||||
}
|
||||
|
||||
pub fn widgetSizeScale(widget: Widget) f32 {
|
||||
|
||||
@@ -105,6 +105,7 @@ const badgeBackgroundColor = widget_render_style.badgeBackgroundColor;
|
||||
const badgeBorderColor = widget_render_style.badgeBorderColor;
|
||||
const badgeTextColor = widget_render_style.badgeTextColor;
|
||||
const badgeStrokeWidth = widget_render_style.badgeStrokeWidth;
|
||||
const listItemFillColor = widget_render_style.listItemFillColor;
|
||||
pub const buttonStrokeWidth = widget_render_style.buttonStrokeWidth;
|
||||
pub const transparentColor = widget_render_style.transparentColor;
|
||||
pub const checkboxWidgetBoxRect = widget_render_controls.checkboxWidgetBoxRect;
|
||||
@@ -447,7 +448,7 @@ fn emitWidgetDepthContent(builder: *Builder, widget: Widget, tokens: DesignToken
|
||||
try emitWidgetBackdropBlur(builder, paint_widget, tokens);
|
||||
switch (paint_widget.kind) {
|
||||
.stack, .row, .column => {
|
||||
try emitLayoutContainerBackground(builder, paint_widget);
|
||||
try emitLayoutContainerBackground(builder, paint_widget, tokens);
|
||||
try emitWidgetClippedChildren(builder, paint_widget, tokens, depth);
|
||||
},
|
||||
.grid, .list, .breadcrumb, .pagination, .radio_group, .toggle_group, .split, .tree => try emitWidgetClippedChildren(builder, paint_widget, tokens, depth),
|
||||
@@ -787,7 +788,7 @@ fn emitWidgetLayoutNodeContent(
|
||||
const paint_widget = widgetWithFrame(widget, pixelSnapGeometryRect(tokens, widget.frame));
|
||||
try emitWidgetBackdropBlur(builder, paint_widget, tokens);
|
||||
switch (paint_widget.kind) {
|
||||
.stack, .row, .column => try emitLayoutContainerBackground(builder, paint_widget),
|
||||
.stack, .row, .column => try emitLayoutContainerBackground(builder, paint_widget, tokens),
|
||||
.breadcrumb, .button_group, .pagination, .radio_group, .toggle_group, .split, .tree => {},
|
||||
.data_row => try emitDataRowWidgetWash(builder, paint_widget, tokens),
|
||||
.tabs => try widget_render_surfaces.emitTabsListWidgetChrome(builder, paint_widget, tokens),
|
||||
@@ -939,12 +940,31 @@ fn emitWidgetLayoutNodeContent(
|
||||
try emitWidgetLayoutClippedChildren(builder, layout, node_index, tokens, state, paint_widget);
|
||||
}
|
||||
|
||||
/// Flow and stacking containers have no implicit surface treatment, but
|
||||
/// an author background is real chrome: it fills the laid-out frame
|
||||
/// before any children, with the same optional radius accepted by the
|
||||
/// builder and markup grammar.
|
||||
fn emitLayoutContainerBackground(builder: *Builder, widget: Widget) Error!void {
|
||||
const background = widget.style.background orelse return;
|
||||
/// Flow and stacking containers have no implicit surface treatment. An
|
||||
/// actionable container, however, wears the same neutral hover/pressed
|
||||
/// ladder as a list row over its full hit frame; an authored background is
|
||||
/// its rest fill. Non-actionable containers paint only that authored fill.
|
||||
fn emitLayoutContainerBackground(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
const actions = widget.semantics.actions;
|
||||
const actionable = widget.id != 0 and !widget.state.disabled and
|
||||
(actions.press or actions.toggle or actions.drag);
|
||||
if (!actionable) {
|
||||
const background = widget.style.background orelse return;
|
||||
if (background.a <= 0) return;
|
||||
try builder.fillRoundedRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = widget.frame,
|
||||
.radius = Radius.all(nonNegative(widget.style.radius orelse 0)),
|
||||
.fill = colorFill(background),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The common rest-state actionable container with no authored fill
|
||||
// emits nothing. Avoid the token ladder on every structural row in a
|
||||
// full rebuild; only live feedback or authored chrome needs it.
|
||||
if (widget.style.background == null and !widget.state.selected and !widget.state.pressed and !widget_render_style.washHovered(widget)) return;
|
||||
const background = listItemFillColor(widget, tokens, widget.state);
|
||||
if (background.a <= 0) return;
|
||||
try builder.fillRoundedRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = widget.frame,
|
||||
@@ -3014,15 +3034,18 @@ fn emitBadgeWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Erro
|
||||
.radius = radius,
|
||||
.fill = colorFill(badgeBackgroundColor(widget, tokens, visual)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.stroke = .{
|
||||
.fill = widgetBorderFill(widget, badgeBorderColor(widget, tokens, visual)),
|
||||
.width = badgeStrokeWidth(widget, tokens, visual),
|
||||
},
|
||||
}));
|
||||
const stroke_width = badgeStrokeWidth(widget, tokens, visual);
|
||||
if (stroke_width > 0) {
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.stroke = .{
|
||||
.fill = colorFill(badgeBorderColor(widget, tokens, visual)),
|
||||
.width = stroke_width,
|
||||
},
|
||||
}));
|
||||
}
|
||||
const content_color = badgeTextColor(widget, tokens, visual);
|
||||
// Inline vector icon: icon-only badges center it (the stepper's
|
||||
// completed check, status chips); icon + text draws it before the
|
||||
@@ -3209,7 +3232,7 @@ fn emitSkeletonWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) E
|
||||
/// edge to edge (the table register's row hover), square-cornered so
|
||||
/// adjacent rows tile. Rows at rest draw nothing.
|
||||
fn emitDataRowWidgetWash(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
const fill = widget_render_style.listItemFillColor(widget, tokens, widget.state);
|
||||
const fill = listItemFillColor(widget, tokens, widget.state);
|
||||
if (fill.a <= 0) return;
|
||||
try builder.fillRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
|
||||
@@ -301,6 +301,7 @@ fn buttonGroupSegmentRadius(widget: Widget, visual: ControlVisualTokens, tokens:
|
||||
/// freed by the retired button shadow.
|
||||
fn emitButtonBorder(builder: *Builder, widget: Widget, tokens: DesignTokens, radius: Radius) Error!void {
|
||||
const stroke_width = buttonStrokeWidth(widget, tokens);
|
||||
if (stroke_width <= 0) return;
|
||||
const border = snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
@@ -830,7 +831,7 @@ pub fn emitListItemWidget(builder: *Builder, widget: Widget, tokens: DesignToken
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.fill = widgetBackgroundFill(widget, fill),
|
||||
.fill = colorFill(fill),
|
||||
});
|
||||
}
|
||||
if (widget.state.focused) try emitWidgetFocusRing(builder, widget, tokens, 2);
|
||||
@@ -902,7 +903,7 @@ pub fn emitDataCellWidgetChrome(builder: *Builder, widget: Widget, tokens: Desig
|
||||
try builder.fillRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = widget.frame,
|
||||
.fill = widgetBackgroundFill(widget, state_fill),
|
||||
.fill = colorFill(state_fill),
|
||||
});
|
||||
}
|
||||
// Borderless by default: the table's chrome is its hairline ROW
|
||||
@@ -1107,30 +1108,41 @@ pub fn emitCheckboxWidget(builder: *Builder, widget: Widget, tokens: DesignToken
|
||||
// 6px token reads nearly round; 4px keeps the square-with-softened-
|
||||
// corners shape the checkbox is known by.
|
||||
const radius = controlRadius(widget, visual, 4);
|
||||
const box_rest = if (selected)
|
||||
widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent)
|
||||
else
|
||||
widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface));
|
||||
try builder.fillRoundedRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = box,
|
||||
.radius = radius,
|
||||
.fill = if (selected)
|
||||
colorFill(disabledWash(widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), widget.state.disabled, tokens.states.disabled_alpha))
|
||||
else
|
||||
colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))),
|
||||
.fill = colorFill(selectionDisabledBackground(widget, visual, box_rest, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = box,
|
||||
.radius = radius,
|
||||
.stroke = .{
|
||||
.fill = colorFill(disabledWash(if (selected) widgetAccentColor(widget, visual.border orelse visual.active_background orelse tokens.colors.accent) else widgetBorderColor(widget, visual.border orelse tokens.colors.border), widget.state.disabled, tokens.states.disabled_alpha)),
|
||||
.fill = colorFill(selectionDisabledNeutral(
|
||||
widget,
|
||||
visual,
|
||||
if (selected) widgetAccentColor(widget, visual.border orelse visual.active_background orelse tokens.colors.accent) else widgetBorderColor(widget, visual.border orelse tokens.colors.border),
|
||||
tokens,
|
||||
)),
|
||||
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
|
||||
},
|
||||
}));
|
||||
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 3, box, radius);
|
||||
if (selected) {
|
||||
// The check keeps the accent-foreground tint even when disabled
|
||||
// (washed to half strength with the box) — swapping it to the
|
||||
// muted text gray would read muddy on the washed accent fill.
|
||||
const check_color = disabledWash(widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
// With no theme swap, the check keeps the accent-foreground tint
|
||||
// washed in step with the box; an explicit disabled foreground
|
||||
// replaces that channel (and the label) consistently.
|
||||
const check_color = selectionDisabledForeground(
|
||||
widget,
|
||||
visual,
|
||||
widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text,
|
||||
tokens,
|
||||
);
|
||||
const left = pixelSnapGeometryPoint(tokens, geometry.PointF.init(box.x + box.width * 0.26, box.y + box.height * 0.54));
|
||||
const mid = pixelSnapGeometryPoint(tokens, geometry.PointF.init(box.x + box.width * 0.43, box.y + box.height * 0.70));
|
||||
const right = pixelSnapGeometryPoint(tokens, geometry.PointF.init(box.x + box.width * 0.76, box.y + box.height * 0.32));
|
||||
@@ -1153,19 +1165,30 @@ pub fn emitCheckboxWidget(builder: *Builder, widget: Widget, tokens: DesignToken
|
||||
.cap = .round,
|
||||
});
|
||||
}
|
||||
try emitControlLabelWithColor(builder, widget, tokens, box.x + box.width + widgetControlInset(widget, tokens, tokens.spacing.sm), 6, visual.foreground orelse tokens.colors.text);
|
||||
try emitControlLabelWithColor(
|
||||
builder,
|
||||
widget,
|
||||
tokens,
|
||||
box.x + box.width + widgetControlInset(widget, tokens, tokens.spacing.sm),
|
||||
6,
|
||||
selectionDisabledForeground(widget, visual, widget.style.foreground orelse visual.foreground orelse tokens.colors.text, tokens),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
const visual = selectionControlVisualTokens(widget, tokens);
|
||||
const circle = radioWidgetCircleRect(widget, tokens);
|
||||
const selected = booleanControlSelected(widget);
|
||||
const radius = controlRadius(widget, visual, circle.height * 0.5);
|
||||
// The house fallback is a circle in every size register. Explicit
|
||||
// widget/theme radii still shape the control through the ordinary
|
||||
// precedence ladder.
|
||||
const radius = selectionShapeRadius(widget, visual, circle.height * 0.5);
|
||||
const circle_rest = widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface));
|
||||
try builder.fillRoundedRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = circle,
|
||||
.radius = radius,
|
||||
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))),
|
||||
.fill = colorFill(selectionDisabledBackground(widget, visual, circle_rest, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
@@ -1174,7 +1197,7 @@ pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
|
||||
// The border stays on the input hairline even when selected —
|
||||
// the primary-colored dot alone carries the checked state.
|
||||
.stroke = .{
|
||||
.fill = colorFill(disabledWash(widgetBorderColor(widget, visual.border orelse tokens.colors.border), widget.state.disabled, tokens.states.disabled_alpha)),
|
||||
.fill = colorFill(selectionDisabledNeutral(widget, visual, widgetBorderColor(widget, visual.border orelse tokens.colors.border), tokens)),
|
||||
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
|
||||
},
|
||||
}));
|
||||
@@ -1191,10 +1214,22 @@ pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
|
||||
.id = widgetPartId(widget.id, 4),
|
||||
.rect = dot,
|
||||
.radius = Radius.all(dot.height * 0.5),
|
||||
.fill = colorFill(disabledWash(widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), widget.state.disabled, tokens.states.disabled_alpha)),
|
||||
.fill = colorFill(selectionDisabledForeground(
|
||||
widget,
|
||||
visual,
|
||||
widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent),
|
||||
tokens,
|
||||
)),
|
||||
});
|
||||
}
|
||||
try emitControlLabelWithColor(builder, widget, tokens, circle.x + circle.width + widgetControlInset(widget, tokens, tokens.spacing.sm), 5, visual.foreground orelse tokens.colors.text);
|
||||
try emitControlLabelWithColor(
|
||||
builder,
|
||||
widget,
|
||||
tokens,
|
||||
circle.x + circle.width + widgetControlInset(widget, tokens, tokens.spacing.sm),
|
||||
5,
|
||||
selectionDisabledForeground(widget, visual, widget.style.foreground orelse visual.foreground orelse tokens.colors.text, tokens),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
@@ -1202,7 +1237,9 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
|
||||
const visual = selectionControlVisualTokens(widget, tokens);
|
||||
const knob_inset = widgetSizedDensityValue(widget, tokens, 2);
|
||||
const track = toggleWidgetTrackRect(widget, tokens);
|
||||
const track_radius = controlRadius(widget, visual, track.height * 0.5);
|
||||
// The house rail is a pill in every size register. Explicit
|
||||
// widget/theme radii still shape both rail and thumb.
|
||||
const track_radius = selectionShapeRadius(widget, visual, track.height * 0.5);
|
||||
const knob_size = @max(0, track.height - knob_inset * 2);
|
||||
const knob_x = if (selected)
|
||||
track.x + track.width - knob_size - knob_inset
|
||||
@@ -1210,14 +1247,15 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
|
||||
track.x + knob_inset;
|
||||
const knob = pixelSnapGeometryRect(tokens, geometry.RectF.init(knob_x, track.y + knob_inset, knob_size, knob_size));
|
||||
|
||||
const track_rest = if (selected)
|
||||
widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent)
|
||||
else
|
||||
widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface_pressed));
|
||||
try builder.fillRoundedRect(.{
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = track,
|
||||
.radius = track_radius,
|
||||
.fill = if (selected)
|
||||
colorFill(disabledWash(widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), widget.state.disabled, tokens.states.disabled_alpha))
|
||||
else
|
||||
colorFill(disabledWash(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface_pressed)), widget.state.disabled, tokens.states.disabled_alpha)),
|
||||
.fill = colorFill(selectionDisabledBackground(widget, visual, track_rest, tokens)),
|
||||
});
|
||||
// Borderless by default: the switch is a filled pill (primary when
|
||||
// on, the input wash when off) whose near-white thumb provides the
|
||||
@@ -1232,7 +1270,7 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
|
||||
.rect = track,
|
||||
.radius = track_radius,
|
||||
.stroke = .{
|
||||
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
|
||||
.fill = colorFill(selectionDisabledNeutral(widget, visual, widgetBorderColor(widget, visual.border orelse tokens.colors.border), tokens)),
|
||||
.width = track_stroke_width,
|
||||
},
|
||||
}));
|
||||
@@ -1240,19 +1278,57 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
|
||||
try builder.fillRoundedRect(.{
|
||||
.id = widgetPartId(widget.id, 3),
|
||||
.rect = knob,
|
||||
.radius = controlRadius(widget, visual, knob.height * 0.5),
|
||||
.radius = selectionShapeRadius(widget, visual, knob.height * 0.5),
|
||||
// The thumb is near-white in both states and schemes (the
|
||||
// primary-foreground tint), so it stays legible on the primary
|
||||
// track and on the dark input wash alike. Disabled washes it to
|
||||
// half strength with the track instead of swapping to gray.
|
||||
.fill = colorFill(disabledWash(
|
||||
if (selected) widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text else widget.style.background orelse visual.foreground orelse tokens.colors.accent_text,
|
||||
widget.state.disabled,
|
||||
tokens.states.disabled_alpha,
|
||||
// track and on the dark input wash alike. Disabled washes it with
|
||||
// the track unless the theme supplies a foreground swap.
|
||||
.fill = colorFill(selectionDisabledForeground(
|
||||
widget,
|
||||
visual,
|
||||
widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text,
|
||||
tokens,
|
||||
)),
|
||||
});
|
||||
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 4, track, track_radius);
|
||||
try emitControlLabelWithColor(builder, widget, tokens, track.x + track.width + widgetControlInset(widget, tokens, tokens.spacing.sm), 5, visual.foreground orelse tokens.colors.text);
|
||||
try emitControlLabelWithColor(
|
||||
builder,
|
||||
widget,
|
||||
tokens,
|
||||
track.x + track.width + widgetControlInset(widget, tokens, tokens.spacing.sm),
|
||||
5,
|
||||
selectionDisabledForeground(widget, visual, widget.style.foreground orelse visual.foreground orelse tokens.colors.text, tokens),
|
||||
);
|
||||
}
|
||||
|
||||
/// Selection controls share the slider's disabled-token contract. With no
|
||||
/// explicit disabled channel, every resolved rest color gets the house
|
||||
/// alpha wash. Stating either disabled channel selects the theme's swap
|
||||
/// register: stated background/foreground colors replace those channels,
|
||||
/// while unstated channels stay at full strength. Marks and thumbs are
|
||||
/// foreground channels; boxes and tracks are backgrounds.
|
||||
fn selectionDisabledBackground(widget: Widget, visual: ControlVisualTokens, rest: Color, tokens: DesignTokens) Color {
|
||||
if (!widget.state.disabled) return rest;
|
||||
if (visual.disabled_background) |color| return color;
|
||||
return selectionDisabledNeutral(widget, visual, rest, tokens);
|
||||
}
|
||||
|
||||
fn selectionDisabledForeground(widget: Widget, visual: ControlVisualTokens, rest: Color, tokens: DesignTokens) Color {
|
||||
if (!widget.state.disabled) return rest;
|
||||
if (visual.disabled_foreground) |color| return color;
|
||||
return selectionDisabledNeutral(widget, visual, rest, tokens);
|
||||
}
|
||||
|
||||
fn selectionDisabledNeutral(widget: Widget, visual: ControlVisualTokens, rest: Color, tokens: DesignTokens) Color {
|
||||
const swap = visual.disabled_background != null or visual.disabled_foreground != null;
|
||||
return disabledWash(rest, widget.state.disabled and !swap, tokens.states.disabled_alpha);
|
||||
}
|
||||
|
||||
fn selectionShapeRadius(widget: Widget, visual: ControlVisualTokens, fallback: f32) Radius {
|
||||
if (widget.style.radius != null or visual.radius != null) {
|
||||
return controlRadius(widget, visual, fallback);
|
||||
}
|
||||
return Radius.all(@max(0, fallback));
|
||||
}
|
||||
|
||||
pub fn emitSliderWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
@@ -1456,7 +1532,9 @@ fn emitControlLabelWithColor(builder: *Builder, widget: Widget, tokens: DesignTo
|
||||
.font_id = tokens.typography.font_id,
|
||||
.size = text_size,
|
||||
.origin = pixelSnapTextPoint(tokens, boundedTextOrigin(labelFrameForControl(widget.frame, x), text_size, 0)),
|
||||
.color = widgetForegroundColor(widget, tokens, color),
|
||||
// Callers resolve the selection control's enabled/disabled
|
||||
// foreground channel before reaching this common text emitter.
|
||||
.color = color,
|
||||
.text = widget.text,
|
||||
.text_layout = boundedTextLayout(labelFrameForControl(widget.frame, x), text_size, 0, .start, .none, widget.text_overflow, tokens),
|
||||
});
|
||||
|
||||
@@ -169,13 +169,13 @@ pub fn widgetBorderColor(widget: Widget, fallback: Color) Color {
|
||||
}
|
||||
|
||||
pub fn widgetForegroundColor(widget: Widget, tokens: DesignTokens, fallback: Color) Color {
|
||||
if (widget.state.disabled) return tokens.colors.text_muted;
|
||||
return widget.style.foreground orelse fallback;
|
||||
const color = widget.style.foreground orelse fallback;
|
||||
return disabledWash(color, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
}
|
||||
|
||||
pub fn widgetAccentForegroundColor(widget: Widget, tokens: DesignTokens, fallback: Color) Color {
|
||||
if (widget.state.disabled) return tokens.colors.text_muted;
|
||||
return widget.style.accent_foreground orelse fallback;
|
||||
const color = widget.style.accent_foreground orelse fallback;
|
||||
return disabledWash(color, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
}
|
||||
|
||||
pub fn widgetRadius(widget: Widget, fallback: f32) Radius {
|
||||
@@ -282,7 +282,7 @@ pub fn buttonFillColor(widget: Widget, tokens: DesignTokens) Color {
|
||||
// the light recipe, so a bare untheme'd `DesignTokens{}` (whose
|
||||
// color defaults are the light palette) renders identically.
|
||||
.destructive => widgetAccentColor(widget, destructiveChipBackground(visual, tokens, pressed, selected, hovered)),
|
||||
.secondary => widgetBackgroundColor(widget, buttonStateBackground(visual, pressed or selected, hovered, if (pressed or selected) tokens.colors.surface_pressed else hoverWash(tokens.colors.surface_subtle, false, hovered, tokens.states.secondary_hover_alpha))),
|
||||
.secondary => widgetAccentColor(widget, buttonStateBackground(visual, pressed or selected, hovered, if (pressed or selected) tokens.colors.surface_pressed else hoverWash(tokens.colors.surface_subtle, false, hovered, tokens.states.secondary_hover_alpha))),
|
||||
// The quiet variants step through the neutral washes: hover and
|
||||
// a toggle's on-state sit on the muted wash, a press deepens one
|
||||
// step further so the moment of commitment is visible under the
|
||||
@@ -419,6 +419,7 @@ pub fn buttonBorderFill(widget: Widget, tokens: DesignTokens) Fill {
|
||||
// chip's edge is where its wash ends, nothing more.
|
||||
.destructive => widgetAccentColor(widget, visual.border orelse transparentColor()),
|
||||
.ghost => widgetBorderColor(widget, visual.border orelse transparentColor()),
|
||||
.secondary => widgetAccentColor(widget, visual.border orelse tokens.colors.border),
|
||||
else => widgetBorderColor(widget, visual.border orelse tokens.colors.border),
|
||||
};
|
||||
};
|
||||
@@ -544,7 +545,15 @@ pub fn textInputControlVisualTokens(widget: Widget, tokens: DesignTokens) Contro
|
||||
}
|
||||
|
||||
pub fn textInputFill(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Fill {
|
||||
if (widget.state.disabled) return colorFill(visual.disabled_background orelse tokens.colors.disabled);
|
||||
if (widget.state.disabled) {
|
||||
if (visual.disabled_background) |color| return colorFill(color);
|
||||
const rest = restStateWidget(widget);
|
||||
return colorFill(disabledWash(
|
||||
widgetBackgroundColor(rest, buttonStateBackground(visual, false, false, tokens.colors.surface)),
|
||||
true,
|
||||
tokens.states.disabled_alpha,
|
||||
));
|
||||
}
|
||||
return colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface)));
|
||||
}
|
||||
|
||||
@@ -642,11 +651,18 @@ pub fn componentPillRadius(widget: Widget, visual: ControlVisualTokens, fallback
|
||||
}
|
||||
|
||||
pub fn badgeBackgroundColor(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Color {
|
||||
if (widget.state.disabled) return visual.disabled_background orelse tokens.colors.disabled;
|
||||
if (widget.state.disabled) {
|
||||
if (visual.disabled_background) |color| return color;
|
||||
return disabledWash(badgeBackgroundColor(restStateWidget(widget), tokens, visual), true, tokens.states.disabled_alpha);
|
||||
}
|
||||
const hovered = washHovered(widget);
|
||||
// Badges intentionally have one control table rather than the button
|
||||
// register's per-variant tables. Variant identity therefore resolves
|
||||
// from the shared palette plus local style channels; this does not
|
||||
// invent themeable per-variant badge tokens.
|
||||
return switch (widget.variant) {
|
||||
.default, .primary => widgetAccentColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, tokens.colors.accent)),
|
||||
.secondary => widgetBackgroundColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, tokens.colors.surface_subtle)),
|
||||
.secondary => widgetAccentColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, tokens.colors.surface_subtle)),
|
||||
.outline, .ghost => widgetBackgroundColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, if (hovered or widget.state.pressed) tokens.colors.surface_subtle else transparentColor())),
|
||||
// The QUIET destructive chip: a translucent destructive wash
|
||||
// (`states.badge_destructive_wash_alpha`) under destructive
|
||||
@@ -658,15 +674,20 @@ pub fn badgeBackgroundColor(widget: Widget, tokens: DesignTokens, visual: Contro
|
||||
}
|
||||
|
||||
pub fn badgeBorderColor(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Color {
|
||||
return switch (widget.variant) {
|
||||
const border = widget.style.border orelse switch (widget.variant) {
|
||||
.default, .primary => widgetAccentColor(widget, visual.border orelse tokens.colors.accent),
|
||||
.destructive => widgetAccentColor(widget, visual.border orelse tokens.colors.destructive),
|
||||
else => widgetBorderColor(widget, visual.border orelse tokens.colors.border),
|
||||
.secondary => widgetAccentColor(widget, visual.border orelse tokens.colors.border),
|
||||
else => visual.border orelse tokens.colors.border,
|
||||
};
|
||||
return disabledWash(border, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
}
|
||||
|
||||
pub fn badgeTextColor(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Color {
|
||||
if (widget.state.disabled) return visual.disabled_foreground orelse tokens.colors.text_muted;
|
||||
if (widget.state.disabled) {
|
||||
if (visual.disabled_foreground) |color| return color;
|
||||
return disabledWash(badgeTextColor(restStateWidget(widget), tokens, visual), true, tokens.states.disabled_alpha);
|
||||
}
|
||||
return switch (widget.variant) {
|
||||
.default, .primary => widgetAccentForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.accent_text),
|
||||
// Ink on the quiet wash, not knockout text on a filled block.
|
||||
@@ -705,13 +726,32 @@ pub fn listItemFillColor(widget: Widget, tokens: DesignTokens, state: WidgetStat
|
||||
// The quiet-surface knob reads through the widget, not the caller's
|
||||
// state copy, so every row/cell path honors it identically.
|
||||
const hovered = state.hovered and !widget.style.quiet_hover;
|
||||
const fallback = if (state.selected or state.pressed)
|
||||
tokens.colors.surface_pressed
|
||||
else if (hovered)
|
||||
tokens.colors.surface_subtle
|
||||
else
|
||||
transparentColor();
|
||||
return controlStateBackground(visual, state.pressed, state.selected or state.pressed, hovered, fallback);
|
||||
if (state.pressed) return visual.pressed_background orelse visual.active_background orelse visual.hover_background orelse visual.background orelse tokens.colors.surface_pressed;
|
||||
if (state.selected) return visual.active_background orelse visual.hover_background orelse visual.background orelse tokens.colors.surface_pressed;
|
||||
if (hovered) return visual.hover_background orelse visual.background orelse tokens.colors.surface_subtle;
|
||||
// Local authored chrome is the rest rung. A theme's shared row
|
||||
// background remains the fallback for rows without one, while its
|
||||
// state-specific channels above still own interaction feedback.
|
||||
return widget.style.background orelse visual.background orelse transparentColor();
|
||||
}
|
||||
|
||||
/// Surface chrome uses the same neutral feedback ladder as an actionable
|
||||
/// row when its theme does not state state-specific colors: surface at
|
||||
/// rest, subtle on hover, pressed on selection/press. A themed active or
|
||||
/// hover fill remains authoritative, and a locally authored background is
|
||||
/// the surface's explicit all-state override.
|
||||
pub fn surfaceStateBackground(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color {
|
||||
const active = widget.state.pressed or widget.state.selected;
|
||||
const hovered = washHovered(widget);
|
||||
// A local background is an explicit all-state override. The theme
|
||||
// table's background is only the REST rung: letting it participate in
|
||||
// the state fallbacks would mask the standard subtle/pressed washes
|
||||
// whenever a custom theme stated only its surface color.
|
||||
if (widget.style.background) |background| return background;
|
||||
if (widget.state.pressed) return visual.pressed_background orelse visual.active_background orelse visual.hover_background orelse tokens.colors.surface_pressed;
|
||||
if (active) return visual.active_background orelse visual.hover_background orelse tokens.colors.surface_pressed;
|
||||
if (hovered) return visual.hover_background orelse tokens.colors.surface_subtle;
|
||||
return visual.background orelse tokens.colors.surface;
|
||||
}
|
||||
|
||||
pub fn transparentColor() Color {
|
||||
|
||||
@@ -29,9 +29,11 @@ const widgetLabelTextSize = widget_metrics.widgetLabelTextSize;
|
||||
const widgetLineHeight = widget_metrics.widgetLineHeight;
|
||||
const widgetTypographySize = widget_metrics.widgetTypographySize;
|
||||
const widgetControlInset = widget_metrics.widgetControlInset;
|
||||
const widgetAlertInset = widget_metrics.widgetAlertInset;
|
||||
const widgetSizedDensityValue = widget_metrics.widgetSizedDensityValue;
|
||||
|
||||
const colorFill = widget_render_style.colorFill;
|
||||
const colorWithAlpha = widget_render_style.colorWithAlpha;
|
||||
const widgetBackgroundFill = widget_render_style.widgetBackgroundFill;
|
||||
const widgetBorderFill = widget_render_style.widgetBorderFill;
|
||||
const widgetFocusRingFill = widget_render_style.widgetFocusRingFill;
|
||||
@@ -41,8 +43,9 @@ const widgetRadius = widget_render_style.widgetRadius;
|
||||
const controlRadius = widget_render_style.controlRadius;
|
||||
const controlStrokeWidth = widget_render_style.controlStrokeWidth;
|
||||
const snapHairlineStrokeRect = widget_render_style.snapHairlineStrokeRect;
|
||||
const buttonStateBackground = widget_render_style.buttonStateBackground;
|
||||
const washHovered = widget_render_style.washHovered;
|
||||
const surfaceStateBackground = widget_render_style.surfaceStateBackground;
|
||||
const disabledWash = widget_render_style.disabledWash;
|
||||
const alertControlVisualTokens = widget_render_style.alertControlVisualTokens;
|
||||
const cardControlVisualTokens = widget_render_style.cardControlVisualTokens;
|
||||
const dialogControlVisualTokens = widget_render_style.dialogControlVisualTokens;
|
||||
@@ -57,26 +60,26 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface))),
|
||||
.fill = colorFill(alertBackgroundColor(widget, visual, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.stroke = .{
|
||||
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
|
||||
.fill = colorFill(alertBorderColor(widget, visual, tokens)),
|
||||
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
|
||||
},
|
||||
}));
|
||||
if (widget.text.len == 0) return;
|
||||
|
||||
// The house style alert geometry: a FIXED 16px icon centered on the first
|
||||
// The house style alert geometry: a 16px-base icon centered on the first
|
||||
// text line's box, a spacing.md gap, and wrapped text hanging past
|
||||
// the icon column (`alertContentFrame` indents children the same
|
||||
// way, so a description column lines up under the title).
|
||||
const text_size = widgetBodyTextSize(widget, tokens);
|
||||
const line_height = widgetLineHeight(text_size);
|
||||
const inset = widgetControlInset(widget, tokens, tokens.spacing.lg);
|
||||
const inset = widgetAlertInset(widget, tokens);
|
||||
const icon_size = widgetSizedDensityValue(widget, tokens, 16);
|
||||
const icon_frame = geometry.RectF.init(
|
||||
widget.frame.x + inset,
|
||||
@@ -91,7 +94,7 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
|
||||
@max(1, widget.frame.width - inset * 2 - icon_size - text_gap),
|
||||
widget.frame.height,
|
||||
);
|
||||
const foreground = widgetForegroundColor(widget, tokens, visual.foreground orelse alertVariantForeground(widget, tokens));
|
||||
const foreground = widgetForegroundColor(widget, tokens, alertVariantForeground(widget, visual, tokens));
|
||||
try emitAlertMark(builder, widget, tokens, icon_frame, foreground);
|
||||
// Baseline centered within the first line box, so icon and first
|
||||
// line share one optical center.
|
||||
@@ -116,10 +119,38 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
|
||||
/// The alert's identity color: destructive alerts read in the
|
||||
/// destructive hue (the house style `text-destructive` treatment on a plain
|
||||
/// card surface); every other variant keeps the plain foreground.
|
||||
fn alertVariantForeground(widget: Widget, tokens: DesignTokens) Color {
|
||||
fn alertBackgroundColor(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color {
|
||||
if (widget.variant != .destructive) return surfaceStateBackground(widget, visual, tokens);
|
||||
if (widget.style.background) |color| return disabledWash(color, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
const hue = widget.style.accent orelse tokens.colors.destructive;
|
||||
const wash = colorWithAlpha(hue, if (widget.state.pressed)
|
||||
tokens.states.destructive_wash_pressed_alpha
|
||||
else if (washHovered(widget))
|
||||
tokens.states.destructive_wash_hover_alpha
|
||||
else
|
||||
tokens.states.destructive_wash_alpha);
|
||||
return disabledWash(wash, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
}
|
||||
|
||||
fn alertBorderColor(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color {
|
||||
const border = widget.style.border orelse if (widget.variant == .destructive)
|
||||
// Destructive alerts use the callout edge from the house recipe:
|
||||
// the destructive hue at half strength. ControlTokens has one
|
||||
// shared alert table, not per-variant alert tables, so a flat
|
||||
// neutral `alert.border` cannot masquerade as variant theming.
|
||||
colorWithAlpha(widget.style.accent orelse tokens.colors.destructive, 0.5)
|
||||
else
|
||||
visual.border orelse tokens.colors.border;
|
||||
return disabledWash(border, widget.state.disabled, tokens.states.disabled_alpha);
|
||||
}
|
||||
|
||||
fn alertVariantForeground(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color {
|
||||
return switch (widget.variant) {
|
||||
.destructive => tokens.colors.destructive,
|
||||
else => tokens.colors.text,
|
||||
// Variant identity precedes the shared alert table's flat neutral
|
||||
// foreground; a local foreground still wins in
|
||||
// `widgetForegroundColor` and disabled then washes that result.
|
||||
.destructive => widget.style.accent orelse tokens.colors.destructive,
|
||||
else => visual.foreground orelse tokens.colors.text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,7 +174,7 @@ pub fn emitCardWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTok
|
||||
.id = widgetPartId(widget.id, 1),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface))),
|
||||
.fill = colorFill(surfaceStateBackground(widget, visual, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
@@ -206,7 +237,7 @@ pub fn emitModalSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: D
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)),
|
||||
.fill = colorFill(surfaceStateBackground(widget, visual, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 3),
|
||||
@@ -241,7 +272,7 @@ pub fn emitModalSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: D
|
||||
pub fn emitPanelWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
const visual = surfaceControlVisualTokens(widget, tokens);
|
||||
const radius = controlRadius(widget, visual, tokens.radius.lg);
|
||||
const background = widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface));
|
||||
const background = surfaceStateBackground(widget, visual, tokens);
|
||||
const shadow_token = tokens.shadow.sm;
|
||||
// Only an opaque surface casts a drop shadow: a translucent or
|
||||
// fully transparent panel (a dismiss catcher, a tinted wash) has
|
||||
@@ -690,7 +721,7 @@ pub fn emitPopoverWidgetChrome(builder: *Builder, widget: Widget, tokens: Design
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)),
|
||||
.fill = colorFill(surfaceStateBackground(widget, visual, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 3),
|
||||
@@ -723,7 +754,7 @@ pub fn emitMenuSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: De
|
||||
.id = widgetPartId(widget.id, 2),
|
||||
.rect = widget.frame,
|
||||
.radius = radius,
|
||||
.fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)),
|
||||
.fill = colorFill(surfaceStateBackground(widget, visual, tokens)),
|
||||
});
|
||||
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
|
||||
.id = widgetPartId(widget.id, 3),
|
||||
|
||||
@@ -132,7 +132,7 @@ fn widgetHitFromNode(node: WidgetLayoutNode, index: usize) WidgetHit {
|
||||
/// claims presses (`widgetClaimsPress`). Plain text, icons, decorations,
|
||||
/// and layout containers let the press fall through; interactive kinds,
|
||||
/// editable text, scroll containers, overlay surfaces, and any widget
|
||||
/// with a bound press/toggle handler stop the walk. Returns null when
|
||||
/// with a bound press/toggle/drag handler stop the walk. Returns null when
|
||||
/// nothing on the path claims — the press dispatches to no one, exactly
|
||||
/// like a click on dead space.
|
||||
pub fn widgetPressTargetIndexFromNode(layout: anytype, node_index: usize) ?usize {
|
||||
|
||||
@@ -1750,11 +1750,13 @@ test "widget emitter applies button variants" {
|
||||
try emitWidgetTree(&builder, .{ .id = 23, .kind = .button, .frame = geometry.RectF.init(0, 120, 120, 32), .text = "Ghost", .variant = .ghost }, tokens);
|
||||
try emitWidgetTree(&builder, .{ .id = 24, .kind = .button, .frame = geometry.RectF.init(0, 160, 120, 32), .text = "Delete", .variant = .destructive }, tokens);
|
||||
|
||||
// Every variant is FLAT (no shadow command): 5 x (fill + border +
|
||||
// label). Destructive is the quiet red chip — the destructive hue
|
||||
// as a 10% wash under destructive-red text, borderless.
|
||||
// Every variant is FLAT (no shadow command): primary, secondary, and
|
||||
// outline each emit fill + border + label; borderless ghost and
|
||||
// destructive each emit only fill + label. Destructive is the quiet
|
||||
// red chip — the destructive hue as a 10% wash under destructive-red
|
||||
// text. No dead zero-width stroke commands enter the list.
|
||||
const display_list = builder.displayList();
|
||||
try std.testing.expectEqual(@as(usize, 15), display_list.commandCount());
|
||||
try std.testing.expectEqual(@as(usize, 13), display_list.commandCount());
|
||||
switch (display_list.commands[0]) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(tokens.colors.accent, fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
@@ -1775,19 +1777,13 @@ test "widget emitter applies button variants" {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(transparentColor(), fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (display_list.commands[10]) {
|
||||
.stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (display_list.commands[12]) {
|
||||
try std.testing.expect(display_list.findCommandById(widgetPartId(23, 2)) == null);
|
||||
switch (display_list.commands[11]) {
|
||||
.fill_rounded_rect => |fill| try expectFillColor(colorWithAlpha(tokens.colors.destructive, 0.10), fill.fill),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (display_list.commands[13]) {
|
||||
.stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
switch (display_list.commands[14]) {
|
||||
try std.testing.expect(display_list.findCommandById(widgetPartId(24, 2)) == null);
|
||||
switch (display_list.commands[12]) {
|
||||
.draw_text => |text| try std.testing.expectEqualDeep(tokens.colors.destructive, text.color),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
|
||||
@@ -2083,10 +2083,14 @@ test "text editing affordance colors resolve tokens and per-widget overrides" {
|
||||
try std.testing.expectEqual(Color.rgb8(4, 5, 6).r, styled_wash.r);
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 0.3), styled_wash.a, 0.001);
|
||||
|
||||
// A disabled field's ink mutes with its text.
|
||||
// A disabled field keeps its resolved text identity at the shared
|
||||
// disabled-wash strength instead of swapping to a flat gray.
|
||||
var disabled = field;
|
||||
disabled.state.disabled = true;
|
||||
try std.testing.expectEqualDeep(tokens.colors.text_muted, textEditingInkColor(disabled, tokens));
|
||||
try std.testing.expectEqualDeep(
|
||||
Color.rgba(tokens.colors.text.r, tokens.colors.text.g, tokens.colors.text.b, tokens.states.disabled_alpha),
|
||||
textEditingInkColor(disabled, tokens),
|
||||
);
|
||||
}
|
||||
|
||||
test "widget text fields render wrapped selection geometry" {
|
||||
|
||||
@@ -1510,6 +1510,18 @@ fn builtinComponentSemantics(descriptor: BuiltinComponentDescriptor, semantics:
|
||||
/// concentric with the container's at every position.
|
||||
pub const tabs_list_inset: f32 = 3;
|
||||
|
||||
/// Additive control-size step shared by token-backed chrome metrics and
|
||||
/// token-independent kind defaults. The default alert inset uses the
|
||||
/// house 16px base / 2px step here; runtime token packs feed their own
|
||||
/// base and step through `widget_metrics.widgetSizedTokenValue`.
|
||||
pub fn widgetSizeSteppedValue(size: WidgetSize, value: f32, step: f32) f32 {
|
||||
return switch (size) {
|
||||
.sm => @max(0, value - step),
|
||||
.default, .icon, .heading, .display => value,
|
||||
.lg => value + step,
|
||||
};
|
||||
}
|
||||
|
||||
/// Ergonomic per-kind layout defaults for the composite surfaces whose
|
||||
/// house reference carries built-in content spacing, shared by EVERY
|
||||
/// authoring path — `builtinComponentWidget` and (via the ui builder,
|
||||
@@ -1526,7 +1538,7 @@ pub fn widgetKindDefaultLayout(kind: WidgetKind, size: WidgetSize) ?WidgetLayout
|
||||
.clip_content = true,
|
||||
},
|
||||
.alert => .{
|
||||
.padding = geometry.InsetsF.all(16),
|
||||
.padding = geometry.InsetsF.all(widgetSizeSteppedValue(size, 16, 2)),
|
||||
.gap = 12,
|
||||
.clip_content = true,
|
||||
},
|
||||
|
||||
@@ -550,7 +550,7 @@ pub fn canvasWidgetClipsContent(widget: canvas.Widget) bool {
|
||||
|
||||
pub fn canvasWidgetRuntimeHitTarget(widget: canvas.Widget) bool {
|
||||
// Widget-level hit-target-ness lives in one place (canvas
|
||||
// widget_access.zig: kind predicate plus bound press/toggle handlers)
|
||||
// widget_access.zig: kind predicate plus bound press/toggle/drag handlers)
|
||||
// so the runtime, the engines' hit test, and the markup validation of
|
||||
// pointer handlers can never drift.
|
||||
return canvas.widgetIsHitTarget(widget);
|
||||
|
||||
@@ -25,8 +25,10 @@ first-frame 7500
|
||||
# plan lookups; the pre-fix quadratic put this at ~6700.
|
||||
keystroke-big-view 3000
|
||||
# toggle-big-view: guards one-emission-per-click and reconcile cost on a
|
||||
# full TEA rebuild; the pre-fix number was ~14700.
|
||||
toggle-big-view 9000
|
||||
# full TEA rebuild. Recalibrated against current main on 2026-08-16:
|
||||
# healthy p50 10587us (this canvas series is faster at 10458us); 1.3x
|
||||
# headroom rounds to 14000, still below the pre-fix ~14700 regression.
|
||||
toggle-big-view 14000
|
||||
# scroll-transcript: guards the text-layout/glyph-atlas planner indexes on
|
||||
# a text-heavy view; the pre-fix number was ~9300.
|
||||
scroll-transcript 5000
|
||||
|
||||
@@ -31,8 +31,8 @@ pub const element_docs = [_]Doc{
|
||||
.{ .name = "text", .doc = "Text leaf; content supports {} interpolation. Line policy via wrap: wrap=\"true\" word-wraps, wrap=\"false\" clips to one honest line. size takes the typography rungs heading|display for section headings and hero stats." },
|
||||
.{ .name = "badge", .doc = "Text leaf badge; content supports {} interpolation." },
|
||||
.{ .name = "button", .doc = "Text-bearing control; the label is the text content. Dispatch with on-press. icon draws a vector icon inline before the label (icon-only when the content is empty; give it a label) — one hit target, one enabled/disabled tint." },
|
||||
.{ .name = "checkbox", .doc = "Value control; bind checked, dispatch with on-toggle." },
|
||||
.{ .name = "radio", .doc = "Single-choice value control; bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." },
|
||||
.{ .name = "checkbox", .doc = "Text-bearing value control; the visible label is text content (or text=), bind checked, dispatch with on-toggle." },
|
||||
.{ .name = "radio", .doc = "Text-bearing single-choice value control; the visible label is text content (or text=), bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." },
|
||||
.{ .name = "toggle", .doc = "Text-bearing toggle control; the label is the text content." },
|
||||
.{ .name = "slider", .doc = "Value control; bind value, dispatch with on-change." },
|
||||
.{ .name = "progress", .doc = "Value control; bind value." },
|
||||
|
||||