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