Skip to main content

rux_layout/
lib.rs

1//! Rux layout, milestones M1–M4.
2//!
3//! A styled node tree fed through `taffy` (flexbox) to produce absolute paint
4//! items. Boxes come straight from taffy; text leaves are sized through a
5//! caller-supplied `measure` callback (so this crate stays free of any font
6//! dependency, the shell owns the text engine). See `docs/04-architecture.md`,
7//! Stage 4.
8//!
9//! The crate is mostly its vocabulary. [`Style`] is the honored subset of CSS
10//! as the engine actually sees it, and [`Node`] is one styled element with its
11//! children. `rux-style` produces that tree; this crate turns it into the flat
12//! [`Paint`] list `rux-paint` consumes, in absolute coordinates with the
13//! cascade and the box model already collapsed into numbers.
14//!
15//! Layout emits more than pictures, because a frame's geometry is the only
16//! place several other questions can be answered honestly. Alongside the paint
17//! items come the regions the shell needs and cannot recompute for itself:
18//! [`HitRegion`] for pointer targets, [`ScrollRegion`] for what scrolls and how
19//! far, [`FocusRegion`] and [`FocusItem`] for tab order, [`SelectRegion`] for
20//! selectable text, [`StateRegion`] for hover and active, and [`AccessNode`]
21//! for the accessibility tree. All of them are in the same coordinate space as
22//! the paint items, which is the point: two places doing the same coordinate
23//! arithmetic eventually disagree, so there is one conversion and everyone
24//! reads its output.
25//!
26//! Both flexbox and grid come from taffy. What does not come from taffy is
27//! text sizing, which is why `measure` is a callback: pulling a font
28//! stack into this crate would put shaping under layout, and the shell already
29//! owns one.
30
31use std::collections::HashMap;
32
33use taffy::prelude::*;
34use taffy::geometry::Point;
35
36/// Straight RGBA in the 0..=1 range. Renderer-agnostic.
37#[derive(Clone, Copy, Debug)]
38pub struct Rgba {
39    pub r: f32,
40    pub g: f32,
41    pub b: f32,
42    pub a: f32,
43}
44
45impl Rgba {
46    pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
47        Self { r, g, b, a }
48    }
49}
50
51/// Per-side box-model lengths (padding / margin / border widths).
52#[derive(Clone, Copy, Debug, Default)]
53pub struct Sides {
54    pub top: f32,
55    pub right: f32,
56    pub bottom: f32,
57    pub left: f32,
58}
59
60impl Sides {
61    pub const fn uniform(v: f32) -> Self {
62        Self {
63            top: v,
64            right: v,
65            bottom: v,
66            left: v,
67        }
68    }
69}
70
71/// A CSS length. Percentages are stored as a fraction (`0.0..=1.0`); `vh`/`vw`
72/// hold the raw viewport-percentage number (e.g. `100vh` → `Vh(100.0)`). `rem`
73/// is resolved to pixels at parse time.
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub enum Len {
76    Px(f32),
77    Pct(f32),
78    Vw(f32),
79    Vh(f32),
80}
81
82/// A grid track size (`grid-template-columns`/`-rows`).
83#[derive(Clone, Copy, Debug)]
84pub enum Track {
85    Px(f32),
86    Fr(f32),
87    Auto,
88    /// `minmax(min, max)`. Its whole point over a bare `1fr` is a `0` (or `px`)
89    /// minimum, which lets the track shrink *below* its content's min-content,
90    /// so a grid of fixed-size cards squeezes to fit instead of overflowing.
91    MinMax(TrackSide, TrackSide),
92}
93
94/// One side of a `minmax()`, never itself a `minmax`. A `Fr` is only valid on
95/// the max side (a flex minimum is meaningless), and degrades to `auto` if used
96/// as a minimum.
97#[derive(Clone, Copy, Debug)]
98pub enum TrackSide {
99    Px(f32),
100    Fr(f32),
101    Auto,
102}
103
104/// How a node lays out its children. Defaults to `Row` to match CSS's
105/// `flex-direction` initial value.
106#[derive(Clone, Copy, Debug, Default)]
107pub enum Axis {
108    #[default]
109    Row,
110    Column,
111}
112
113/// Main-axis distribution (`justify-content`).
114#[derive(Clone, Copy, Debug)]
115pub enum Justify {
116    Start,
117    Center,
118    End,
119    SpaceBetween,
120    SpaceAround,
121}
122
123/// Cross-axis alignment (`align-items`).
124#[derive(Clone, Copy, Debug)]
125pub enum Align {
126    Start,
127    Center,
128    End,
129    Stretch,
130}
131
132/// Horizontal text alignment within a text box (`text-align`).
133#[derive(Clone, Copy, Debug, Default, PartialEq)]
134pub enum TextAlign {
135    #[default]
136    Start,
137    Center,
138    End,
139    Justify,
140}
141
142/// How a line may break when a word is wider than its box (`overflow-wrap` /
143/// `word-break`). CSS's default lets a long word overflow rather than break.
144#[derive(Clone, Copy, Debug, Default, PartialEq)]
145pub enum TextWrap {
146    #[default]
147    Normal,
148    /// `overflow-wrap: break-word`: break inside a word rather than overflow.
149    BreakWord,
150    /// `word-break: break-all`: break anywhere.
151    Anywhere,
152}
153
154/// CSS `display`. Defaults to `Block` (strict-CSS fidelity): flex layout,
155/// `gap`, and `flex-direction` only apply under `Flex`.
156#[derive(Clone, Copy, Debug, Default, PartialEq)]
157pub enum Display {
158    #[default]
159    Block,
160    /// Hugs its content and does not stretch to fill (works inside flex parents;
161    /// taffy has no true inline text flow).
162    Inline,
163    Flex,
164    Grid,
165    /// Removed from layout entirely (no space reserved).
166    None,
167}
168
169/// Overflow behaviour for content exceeding a box.
170#[derive(Clone, Copy, Debug, Default, PartialEq)]
171pub enum Overflow {
172    #[default]
173    Visible,
174    /// Clip the subtree to this box (`hidden` / `clip`).
175    Clip,
176    /// Clip, and let the wheel move the content (`auto` / `scroll`). The box
177    /// keeps its own size; taffy reports how tall the content actually is.
178    Scroll,
179}
180
181/// The mouse cursor shown while the pointer is over a box (`cursor`). Only the
182/// values the shell maps to a winit `CursorIcon` are modelled; the default is
183/// the arrow.
184#[derive(Clone, Copy, Debug, Default, PartialEq)]
185pub enum Cursor {
186    #[default]
187    Default,
188    /// `cursor: pointer`: the hand, for tappable things.
189    Pointer,
190}
191
192/// `position`. `Relative` is the normal in-flow box (the default); `Absolute`
193/// takes the box out of flow and positions it by its `inset` against the
194/// nearest positioned ancestor.
195#[derive(Clone, Copy, Debug, Default, PartialEq)]
196pub enum Position {
197    #[default]
198    Relative,
199    Absolute,
200}
201
202/// Corner radii in CSS order, top-left, top-right, bottom-right, bottom-left.
203/// A single `border-radius` fills all four; the per-corner longhands override.
204pub type Corners = [f32; 4];
205
206/// A 2-D affine `transform`, as the six coefficients `[a, b, c, d, e, f]` (kurbo
207/// `Affine` order: `x' = a·x + c·y + e`, `y' = b·x + d·y + f`). Translations are
208/// in logical px; the origin is applied at paint time (CSS default: box centre).
209pub type Transform = [f32; 6];
210
211/// `grid-auto-flow`: how auto-placed items fill the implicit grid.
212#[derive(Clone, Copy, Debug, Default, PartialEq)]
213pub enum GridFlow {
214    #[default]
215    Row,
216    Column,
217    RowDense,
218    ColumnDense,
219}
220
221/// One endpoint of a `grid-column` / `grid-row` placement.
222#[derive(Clone, Copy, Debug, Default, PartialEq)]
223pub enum GridPlace {
224    /// Auto-placed by the grid algorithm.
225    #[default]
226    Auto,
227    /// A specific grid line (1-based; negative counts back from the end).
228    Line(i16),
229    /// Span this many tracks from the other endpoint.
230    Span(u16),
231}
232
233/// A box background: a flat colour, a gradient, or an image.
234#[derive(Clone, Debug)]
235pub enum Background {
236    Color(Rgba),
237    Gradient(Gradient),
238    /// `background-image: url(…)`. The runtime resolves this to an absolute path
239    /// (like `<image src>`); the painter decodes it and draws it `cover`-sized.
240    Image(String),
241}
242
243/// A CSS gradient reduced to what the painter needs: a shape and colour stops.
244#[derive(Clone, Debug)]
245pub struct Gradient {
246    pub kind: GradientKind,
247    /// Colour stops as `(colour, offset)` with offset in 0..=1, in order.
248    pub stops: Vec<(Rgba, f32)>,
249}
250
251#[derive(Clone, Copy, Debug)]
252pub enum GradientKind {
253    /// `linear-gradient(<angle>, …)`: angle in radians, CSS convention (0 = to
254    /// top, increasing clockwise).
255    Linear { angle: f32 },
256    /// `radial-gradient(…)`: a centred circle out to the nearest edge.
257    Radial,
258}
259
260/// A single (outer) `box-shadow`. Offsets, blur and spread are logical px.
261#[derive(Clone, Copy, Debug)]
262pub struct BoxShadow {
263    pub dx: f32,
264    pub dy: f32,
265    pub blur: f32,
266    pub spread: f32,
267    pub color: Rgba,
268    /// `inset` shadows are parsed but not yet drawn.
269    pub inset: bool,
270}
271
272/// The style subset M-series understands (a stand-in for the CSS `ComputedStyle`).
273#[derive(Clone, Debug)]
274pub struct Style {
275    pub display: Display,
276    pub width: Option<Len>,
277    pub height: Option<Len>,
278    pub min_width: Option<Len>,
279    pub max_width: Option<Len>,
280    pub min_height: Option<Len>,
281    pub max_height: Option<Len>,
282    pub grid_columns: Vec<Track>,
283    pub grid_rows: Vec<Track>,
284    /// `grid-column` / `grid-row` placement for a grid item: `(start, end)`.
285    pub grid_column: (GridPlace, GridPlace),
286    pub grid_row: (GridPlace, GridPlace),
287    /// `grid-auto-flow` and the implicit-track sizes `grid-auto-rows`/`-columns`.
288    pub grid_auto_flow: GridFlow,
289    pub grid_auto_rows: Vec<Track>,
290    pub grid_auto_columns: Vec<Track>,
291    pub grow: f32,
292    /// `flex-shrink`. CSS defaults to 1: a flex item gives up space to fit its
293    /// container. `0` keeps the item's size and lets it overflow, which is the
294    /// author's call, and what `overflow: clip` is for.
295    pub shrink: f32,
296    /// `flex-basis`. `None` = `auto` (size from width/content).
297    pub basis: Option<Len>,
298    /// `flex-wrap: wrap`: items that don't fit start a new line.
299    pub wrap: bool,
300    /// `opacity`, 0.0–1.0. Applies to the whole subtree.
301    pub opacity: f32,
302    /// `overflow-wrap` / `word-break`, applied to a text node's own content.
303    pub text_wrap: TextWrap,
304    pub padding: Sides,
305    pub margin: Sides,
306    pub border: Sides,
307    pub border_color: Option<Rgba>,
308    pub gap: f32,
309    /// `row-gap` / `column-gap` overrides for the shorthand `gap`. `None` keeps
310    /// the shorthand (`gap`) value on that axis.
311    pub row_gap: Option<f32>,
312    pub column_gap: Option<f32>,
313    pub axis: Axis,
314    pub justify: Option<Justify>,
315    pub align: Option<Align>,
316    /// `align-self` (flex/grid cross-axis) and `justify-self` (grid inline-axis)
317    /// for this item, overriding the parent's `align-items`/`justify-items`.
318    pub align_self: Option<Align>,
319    pub justify_self: Option<Align>,
320    /// `justify-items` (grid) and `align-content` (multi-line flex / grid).
321    pub justify_items: Option<Align>,
322    pub align_content: Option<Justify>,
323    pub overflow: Overflow,
324    pub background: Option<Background>,
325    /// `border-radius`, per corner (top-left, top-right, bottom-right, bottom-left).
326    pub radius: Corners,
327    /// `box-shadow` (single, outer). Drawn behind the box's own background.
328    pub box_shadow: Option<BoxShadow>,
329    /// `transform`: an affine applied to this box and its subtree at paint time.
330    /// Visual only: hit regions are not transformed.
331    pub transform: Option<Transform>,
332    /// `cursor`: the pointer shape over this box.
333    pub cursor: Cursor,
334    /// `position` and its `inset` (top, right, bottom, left). `None` per side =
335    /// `auto`. Only meaningful when `position: absolute`.
336    pub position: Position,
337    pub inset: [Option<Len>; 4],
338    /// `aspect-ratio` (width / height).
339    pub aspect_ratio: Option<f32>,
340}
341
342impl Default for Style {
343    fn default() -> Self {
344        Self {
345            display: Display::Block,
346            width: None,
347            height: None,
348            min_width: None,
349            max_width: None,
350            min_height: None,
351            max_height: None,
352            grid_columns: Vec::new(),
353            grid_rows: Vec::new(),
354            grid_column: (GridPlace::Auto, GridPlace::Auto),
355            grid_row: (GridPlace::Auto, GridPlace::Auto),
356            grid_auto_flow: GridFlow::Row,
357            grid_auto_rows: Vec::new(),
358            grid_auto_columns: Vec::new(),
359            grow: 0.0,
360            shrink: 1.0,
361            basis: None,
362            wrap: false,
363            opacity: 1.0,
364            text_wrap: TextWrap::Normal,
365            padding: Sides::default(),
366            margin: Sides::default(),
367            border: Sides::default(),
368            border_color: None,
369            gap: 0.0,
370            row_gap: None,
371            column_gap: None,
372            axis: Axis::Row,
373            justify: None,
374            align: None,
375            align_self: None,
376            justify_self: None,
377            justify_items: None,
378            align_content: None,
379            overflow: Overflow::Visible,
380            background: None,
381            radius: [0.0; 4],
382            box_shadow: None,
383            transform: None,
384            cursor: Cursor::Default,
385            position: Position::Relative,
386            inset: [None; 4],
387            aspect_ratio: None,
388        }
389    }
390}
391
392/// An image carried by a leaf node. `src` is resolved to a path the painter can
393/// open; the intrinsic size is filled in by the runtime (it reads the file's
394/// header) and sizes the box when CSS gives no width/height.
395#[derive(Clone, Debug)]
396pub struct ImageContent {
397    pub src: String,
398    pub intrinsic: (f32, f32),
399}
400
401/// Text carried by a leaf node.
402#[derive(Clone, Debug)]
403pub struct TextContent {
404    pub text: String,
405    pub font_size: f32,
406    pub weight: u16,
407    pub color: Rgba,
408    pub align: TextAlign,
409    pub wrap: TextWrap,
410    /// `font-family` as a raw CSS list (e.g. `"Inter, sans-serif"`). `None` uses
411    /// the system default. Inherits, like `color` and `font-size`.
412    pub font_family: Option<String>,
413    /// `letter-spacing` / `word-spacing`, extra px between letters / words.
414    pub letter_spacing: Option<f32>,
415    pub word_spacing: Option<f32>,
416    /// `line-height` as an absolute pixel value; `None` uses the font metrics.
417    pub line_height: Option<f32>,
418    /// `font-style: italic`.
419    pub italic: bool,
420    /// `text-decoration: underline` / `line-through`.
421    pub underline: bool,
422    pub strikethrough: bool,
423    /// `white-space: nowrap`: never wrap, even past the box width.
424    pub nowrap: bool,
425    /// Byte index of the caret, when this text is inside the focused input.
426    pub caret: Option<usize>,
427    /// The selected byte range (start < end, normalized), when this text is
428    /// inside the focused input and its selection isn't collapsed. The painter
429    /// highlights it behind the glyphs.
430    pub selection: Option<(usize, usize)>,
431    /// The byte range holding an in-progress IME composition, when this text is
432    /// inside the focused input and something is being composed. The painter
433    /// underlines it.
434    ///
435    /// The composed text is already inside `text`: the shell writes it into the
436    /// bound signal as it is typed, exactly as a browser does to an `<input>`'s
437    /// value during composition. This range only says which part of it is not
438    /// committed yet, so it can be drawn as provisional rather than as text the
439    /// author typed and meant.
440    pub preedit: Option<(usize, usize)>,
441}
442
443/// What an element *is*, for assistive technology. Deliberately a small enum
444/// owned by the layout rather than an `accesskit` type: the layout stays free of
445/// the platform a11y crate, and only the shell translates these.
446///
447/// Resolved during the build, where the tag, the `type=` and the `role=`
448/// attribute are all still in hand, deriving it later from painted output would
449/// be guesswork.
450#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
451pub enum AccessRole {
452    /// Not interesting to a screen reader on its own (a plain layout box).
453    #[default]
454    None,
455    /// Static text.
456    Label,
457    Heading,
458    Button,
459    CheckBox,
460    RadioButton,
461    TextInput,
462    /// `type="textarea"`.
463    MultilineTextInput,
464    /// `type="select"`.
465    ComboBox,
466    Image,
467    /// A navigation target: `to="/path"`, or an explicit `role="link"`. Distinct
468    /// from a button because a screen reader announces it differently, and
469    /// because the distinction is what tells someone they are moving rather than
470    /// acting.
471    Link,
472    /// A box that scrolls its content.
473    ScrollView,
474    /// A meaningful grouping (an explicit `role=` we don't map more precisely).
475    Group,
476}
477
478impl AccessRole {
479    /// Does this element carry meaning worth exposing at all?
480    pub fn is_meaningful(self) -> bool {
481        self != Self::None
482    }
483}
484
485/// The accessibility facts about one node: what it is, what it's called, and what
486/// state it's in. Attached during the build and carried through layout so the
487/// shell can publish a tree with real geometry.
488#[derive(Clone, Debug, Default, PartialEq)]
489pub struct Access {
490    pub role: AccessRole,
491    /// The accessible *name*, what a screen reader announces. For a control this
492    /// is its label, not its value.
493    pub label: Option<String>,
494    /// An input's placeholder. Kept apart from `label` because it is only a
495    /// *fallback* name: a real label (authored `label=`, or a `<text for="…">`)
496    /// must win, and labels are linked after the build, so baking the placeholder
497    /// into `label` would let a hint outrank the actual label.
498    pub placeholder: Option<String>,
499    /// Current value, for inputs and selects.
500    pub value: Option<String>,
501    /// Checked state, for checkboxes and radios.
502    pub checked: Option<bool>,
503}
504
505impl Access {
506    /// What to announce as this element's name: its label, else its placeholder.
507    pub fn name(&self) -> Option<&str> {
508        self.label.as_deref().or(self.placeholder.as_deref())
509    }
510}
511
512/// A node in the view tree: a style, optional text, children, and an optional
513/// `@tap` handler (raw handler source, run by the shell on tap).
514#[derive(Clone, Debug)]
515pub struct Node {
516    pub style: Style,
517    pub text: Option<TextContent>,
518    /// `<image src=…>`.
519    pub image: Option<ImageContent>,
520    /// A checkmark stroked to fill this box, in the given colour. Drawn as a
521    /// path rather than a font glyph, since ✓ is whatever the system font happens to
522    /// ship, which is not a control mark.
523    pub tick: Option<Rgba>,
524    pub children: Vec<Node>,
525    pub on_tap: Option<String>,
526    /// `r-model` signal name for `<input>` nodes (focus target + edit binding).
527    pub model: Option<String>,
528    /// `type="textarea"`: a multi-line text input, `Enter` inserts a newline.
529    pub multiline: bool,
530    /// `type="select"`: the bound `:options`, so the shell can open a dropdown.
531    pub options: Option<Vec<String>>,
532    /// `r-show="false"`: laid out (space reserved) but not painted.
533    pub hidden: bool,
534    /// `id="…"`: a stable identifier a label's `for=` can target.
535    pub id: Option<String>,
536    /// `for="…"` on a label, the `id` of the input it labels. Resolved at build
537    /// time (the label inherits its target's `@tap`), so tapping the label toggles
538    /// the target the same way tapping the target would.
539    pub label_for: Option<String>,
540    /// A label whose `for=` targets a *text* input: the target input's `r-model`.
541    /// The layout emits a `FocusRegion` here so tapping the label focuses that input
542    /// (the caret lands in the input itself, matched by model).
543    pub focus_model: Option<String>,
544    /// This node's tree path, set only when some `:hover`/`:active` rule could
545    /// match it. The layout emits a [`StateRegion`] for such nodes so the shell can
546    /// tell what the pointer is over and hand the path back as interaction state.
547    /// `None`: the common case, costs nothing.
548    pub state_path: Option<Vec<usize>>,
549    /// What this element is, for assistive technology.
550    pub access: Access,
551    /// Which component instance this node belongs to, when it is inside one.
552    ///
553    /// A component's own state is private to the instance, so a handler written
554    /// in a component has to say which instance it is running in: two `<panel>`
555    /// elements are two separate sets of state, and the handler text is
556    /// identical in both.
557    pub instance: Option<String>,
558    /// `r-key` on an `r-for` row: which *item* this node stands for, rather than
559    /// which slot it happens to occupy.
560    ///
561    /// Without it a list is identified by position, so reordering the data moves
562    /// every row's identity by one and anything attached to a row (the caret,
563    /// most visibly) stays behind with the slot. The runtime uses this to follow
564    /// a row across a reorder. Layout itself ignores it.
565    pub key: Option<String>,
566}
567
568impl Node {
569    pub fn new(style: Style) -> Self {
570        Self {
571            style,
572            text: None,
573            image: None,
574            tick: None,
575            children: Vec::new(),
576            on_tap: None,
577            model: None,
578            multiline: false,
579            options: None,
580            hidden: false,
581            id: None,
582            label_for: None,
583            focus_model: None,
584            state_path: None,
585            access: Access::default(),
586            instance: None,
587            key: None,
588        }
589    }
590
591    pub fn text(style: Style, text: TextContent) -> Self {
592        Self {
593            style,
594            text: Some(text),
595            image: None,
596            tick: None,
597            children: Vec::new(),
598            on_tap: None,
599            model: None,
600            multiline: false,
601            options: None,
602            hidden: false,
603            id: None,
604            label_for: None,
605            focus_model: None,
606            state_path: None,
607            access: Access::default(),
608            instance: None,
609            key: None,
610        }
611    }
612
613    pub fn image(style: Style, image: ImageContent) -> Self {
614        Self {
615            style,
616            text: None,
617            image: Some(image),
618            tick: None,
619            children: Vec::new(),
620            on_tap: None,
621            model: None,
622            multiline: false,
623            options: None,
624            hidden: false,
625            id: None,
626            label_for: None,
627            focus_model: None,
628            state_path: None,
629            access: Access::default(),
630            instance: None,
631            key: None,
632        }
633    }
634
635    pub fn with(mut self, child: Node) -> Self {
636        self.children.push(child);
637        self
638    }
639}
640
641/// A resolved, absolutely-positioned box: an optional fill and an optional
642/// border, sharing one rounded-rect geometry.
643#[derive(Clone, Debug)]
644pub struct PaintRect {
645    pub x: f32,
646    pub y: f32,
647    pub width: f32,
648    pub height: f32,
649    pub background: Option<Background>,
650    pub radius: Corners,
651    /// Uniform border width for rendering (0 = none).
652    pub border_width: f32,
653    pub border_color: Option<Rgba>,
654}
655
656/// A resolved, absolutely-positioned text block.
657#[derive(Clone, Debug)]
658pub struct PaintText {
659    pub x: f32,
660    pub y: f32,
661    pub width: f32,
662    pub height: f32,
663    pub content: TextContent,
664}
665
666/// A checkmark stroked inside its laid-out box.
667#[derive(Clone, Copy, Debug)]
668pub struct PaintTick {
669    pub x: f32,
670    pub y: f32,
671    pub width: f32,
672    pub height: f32,
673    pub color: Rgba,
674}
675
676/// An image scaled to fill its laid-out box.
677#[derive(Clone, Debug)]
678pub struct PaintImage {
679    pub x: f32,
680    pub y: f32,
681    pub width: f32,
682    pub height: f32,
683    pub content: ImageContent,
684}
685
686/// A drawable item in painter's order (parents before children).
687#[derive(Clone, Debug)]
688pub enum Paint {
689    Rect(PaintRect),
690    Text(PaintText),
691    Image(PaintImage),
692    Tick(PaintTick),
693    /// A blurred `box-shadow`, drawn behind its box. Geometry already has the
694    /// offset and spread applied.
695    Shadow {
696        x: f32,
697        y: f32,
698        width: f32,
699        height: f32,
700        radius: f32,
701        blur: f32,
702        color: Rgba,
703    },
704    /// Begin clipping subsequent items to this rounded rect (overflow: clip).
705    PushClip {
706        x: f32,
707        y: f32,
708        width: f32,
709        height: f32,
710        radius: Corners,
711    },
712    /// End the most recent clip.
713    PopClip,
714    /// Begin an affine `transform` on the subtree. The matrix already has the
715    /// transform-origin baked in, so it applies directly to absolute coords.
716    PushTransform(Transform),
717    /// End the most recent transform.
718    PopTransform,
719    /// Begin a translucent layer over the subtree (`opacity`). The shape is the
720    /// whole viewport, so the layer fades without also clipping.
721    PushOpacity {
722        alpha: f32,
723        width: f32,
724        height: f32,
725    },
726    /// End the most recent opacity layer.
727    PopOpacity,
728}
729
730/// How far a scroller's content has travelled, in logical pixels. Positive
731/// moves the content up / left, i.e. `y` is "how far down the content we are".
732#[derive(Clone, Copy, Debug, Default, PartialEq)]
733pub struct Offset {
734    pub x: f32,
735    pub y: f32,
736}
737
738impl Offset {
739    pub fn clamp_to(self, max: Offset) -> Offset {
740        Offset {
741            x: self.x.clamp(0.0, max.x),
742            y: self.y.clamp(0.0, max.y),
743        }
744    }
745}
746
747/// A scrollable box. `id` is its index in tree order, stable across rebuilds
748/// as long as the tree's shape is, which is what the shell keys offsets by.
749#[derive(Clone, Debug)]
750pub struct ScrollRegion {
751    pub id: usize,
752    pub x: f32,
753    pub y: f32,
754    pub width: f32,
755    pub height: f32,
756    /// The size of the content inside, which may exceed the box on either axis.
757    pub content_width: f32,
758    pub content_height: f32,
759    /// How far the content can travel on each axis: content - visible (>= 0).
760    pub max: Offset,
761}
762
763impl ScrollRegion {
764    pub fn contains(&self, px: f32, py: f32) -> bool {
765        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
766    }
767
768    /// Whether this box scrolls at all on either axis.
769    pub fn scrollable(&self) -> bool {
770        self.max.x > 0.0 || self.max.y > 0.0
771    }
772}
773
774/// An absolutely-positioned tappable region, carrying its `@tap` handler source.
775#[derive(Clone, Debug)]
776pub struct HitRegion {
777    pub x: f32,
778    pub y: f32,
779    pub width: f32,
780    pub height: f32,
781    pub on_tap: String,
782    /// The `cursor` for this region, so the shell can set the pointer shape when
783    /// it hovers here. Carried on the hit region because that is the geometry the
784    /// shell already hit-tests; a `cursor` on a non-tappable box is not honored.
785    pub cursor: Cursor,
786    /// The component instance this handler was written in, if any. Its state is
787    /// what the handler reads and writes, and two instances of one component
788    /// carry identical handler text, so the text alone cannot say which.
789    pub instance: Option<String>,
790}
791
792impl HitRegion {
793    pub fn contains(&self, px: f32, py: f32) -> bool {
794        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
795    }
796}
797
798/// An absolutely-positioned focusable region for an `<input>`, carrying its
799/// `r-model` signal name.
800#[derive(Clone, Debug)]
801pub struct FocusRegion {
802    pub x: f32,
803    pub y: f32,
804    pub width: f32,
805    pub height: f32,
806    pub model: String,
807    /// The `r-key` of the `r-for` row this input sits in, when it sits in one.
808    ///
809    /// `model` alone does not identify an input: `r-model` is stored as written,
810    /// so every row of a list carries the *same* model text. Without this, two
811    /// inputs in one list are indistinguishable and the caret lands in the first
812    /// of them whichever one was tapped.
813    pub row: Option<String>,
814    /// The input's text box (its laid-out child). The shell needs it to turn a
815    /// click into a caret position.
816    pub text: Option<PaintText>,
817    /// `type="textarea"`: `Enter` inserts a newline instead of being ignored.
818    pub multiline: bool,
819    /// If this input scrolls (a textarea), the index of its `ScrollRegion` in
820    /// `Layout.scrolls`, so the shell can scroll the caret into view.
821    pub scroll_id: Option<usize>,
822}
823
824impl FocusRegion {
825    pub fn contains(&self, px: f32, py: f32) -> bool {
826        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
827    }
828}
829
830/// An absolutely-positioned `type="select"`, carrying its bound options so the
831/// shell can open a dropdown and write the chosen value back to `model`.
832#[derive(Clone, Debug)]
833pub struct SelectRegion {
834    pub x: f32,
835    pub y: f32,
836    pub width: f32,
837    pub height: f32,
838    pub model: String,
839    /// The `r-key` of the `r-for` row this select is in, when it is in one. The
840    /// model repeats across a list's rows, so without this the shell opens the
841    /// first row's dropdown wherever you tapped, draws it over that row, and
842    /// writes the chosen option into it.
843    pub row: Option<String>,
844    pub options: Vec<String>,
845}
846
847impl SelectRegion {
848    pub fn contains(&self, px: f32, py: f32) -> bool {
849        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
850    }
851}
852
853/// An absolutely-positioned box whose styling depends on pointer state
854/// (`:hover` / `:active`), carrying the tree path that identifies it to the
855/// builder. Emitted only for nodes some pointer-state rule could match, so a
856/// document with no such rules produces none.
857#[derive(Clone, Debug)]
858pub struct StateRegion {
859    pub x: f32,
860    pub y: f32,
861    pub width: f32,
862    pub height: f32,
863    /// The node's child-index path from the root, the same identity the binding
864    /// registry uses.
865    pub path: Vec<usize>,
866}
867
868impl StateRegion {
869    pub fn contains(&self, px: f32, py: f32) -> bool {
870        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
871    }
872}
873
874/// One element exposed to assistive technology, with the geometry it ended up
875/// occupying. Emitted in document order, and only for nodes whose role is
876/// meaningful, a plain layout box contributes nothing.
877///
878/// Flat rather than nested: the shell publishes these as children of the window,
879/// which is enough for a screen reader to enumerate and hit-test the UI. Nesting
880/// (landmarks, grouping) can layer on later without changing what is collected.
881#[derive(Clone, Debug)]
882pub struct AccessNode {
883    pub x: f32,
884    pub y: f32,
885    pub width: f32,
886    pub height: f32,
887    pub access: Access,
888    /// `r-model`, when this element is an input, lets the shell match it against
889    /// the focused model and report focus to the platform.
890    pub model: Option<String>,
891}
892
893/// One keyboard-focusable element, in document (Tab) order. Carries the geometry
894/// (for the focus ring) plus how the shell should act on it.
895#[derive(Clone, Debug)]
896pub struct FocusItem {
897    pub x: f32,
898    pub y: f32,
899    pub width: f32,
900    pub height: f32,
901    pub kind: FocusKind,
902    /// The scroller this item sits inside, if any, as an index into
903    /// [`Layout::scrolls`].
904    ///
905    /// The focus ring is painted by the shell as its own scene *after* the
906    /// document's, so it never passes through the `PushClip` a scroller emits
907    /// around its children. Without knowing the enclosing scroller, a ring on a
908    /// row scrolled out of a list draws over whatever is above the list. This
909    /// is the enclosing one, not the item's own: a scroller that is itself
910    /// focusable is clipped by its parent, not by itself.
911    pub scroll: Option<usize>,
912}
913
914impl FocusItem {
915    pub fn contains(&self, px: f32, py: f32) -> bool {
916        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
917    }
918}
919
920#[derive(Clone, Debug)]
921pub enum FocusKind {
922    /// A text / textarea input: focusing it starts caret editing.
923    Text { model: String, row: Option<String>, multiline: bool, text: Option<PaintText> },
924    /// A button / checkbox / radio: Space or Enter runs its handler.
925    Activate { on_tap: String, instance: Option<String> },
926    /// A select: Space or Enter opens its dropdown.
927    Select { model: String, row: Option<String>, options: Vec<String> },
928}
929
930/// The result of laying out a tree: paint items, hit regions, and focus regions,
931/// all in painter's/topmost-last order.
932#[derive(Clone, Debug, Default)]
933pub struct Layout {
934    pub paints: Vec<Paint>,
935    pub hits: Vec<HitRegion>,
936    pub focuses: Vec<FocusRegion>,
937    pub selects: Vec<SelectRegion>,
938    /// Keyboard-focusable elements in document (Tab) order.
939    pub focusables: Vec<FocusItem>,
940    pub scrolls: Vec<ScrollRegion>,
941    /// Boxes with `:hover`/`:active` styling, in painter's order (topmost last).
942    pub states: Vec<StateRegion>,
943    /// Elements exposed to assistive technology, in document order.
944    pub access: Vec<AccessNode>,
945}
946
947/// A node's content box, as an offset from its border-box origin plus a size.
948///
949/// Taffy resolves padding and border against the container during layout, so
950/// these are already absolute pixels: percentage padding and `em` borders are
951/// handled by the time this is asked. Clamped at zero, because padding wider
952/// than the box itself is arithmetic, not a crash.
953fn content_box(layout: &taffy::Layout) -> (f32, f32, f32, f32) {
954    let (p, b) = (layout.padding, layout.border);
955    (
956        p.left + b.left,
957        p.top + b.top,
958        (layout.size.width - p.left - p.right - b.left - b.right).max(0.0),
959        (layout.size.height - p.top - p.bottom - b.top - b.bottom).max(0.0),
960    )
961}
962
963/// Callback that measures a text block:
964/// `(text, font_size, weight, wrap, max_width) -> (w, h)`.
965/// Measures a text node to `(width, height)` given an optional max width. Takes
966/// the whole [`TextContent`] so new text properties (family, spacing, style…)
967/// don't each widen this signature.
968pub type Measure<'a> = dyn FnMut(&TextContent, Option<f32>) -> (f32, f32) + 'a;
969
970/// What each taffy node paints.
971enum PaintKind {
972    Box {
973        bg: Option<Background>,
974        radius: Corners,
975        border_width: f32,
976        border_color: Option<Rgba>,
977        clip: bool,
978        shadow: Option<BoxShadow>,
979    },
980    Text(TextContent),
981    Image(ImageContent),
982    Tick(Rgba),
983}
984
985fn to_dim(l: Len, vp: (f32, f32)) -> Dimension {
986    match l {
987        Len::Px(v) => length(v),
988        Len::Pct(p) => percent(p),
989        Len::Vw(v) => length(vp.0 * v / 100.0),
990        Len::Vh(v) => length(vp.1 * v / 100.0),
991    }
992}
993
994fn to_placement(p: GridPlace) -> GridPlacement {
995    match p {
996        GridPlace::Auto => auto(),
997        GridPlace::Line(i) => line(i),
998        GridPlace::Span(n) => span(n),
999    }
1000}
1001
1002fn to_track(t: Track) -> TrackSizingFunction {
1003    match t {
1004        Track::Px(v) => length(v),
1005        Track::Fr(f) => fr(f),
1006        Track::Auto => auto(),
1007        Track::MinMax(lo, hi) => minmax(
1008            // A flex minimum is invalid; fall back to `auto` (min-content).
1009            match lo {
1010                TrackSide::Px(v) => length(v),
1011                TrackSide::Fr(_) | TrackSide::Auto => auto(),
1012            },
1013            match hi {
1014                TrackSide::Px(v) => length(v),
1015                TrackSide::Fr(f) => fr(f),
1016                TrackSide::Auto => auto(),
1017            },
1018        ),
1019    }
1020}
1021
1022/// Like [`to_track`] but for `grid-auto-rows`/`-columns`, whose tracks can't hold
1023/// a `repeat(…)` and so use taffy's non-repeated track type.
1024fn to_auto_track(t: Track) -> taffy::NonRepeatedTrackSizingFunction {
1025    match t {
1026        Track::Px(v) => length(v),
1027        Track::Fr(f) => fr(f),
1028        Track::Auto => auto(),
1029        Track::MinMax(lo, hi) => minmax(
1030            match lo {
1031                TrackSide::Px(v) => length(v),
1032                TrackSide::Fr(_) | TrackSide::Auto => auto(),
1033            },
1034            match hi {
1035                TrackSide::Px(v) => length(v),
1036                TrackSide::Fr(f) => fr(f),
1037                TrackSide::Auto => auto(),
1038            },
1039        ),
1040    }
1041}
1042
1043/// `vp` is the viewport `(width, height)` in physical pixels, for `vw`/`vh`.
1044fn to_taffy(style: &Style, vp: (f32, f32)) -> taffy::Style {
1045    taffy::Style {
1046        display: match style.display {
1047            // Inline is a normal (block) box; the hug comes from width:auto plus
1048            // not stretching (taffy has no true inline flow).
1049            Display::Block | Display::Inline => taffy::Display::Block,
1050            Display::Flex => taffy::Display::Flex,
1051            Display::Grid => taffy::Display::Grid,
1052            Display::None => taffy::Display::None,
1053        },
1054        grid_template_columns: style.grid_columns.iter().copied().map(to_track).collect(),
1055        grid_template_rows: style.grid_rows.iter().copied().map(to_track).collect(),
1056        grid_column: Line {
1057            start: to_placement(style.grid_column.0),
1058            end: to_placement(style.grid_column.1),
1059        },
1060        grid_row: Line {
1061            start: to_placement(style.grid_row.0),
1062            end: to_placement(style.grid_row.1),
1063        },
1064        grid_auto_flow: match style.grid_auto_flow {
1065            GridFlow::Row => taffy::GridAutoFlow::Row,
1066            GridFlow::Column => taffy::GridAutoFlow::Column,
1067            GridFlow::RowDense => taffy::GridAutoFlow::RowDense,
1068            GridFlow::ColumnDense => taffy::GridAutoFlow::ColumnDense,
1069        },
1070        grid_auto_rows: style.grid_auto_rows.iter().copied().map(to_auto_track).collect(),
1071        grid_auto_columns: style.grid_auto_columns.iter().copied().map(to_auto_track).collect(),
1072        flex_direction: match style.axis {
1073            Axis::Column => FlexDirection::Column,
1074            Axis::Row => FlexDirection::Row,
1075        },
1076        justify_content: style.justify.map(|j| match j {
1077            Justify::Start => JustifyContent::FlexStart,
1078            Justify::Center => JustifyContent::Center,
1079            Justify::End => JustifyContent::FlexEnd,
1080            Justify::SpaceBetween => JustifyContent::SpaceBetween,
1081            Justify::SpaceAround => JustifyContent::SpaceAround,
1082        }),
1083        // Default flex cross-alignment is flex-start (hug), not taffy's stretch,
1084        // so children keep their own width unless the author asks to stretch.
1085        align_items: style
1086            .align
1087            .map(to_align_items)
1088            .or(if style.display == Display::Flex {
1089                Some(AlignItems::FlexStart)
1090            } else {
1091                None
1092            }),
1093        align_self: style.align_self.map(to_align_items),
1094        justify_self: style.justify_self.map(to_align_items),
1095        justify_items: style.justify_items.map(to_align_items),
1096        align_content: style.align_content.map(to_align_content),
1097        position: match style.position {
1098            Position::Relative => taffy::Position::Relative,
1099            Position::Absolute => taffy::Position::Absolute,
1100        },
1101        inset: Rect {
1102            left: to_inset(style.inset[3], vp),
1103            right: to_inset(style.inset[1], vp),
1104            top: to_inset(style.inset[0], vp),
1105            bottom: to_inset(style.inset[2], vp),
1106        },
1107        aspect_ratio: style.aspect_ratio,
1108        // taffy needs to know the box scrolls: it then sizes the box from its own
1109        // width/height (not its content) and reports `content_size`, which is how
1110        // far we can scroll.
1111        overflow: match style.overflow {
1112            Overflow::Scroll => Point {
1113                x: taffy::Overflow::Scroll,
1114                y: taffy::Overflow::Scroll,
1115            },
1116            _ => Point {
1117                x: taffy::Overflow::Visible,
1118                y: taffy::Overflow::Visible,
1119            },
1120        },
1121        flex_grow: style.grow,
1122        flex_shrink: style.shrink,
1123        flex_basis: style.basis.map(|l| to_dim(l, vp)).unwrap_or(auto()),
1124        flex_wrap: if style.wrap {
1125            FlexWrap::Wrap
1126        } else {
1127            FlexWrap::NoWrap
1128        },
1129        size: Size {
1130            // `flex-wrap` + a *percentage* width + a `max-width` trips a taffy
1131            // bug (still present in 0.12): it measures the container's content
1132            // at the full percentage width, ignoring the cap, so it sees one
1133            // row and sizes the cross-axis for one row, then clamps the width
1134            // to `max-width`, wraps to two rows, and never revisits the height.
1135            // The wrapped rows then paint *under* the following sibling. Both a
1136            // definite width and `auto` measure correctly, so for this exact
1137            // combination we drop the percentage to `auto` (fit-content, capped
1138            // by the same `max-width`), which fills available width up to the
1139            // cap for any content that overflows it, i.e. the wrap case.
1140            width: match style.width {
1141                Some(Len::Pct(_)) if style.wrap && style.max_width.is_some() => auto(),
1142                Some(l) => to_dim(l, vp),
1143                None => auto(),
1144            },
1145            height: style.height.map(|l| to_dim(l, vp)).unwrap_or(auto()),
1146        },
1147        min_size: Size {
1148            width: style.min_width.map(|l| to_dim(l, vp)).unwrap_or(auto()),
1149            height: style.min_height.map(|l| to_dim(l, vp)).unwrap_or(auto()),
1150        },
1151        max_size: Size {
1152            // A box with no width hugs its content. Hug means CSS `fit-content`
1153            //, min(max-content, available), so clamp it to the parent's inner
1154            // width. Without this, taffy hands a hugging box its full max-content
1155            // size and it bursts out of a narrower parent. An explicit width or
1156            // max-width is the author's call and is left alone.
1157            width: match (style.max_width, style.width) {
1158                (Some(l), _) => to_dim(l, vp),
1159                // `flex-shrink: 0` says "keep my size", don't clamp behind the
1160                // author's back; let it overflow and let the parent clip it.
1161                // `1.0_f32` spelled out: an unsuffixed float literal here makes
1162                // rustc fall back to f32 through a trait bound it warns about,
1163                // and that fallback is due to become a hard error. Only newer
1164                // toolchains than the one used on Windows report it, so it
1165                // surfaced from CI rather than locally.
1166                (None, None) if style.shrink != 0.0 => percent(1.0_f32),
1167                (None, _) => auto(),
1168            },
1169            height: style.max_height.map(|l| to_dim(l, vp)).unwrap_or(auto()),
1170        },
1171        padding: Rect {
1172            left: length(style.padding.left),
1173            right: length(style.padding.right),
1174            top: length(style.padding.top),
1175            bottom: length(style.padding.bottom),
1176        },
1177        margin: Rect {
1178            left: length(style.margin.left),
1179            right: length(style.margin.right),
1180            top: length(style.margin.top),
1181            bottom: length(style.margin.bottom),
1182        },
1183        border: Rect {
1184            left: length(style.border.left),
1185            right: length(style.border.right),
1186            top: length(style.border.top),
1187            bottom: length(style.border.bottom),
1188        },
1189        // taffy's gap is (column, row): width is the inline gap, height the block
1190        // gap. `column-gap`/`row-gap` override the `gap` shorthand per axis.
1191        gap: Size {
1192            width: length(style.column_gap.unwrap_or(style.gap)),
1193            height: length(style.row_gap.unwrap_or(style.gap)),
1194        },
1195        ..Default::default()
1196    }
1197}
1198
1199fn to_align_items(a: Align) -> AlignItems {
1200    match a {
1201        Align::Start => AlignItems::FlexStart,
1202        Align::Center => AlignItems::Center,
1203        Align::End => AlignItems::FlexEnd,
1204        Align::Stretch => AlignItems::Stretch,
1205    }
1206}
1207
1208fn to_align_content(j: Justify) -> AlignContent {
1209    match j {
1210        Justify::Start => AlignContent::FlexStart,
1211        Justify::Center => AlignContent::Center,
1212        Justify::End => AlignContent::FlexEnd,
1213        Justify::SpaceBetween => AlignContent::SpaceBetween,
1214        Justify::SpaceAround => AlignContent::SpaceAround,
1215    }
1216}
1217
1218fn to_inset(l: Option<Len>, vp: (f32, f32)) -> LengthPercentageAuto {
1219    match l {
1220        None => auto(),
1221        Some(Len::Px(v)) => length(v),
1222        Some(Len::Pct(p)) => percent(p),
1223        Some(Len::Vw(v)) => length(vp.0 * v / 100.0),
1224        Some(Len::Vh(v)) => length(vp.1 * v / 100.0),
1225    }
1226}
1227
1228/// A laid-out `<input>`: its model plus what kind it is. Becomes either a
1229/// `FocusRegion` (text/textarea) or a `SelectRegion` (select) in `collect`.
1230struct Bound {
1231    id: NodeId,
1232    model: String,
1233    /// The enclosing `r-for` row's key, the other half of an input's identity.
1234    row: Option<String>,
1235    multiline: bool,
1236    options: Option<Vec<String>>,
1237}
1238
1239/// The widest a box can ever be, given its own CSS and everything above it.
1240///
1241/// `parent` is the parent's *inner* width bound, `None` when nothing above has
1242/// pinned one down. A `%` resolves against it; `vw`/`vh` against the viewport.
1243/// `min-width` wins over `max-width`, as in CSS.
1244fn width_cap(style: &Style, parent: Option<f32>, vp: (f32, f32)) -> Option<f32> {
1245    let resolve = |l: Len| match l {
1246        Len::Px(px) => Some(px),
1247        Len::Pct(p) => parent.map(|b| b * p),
1248        Len::Vw(v) => Some(vp.0 * v / 100.0),
1249        Len::Vh(v) => Some(vp.1 * v / 100.0),
1250    };
1251    let capped = match (style.width.and_then(resolve), style.max_width.and_then(resolve)) {
1252        (Some(w), Some(m)) => Some(w.min(m)),
1253        (Some(w), None) => Some(w),
1254        (None, Some(m)) => Some(parent.map_or(m, |p| p.min(m))),
1255        (None, None) => parent,
1256    };
1257    match style.min_width.and_then(resolve) {
1258        Some(min) => Some(capped.map_or(min, |c| c.max(min))),
1259        None => capped,
1260    }
1261}
1262
1263/// The cap to hand this box's children: its own, less what its padding and
1264/// border take out of it.
1265fn inner_cap(style: &Style, own: Option<f32>) -> Option<f32> {
1266    own.map(|w| {
1267        let horizontal = style.padding.left + style.padding.right + style.border.left + style.border.right;
1268        (w - horizontal).max(0.0)
1269    })
1270}
1271
1272#[allow(clippy::too_many_arguments)]
1273fn build(
1274    tree: &mut TaffyTree<TextContent>,
1275    node: &Node,
1276    paint: &mut Vec<(NodeId, PaintKind)>,
1277    handlers: &mut Vec<(NodeId, String, Cursor, Option<String>)>,
1278    models: &mut Vec<Bound>,
1279    focus_labels: &mut Vec<(NodeId, String, Option<String>)>,
1280    hidden: &mut Vec<NodeId>,
1281    opacities: &mut Vec<(NodeId, f32)>,
1282    scrolls: &mut Vec<NodeId>,
1283    transforms: &mut Vec<(NodeId, Transform)>,
1284    states: &mut Vec<(NodeId, Vec<usize>)>,
1285    access: &mut Vec<(NodeId, Access, Option<String>)>,
1286    vp: (f32, f32),
1287    // `cap` is the widest this node can end up, from the constraint chain above
1288    // it; `caps` is where each text leaf's own cap is left for the measure hook.
1289    cap: Option<f32>,
1290    caps: &mut HashMap<NodeId, f32>,
1291    // The `r-key` of the row this node is inside, inherited by everything under
1292    // it. A keyed node starts a new row; nothing else changes it.
1293    row: Option<&str>,
1294) -> NodeId {
1295    let own_cap = width_cap(&node.style, cap, vp);
1296    let child_cap = inner_cap(&node.style, own_cap);
1297    let row = node.key.as_deref().or(row);
1298    let id = if let Some(tc) = &node.text {
1299        // Text leaves carry their content as taffy context so the measure hook
1300        // can shape them.
1301        let id = tree
1302            .new_leaf_with_context(to_taffy(&node.style, vp), tc.clone())
1303            .expect("taffy text leaf");
1304        // A text node is a box too: its background and border paint under the
1305        // glyphs. (collect() walks every paint entry for a node, in order.)
1306        paint.push((
1307            id,
1308            PaintKind::Box {
1309                bg: node.style.background.clone(),
1310                radius: node.style.radius,
1311                border_width: node.style.border.top,
1312                border_color: node.style.border_color,
1313                clip: node.style.overflow != Overflow::Visible,
1314                shadow: node.style.box_shadow,
1315            },
1316        ));
1317        paint.push((id, PaintKind::Text(tc.clone())));
1318        // The text wraps inside this box, so its own padding and border come
1319        // out of the width available to the glyphs.
1320        if let Some(c) = child_cap {
1321            caps.insert(id, c);
1322        }
1323        id
1324    } else if let Some(color) = node.tick {
1325        let id = tree.new_leaf(to_taffy(&node.style, vp)).expect("taffy tick");
1326        paint.push((id, PaintKind::Tick(color)));
1327        id
1328    } else if let Some(ic) = &node.image {
1329        // An image with no CSS size falls back to its intrinsic pixel size, the
1330        // way a browser sizes an <img>.
1331        let mut ts = to_taffy(&node.style, vp);
1332        if node.style.width.is_none() {
1333            ts.size.width = length(ic.intrinsic.0);
1334        }
1335        if node.style.height.is_none() {
1336            ts.size.height = length(ic.intrinsic.1);
1337        }
1338        let id = tree.new_leaf(ts).expect("taffy image leaf");
1339        paint.push((
1340            id,
1341            PaintKind::Box {
1342                bg: node.style.background.clone(),
1343                radius: node.style.radius,
1344                border_width: node.style.border.top,
1345                border_color: node.style.border_color,
1346                clip: node.style.overflow != Overflow::Visible,
1347                shadow: node.style.box_shadow,
1348            },
1349        ));
1350        paint.push((id, PaintKind::Image(ic.clone())));
1351        id
1352    } else {
1353        let children: Vec<NodeId> = node
1354            .children
1355            .iter()
1356            .map(|c| build(tree, c, paint, handlers, models, focus_labels, hidden, opacities, scrolls, transforms, states, access, vp, child_cap, caps, row))
1357            .collect();
1358        let id = if children.is_empty() {
1359            tree.new_leaf(to_taffy(&node.style, vp)).expect("taffy leaf")
1360        } else {
1361            tree.new_with_children(to_taffy(&node.style, vp), &children)
1362                .expect("taffy node")
1363        };
1364        paint.push((
1365            id,
1366            PaintKind::Box {
1367                bg: node.style.background.clone(),
1368                radius: node.style.radius,
1369                // Uniform border for rendering (top width is representative).
1370                border_width: node.style.border.top,
1371                border_color: node.style.border_color,
1372                clip: node.style.overflow != Overflow::Visible,
1373                shadow: node.style.box_shadow,
1374            },
1375        ));
1376        id
1377    };
1378    if let Some(handler) = &node.on_tap {
1379        handlers.push((id, handler.clone(), node.style.cursor, node.instance.clone()));
1380    }
1381    if let Some(model) = &node.model {
1382        models.push(Bound {
1383            id,
1384            model: model.clone(),
1385            row: row.map(str::to_string),
1386            multiline: node.multiline,
1387            options: node.options.clone(),
1388        });
1389    }
1390    if let Some(fm) = &node.focus_model {
1391        focus_labels.push((id, fm.clone(), row.map(str::to_string)));
1392    }
1393    if node.hidden {
1394        hidden.push(id);
1395    }
1396    if node.style.opacity < 1.0 {
1397        opacities.push((id, node.style.opacity.max(0.0)));
1398    }
1399    if let Some(tf) = node.style.transform {
1400        transforms.push((id, tf));
1401    }
1402    if node.style.overflow == Overflow::Scroll {
1403        scrolls.push(id);
1404    }
1405    if let Some(path) = &node.state_path {
1406        states.push((id, path.clone()));
1407    }
1408    if node.access.role.is_meaningful() {
1409        access.push((id, node.access.clone(), node.model.clone()));
1410    }
1411    id
1412}
1413
1414#[allow(clippy::too_many_arguments)]
1415fn collect(
1416    tree: &TaffyTree<TextContent>,
1417    id: NodeId,
1418    origin_x: f32,
1419    origin_y: f32,
1420    paint: &[(NodeId, PaintKind)],
1421    handlers: &[(NodeId, String, Cursor, Option<String>)],
1422    models: &[Bound],
1423    focus_labels: &[(NodeId, String, Option<String>)],
1424    hidden: &[NodeId],
1425    opacities: &[(NodeId, f32)],
1426    scrolls: &[NodeId],
1427    transforms: &[(NodeId, Transform)],
1428    states: &[(NodeId, Vec<usize>)],
1429    access: &[(NodeId, Access, Option<String>)],
1430    offsets: &[Offset],
1431    vp: (f32, f32),
1432    // The nearest scroller above this node, so a focus ring can be clipped to
1433    // the box that clips everything else in it.
1434    inside_scroll: Option<usize>,
1435    out: &mut Layout,
1436) {
1437    let layout = tree.layout(id).expect("layout");
1438    let x = origin_x + layout.location.x;
1439    let y = origin_y + layout.location.y;
1440
1441    // r-show=false: the node kept its layout slot but paints nothing (nor its
1442    // subtree, nor its hit regions).
1443    if hidden.contains(&id) {
1444        return;
1445    }
1446
1447    // opacity fades this node and everything under it, so the layer opens
1448    // before the node paints its own background.
1449    let alpha = opacities
1450        .iter()
1451        .find(|(nid, _)| *nid == id)
1452        .map(|(_, a)| *a)
1453        .unwrap_or(1.0);
1454    if alpha < 1.0 {
1455        out.paints.push(Paint::PushOpacity {
1456            alpha,
1457            width: vp.0,
1458            height: vp.1,
1459        });
1460    }
1461
1462    // `transform` wraps the box and its subtree. The parsed matrix is in local
1463    // coords; bake in the origin (CSS default: the box centre) so it applies to
1464    // absolute coordinates directly.
1465    let transform = transforms.iter().find(|(nid, _)| *nid == id).map(|(_, m)| *m);
1466    if let Some(m) = transform {
1467        let (ox, oy) = (x + layout.size.width / 2.0, y + layout.size.height / 2.0);
1468        out.paints.push(Paint::PushTransform(centre_transform(m, ox, oy)));
1469    }
1470
1471    let mut clip = false;
1472    let mut clip_radius = [0.0; 4];
1473    // A node can emit more than one paint (a text node paints its box, then its
1474    // glyphs), so walk every entry it owns, in order.
1475    for (_, kind) in paint.iter().filter(|(nid, _)| *nid == id) {
1476        match kind {
1477            PaintKind::Box {
1478                bg,
1479                radius,
1480                border_width,
1481                border_color,
1482                clip: c,
1483                shadow,
1484            } => {
1485                clip = *c;
1486                clip_radius = *radius;
1487                // The shadow goes down first, so the box's own fill sits on top.
1488                // Outer shadows only for now; inset is parsed but not drawn.
1489                if let Some(sh) = shadow.filter(|s| !s.inset) {
1490                    out.paints.push(Paint::Shadow {
1491                        x: x + sh.dx - sh.spread,
1492                        y: y + sh.dy - sh.spread,
1493                        width: layout.size.width + 2.0 * sh.spread,
1494                        height: layout.size.height + 2.0 * sh.spread,
1495                        // vello's blurred rect takes one radius; use the largest
1496                        // corner as a stand-in (per-corner blur isn't supported).
1497                        radius: radius.iter().copied().fold(0.0, f32::max),
1498                        blur: sh.blur,
1499                        color: sh.color,
1500                    });
1501                }
1502                let has_border = *border_width > 0.0 && border_color.is_some();
1503                if bg.is_some() || has_border {
1504                    out.paints.push(Paint::Rect(PaintRect {
1505                        x,
1506                        y,
1507                        width: layout.size.width,
1508                        height: layout.size.height,
1509                        background: bg.clone(),
1510                        radius: *radius,
1511                        border_width: *border_width,
1512                        border_color: *border_color,
1513                    }));
1514                }
1515            }
1516            // Glyphs go in the *content* box, inside this node's own padding and
1517            // border. Painting them at the border box put a padded label flush
1518            // against the edge of its own background: the box grew, the words
1519            // did not move. The size matters as much as the origin, since it is
1520            // what the run is aligned and wrapped within.
1521            PaintKind::Text(tc) => {
1522                let (cx, cy, cw, ch) = content_box(layout);
1523                out.paints.push(Paint::Text(PaintText {
1524                    x: x + cx,
1525                    y: y + cy,
1526                    width: cw,
1527                    height: ch,
1528                    content: tc.clone(),
1529                }))
1530            }
1531            PaintKind::Tick(color) => out.paints.push(Paint::Tick(PaintTick {
1532                x,
1533                y,
1534                width: layout.size.width,
1535                height: layout.size.height,
1536                color: *color,
1537            })),
1538            PaintKind::Image(ic) => out.paints.push(Paint::Image(PaintImage {
1539                x,
1540                y,
1541                width: layout.size.width,
1542                height: layout.size.height,
1543                content: ic.clone(),
1544            })),
1545        }
1546    }
1547
1548    // A `for=` label targeting a text input: a focus region at the label's box,
1549    // carrying the *target's* model, so tapping the label focuses that input.
1550    if let Some((_, model, row)) = focus_labels.iter().find(|(nid, ..)| *nid == id) {
1551        out.focuses.push(FocusRegion {
1552            x,
1553            y,
1554            width: layout.size.width,
1555            height: layout.size.height,
1556            model: model.clone(),
1557            row: row.clone(),
1558            text: None,
1559            multiline: false,
1560            scroll_id: None,
1561        });
1562    }
1563
1564    // Assistive technology needs the same geometry the pointer uses, so this rides
1565    // the same walk. `hidden` nodes returned above, so an `r-show="false"` element
1566    // is absent from the a11y tree too, not merely invisible.
1567    if let Some((_, node_access, model)) = access.iter().find(|(nid, ..)| *nid == id) {
1568        out.access.push(AccessNode {
1569            x,
1570            y,
1571            width: layout.size.width,
1572            height: layout.size.height,
1573            access: node_access.clone(),
1574            model: model.clone(),
1575        });
1576    }
1577
1578    // Emitted for any box a `:hover`/`:active` rule could style, tappable or not,
1579    // unlike `cursor`, pointer-state styling is not limited to `@tap` boxes.
1580    if let Some((_, path)) = states.iter().find(|(nid, _)| *nid == id) {
1581        out.states.push(StateRegion {
1582            x,
1583            y,
1584            width: layout.size.width,
1585            height: layout.size.height,
1586            path: path.clone(),
1587        });
1588    }
1589
1590    if let Some((_, handler, cursor, instance)) = handlers.iter().find(|(nid, ..)| *nid == id) {
1591        out.hits.push(HitRegion {
1592            x,
1593            y,
1594            width: layout.size.width,
1595            height: layout.size.height,
1596            on_tap: handler.clone(),
1597            cursor: *cursor,
1598            instance: instance.clone(),
1599        });
1600    }
1601
1602    let (fw, fh) = (layout.size.width, layout.size.height);
1603    if let Some(bound) = models.iter().find(|b| b.id == id) {
1604        if let Some(options) = &bound.options {
1605            // A select: no caret, just a tappable box that opens a dropdown.
1606            out.selects.push(SelectRegion {
1607                x,
1608                y,
1609                width: fw,
1610                height: fh,
1611                model: bound.model.clone(),
1612                row: bound.row.clone(),
1613                options: options.clone(),
1614            });
1615            out.focusables.push(FocusItem {
1616                x,
1617                y,
1618                width: fw,
1619                height: fh,
1620                kind: FocusKind::Select {
1621                    model: bound.model.clone(),
1622                    row: bound.row.clone(),
1623                    options: options.clone(),
1624                },
1625                scroll: inside_scroll,
1626            });
1627        } else {
1628            // A text/textarea input: its value is rendered by its single text
1629            // child; find that child's box so a tap resolves to a caret index.
1630            let text = tree
1631                .children(id)
1632                .ok()
1633                .and_then(|kids| kids.first().copied())
1634                .and_then(|kid| {
1635                    let child = tree.layout(kid).ok()?;
1636                    let content = paint.iter().find_map(|(nid, k)| match k {
1637                        PaintKind::Text(tc) if *nid == kid => Some(tc.clone()),
1638                        _ => None,
1639                    })?;
1640                    // The same content box the glyphs are painted in, or the
1641                    // caret would sit at the border box while the text it is
1642                    // supposed to be inside sits within the padding.
1643                    let (cx, cy, cw, ch) = content_box(child);
1644                    Some(PaintText {
1645                        x: x + child.location.x + cx,
1646                        y: y + child.location.y + cy,
1647                        width: cw,
1648                        height: ch,
1649                        content,
1650                    })
1651                });
1652            out.focuses.push(FocusRegion {
1653                x,
1654                y,
1655                width: fw,
1656                height: fh,
1657                model: bound.model.clone(),
1658                row: bound.row.clone(),
1659                text: text.clone(),
1660                multiline: bound.multiline,
1661                // The scroll block below assigns ids as `out.scrolls.len()`, so if
1662                // this node scrolls it will get the current length as its id.
1663                scroll_id: scrolls.contains(&id).then(|| out.scrolls.len()),
1664            });
1665            out.focusables.push(FocusItem {
1666                x,
1667                y,
1668                width: fw,
1669                height: fh,
1670                kind: FocusKind::Text {
1671                    model: bound.model.clone(),
1672                    row: bound.row.clone(),
1673                    multiline: bound.multiline,
1674                    text,
1675                },
1676                scroll: inside_scroll,
1677            });
1678        }
1679    } else if let Some((_, handler, _, instance)) = handlers.iter().find(|(nid, ..)| *nid == id) {
1680        // A button / checkbox / radio (anything with a `@tap` handler) is
1681        // keyboard-reachable: Space or Enter runs the same handler as a tap.
1682        out.focusables.push(FocusItem {
1683            x,
1684            y,
1685            width: fw,
1686            height: fh,
1687            kind: FocusKind::Activate { on_tap: handler.clone(), instance: instance.clone() },
1688            scroll: inside_scroll,
1689        });
1690    }
1691
1692    // overflow: clip/scroll, bound the subtree to this box (following its corners).
1693    if clip {
1694        out.paints.push(Paint::PushClip {
1695            x,
1696            y,
1697            width: layout.size.width,
1698            height: layout.size.height,
1699            radius: clip_radius,
1700        });
1701    }
1702
1703    // A scroller shifts its children by the current offset and registers itself
1704    // so the wheel, the scrollbars and the keyboard can find it.
1705    let mut shift = Offset::default();
1706    // What the children are clipped by: this box if it scrolls, otherwise
1707    // whatever was clipping us.
1708    let mut child_scroll = inside_scroll;
1709    if scrolls.contains(&id) {
1710        let sid = out.scrolls.len();
1711        child_scroll = Some(sid);
1712        let max = Offset {
1713            x: (layout.content_size.width - layout.size.width).max(0.0),
1714            y: (layout.content_size.height - layout.size.height).max(0.0),
1715        };
1716        shift = offsets.get(sid).copied().unwrap_or_default().clamp_to(max);
1717        out.scrolls.push(ScrollRegion {
1718            id: sid,
1719            x,
1720            y,
1721            width: layout.size.width,
1722            height: layout.size.height,
1723            content_width: layout.content_size.width,
1724            content_height: layout.content_size.height,
1725            max,
1726        });
1727    }
1728
1729    for child in tree.children(id).expect("children") {
1730        collect(
1731            tree,
1732            child,
1733            x - shift.x,
1734            y - shift.y,
1735            paint,
1736            handlers,
1737            models,
1738            focus_labels,
1739            hidden,
1740            opacities,
1741            scrolls,
1742            transforms,
1743            states,
1744            access,
1745            offsets,
1746            vp,
1747            child_scroll,
1748            out,
1749        );
1750    }
1751    if clip {
1752        out.paints.push(Paint::PopClip);
1753    }
1754    if transform.is_some() {
1755        out.paints.push(Paint::PopTransform);
1756    }
1757    if alpha < 1.0 {
1758        out.paints.push(Paint::PopOpacity);
1759    }
1760}
1761
1762/// Bake a transform-origin at `(ox, oy)` into a local transform matrix `m`, so
1763/// the result maps absolute coordinates: `p ↦ M·(p − o) + o`.
1764fn centre_transform(m: Transform, ox: f32, oy: f32) -> Transform {
1765    let [a, b, c, d, e, f] = m;
1766    [
1767        a,
1768        b,
1769        c,
1770        d,
1771        e + ox - a * ox - c * oy,
1772        f + oy - b * ox - d * oy,
1773    ]
1774}
1775
1776/// Lay out `root` into an `avail_w` x `avail_h` viewport, returning paint items
1777/// and hit regions. Text leaves are sized via `measure`.
1778pub fn layout(root: &Node, avail_w: f32, avail_h: f32, measure: &mut Measure) -> Layout {
1779    layout_scrolled(root, avail_w, avail_h, &[], measure)
1780}
1781
1782/// Lay out with the shell's current scroll offsets (one per scrollable box, in
1783/// tree order). A missing entry is 0.
1784pub fn layout_scrolled(
1785    root: &Node,
1786    avail_w: f32,
1787    avail_h: f32,
1788    offsets: &[Offset],
1789    measure: &mut Measure,
1790) -> Layout {
1791    let mut tree: TaffyTree<TextContent> = TaffyTree::new();
1792    // Taffy rounds boxes to whole pixels by default, which can shave a fraction
1793    // off a text box and make paint re-wrap the last word into a line the box
1794    // has no height for. Keep the exact sizes measure asked for.
1795    tree.disable_rounding();
1796    let mut paint = Vec::new();
1797    let mut handlers = Vec::new();
1798    let mut models = Vec::new();
1799    let mut focus_labels = Vec::new();
1800    let mut hidden = Vec::new();
1801    let mut opacities = Vec::new();
1802    let mut scrolls = Vec::new();
1803    let mut transforms = Vec::new();
1804    let mut states = Vec::new();
1805    let mut access = Vec::new();
1806    let vp = (avail_w, avail_h);
1807    let mut caps: HashMap<NodeId, f32> = HashMap::new();
1808    let root_id = build(
1809        &mut tree,
1810        root,
1811        &mut paint,
1812        &mut handlers,
1813        &mut models,
1814        &mut focus_labels,
1815        &mut hidden,
1816        &mut opacities,
1817        &mut scrolls,
1818        &mut transforms,
1819        &mut states,
1820        &mut access,
1821        vp,
1822        // The root is forced to the viewport below, so that is the widest
1823        // anything can be.
1824        Some(avail_w),
1825        &mut caps,
1826        None, // the root is not inside any row
1827    );
1828
1829    // Force the root to fill the viewport so a `screen` always covers the window.
1830    let mut root_style = to_taffy(&root.style, vp);
1831    root_style.size = Size {
1832        width: length(avail_w),
1833        height: length(avail_h),
1834    };
1835    tree.set_style(root_id, root_style).expect("set root style");
1836
1837    tree.compute_layout_with_measure(
1838        root_id,
1839        Size {
1840            width: AvailableSpace::Definite(avail_w),
1841            height: AvailableSpace::Definite(avail_h),
1842        },
1843        |known, available, id, ctx, _style| {
1844            if let (Some(w), Some(h)) = (known.width, known.height) {
1845                return Size { width: w, height: h };
1846            }
1847            match ctx {
1848                Some(tc) => {
1849                    // Wrap to a definite width; otherwise (content sizing) let
1850                    // the text take its natural single-line width.
1851                    let max = known.width.or(match available.width {
1852                        AvailableSpace::Definite(w) => Some(w),
1853                        // Min-content is the narrowest the box can be without
1854                        // its content spilling, which for text is the longest
1855                        // unbreakable word. Wrapping at zero asks exactly that.
1856                        // Answering it with the single-line width (which is
1857                        // what "no constraint" means here) told taffy the box
1858                        // could never be narrower than one long line.
1859                        AvailableSpace::MinContent => Some(0.0),
1860                        AvailableSpace::MaxContent => None,
1861                    });
1862                    // Never measure at a width this box can never have. Taffy
1863                    // sizes a capped box from its *un*capped content, clamps
1864                    // the width afterwards, and does not revisit the height, so
1865                    // a `max-width` card was measured as one long line and
1866                    // drawn as three. Wrapping at the cap up front is what
1867                    // makes the measured height the height that gets drawn.
1868                    let cap = caps.get(&id).copied();
1869                    let max = match (max, cap) {
1870                        (Some(m), Some(c)) => Some(m.min(c)),
1871                        (None, Some(c)) => Some(c),
1872                        (m, None) => m,
1873                    };
1874                    let (w, h) = measure(tc, max);
1875                    Size {
1876                        width: known.width.unwrap_or(w),
1877                        height: known.height.unwrap_or(h),
1878                    }
1879                }
1880                None => Size {
1881                    width: 0.0,
1882                    height: 0.0,
1883                },
1884            }
1885        },
1886    )
1887    .expect("compute layout");
1888
1889    let mut out = Layout::default();
1890    collect(
1891        &tree, root_id, 0.0, 0.0, &paint, &handlers, &models, &focus_labels, &hidden, &opacities,
1892        &scrolls, &transforms, &states, &access, offsets, vp, None, &mut out,
1893    );
1894    out
1895}