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}