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 (None, None) if style.shrink != 0.0 => percent(1.0),
1162 (None, _) => auto(),
1163 },
1164 height: style.max_height.map(|l| to_dim(l, vp)).unwrap_or(auto()),
1165 },
1166 padding: Rect {
1167 left: length(style.padding.left),
1168 right: length(style.padding.right),
1169 top: length(style.padding.top),
1170 bottom: length(style.padding.bottom),
1171 },
1172 margin: Rect {
1173 left: length(style.margin.left),
1174 right: length(style.margin.right),
1175 top: length(style.margin.top),
1176 bottom: length(style.margin.bottom),
1177 },
1178 border: Rect {
1179 left: length(style.border.left),
1180 right: length(style.border.right),
1181 top: length(style.border.top),
1182 bottom: length(style.border.bottom),
1183 },
1184 // taffy's gap is (column, row): width is the inline gap, height the block
1185 // gap. `column-gap`/`row-gap` override the `gap` shorthand per axis.
1186 gap: Size {
1187 width: length(style.column_gap.unwrap_or(style.gap)),
1188 height: length(style.row_gap.unwrap_or(style.gap)),
1189 },
1190 ..Default::default()
1191 }
1192}
1193
1194fn to_align_items(a: Align) -> AlignItems {
1195 match a {
1196 Align::Start => AlignItems::FlexStart,
1197 Align::Center => AlignItems::Center,
1198 Align::End => AlignItems::FlexEnd,
1199 Align::Stretch => AlignItems::Stretch,
1200 }
1201}
1202
1203fn to_align_content(j: Justify) -> AlignContent {
1204 match j {
1205 Justify::Start => AlignContent::FlexStart,
1206 Justify::Center => AlignContent::Center,
1207 Justify::End => AlignContent::FlexEnd,
1208 Justify::SpaceBetween => AlignContent::SpaceBetween,
1209 Justify::SpaceAround => AlignContent::SpaceAround,
1210 }
1211}
1212
1213fn to_inset(l: Option<Len>, vp: (f32, f32)) -> LengthPercentageAuto {
1214 match l {
1215 None => auto(),
1216 Some(Len::Px(v)) => length(v),
1217 Some(Len::Pct(p)) => percent(p),
1218 Some(Len::Vw(v)) => length(vp.0 * v / 100.0),
1219 Some(Len::Vh(v)) => length(vp.1 * v / 100.0),
1220 }
1221}
1222
1223/// A laid-out `<input>`: its model plus what kind it is. Becomes either a
1224/// `FocusRegion` (text/textarea) or a `SelectRegion` (select) in `collect`.
1225struct Bound {
1226 id: NodeId,
1227 model: String,
1228 /// The enclosing `r-for` row's key, the other half of an input's identity.
1229 row: Option<String>,
1230 multiline: bool,
1231 options: Option<Vec<String>>,
1232}
1233
1234/// The widest a box can ever be, given its own CSS and everything above it.
1235///
1236/// `parent` is the parent's *inner* width bound, `None` when nothing above has
1237/// pinned one down. A `%` resolves against it; `vw`/`vh` against the viewport.
1238/// `min-width` wins over `max-width`, as in CSS.
1239fn width_cap(style: &Style, parent: Option<f32>, vp: (f32, f32)) -> Option<f32> {
1240 let resolve = |l: Len| match l {
1241 Len::Px(px) => Some(px),
1242 Len::Pct(p) => parent.map(|b| b * p),
1243 Len::Vw(v) => Some(vp.0 * v / 100.0),
1244 Len::Vh(v) => Some(vp.1 * v / 100.0),
1245 };
1246 let capped = match (style.width.and_then(resolve), style.max_width.and_then(resolve)) {
1247 (Some(w), Some(m)) => Some(w.min(m)),
1248 (Some(w), None) => Some(w),
1249 (None, Some(m)) => Some(parent.map_or(m, |p| p.min(m))),
1250 (None, None) => parent,
1251 };
1252 match style.min_width.and_then(resolve) {
1253 Some(min) => Some(capped.map_or(min, |c| c.max(min))),
1254 None => capped,
1255 }
1256}
1257
1258/// The cap to hand this box's children: its own, less what its padding and
1259/// border take out of it.
1260fn inner_cap(style: &Style, own: Option<f32>) -> Option<f32> {
1261 own.map(|w| {
1262 let horizontal = style.padding.left + style.padding.right + style.border.left + style.border.right;
1263 (w - horizontal).max(0.0)
1264 })
1265}
1266
1267#[allow(clippy::too_many_arguments)]
1268fn build(
1269 tree: &mut TaffyTree<TextContent>,
1270 node: &Node,
1271 paint: &mut Vec<(NodeId, PaintKind)>,
1272 handlers: &mut Vec<(NodeId, String, Cursor, Option<String>)>,
1273 models: &mut Vec<Bound>,
1274 focus_labels: &mut Vec<(NodeId, String, Option<String>)>,
1275 hidden: &mut Vec<NodeId>,
1276 opacities: &mut Vec<(NodeId, f32)>,
1277 scrolls: &mut Vec<NodeId>,
1278 transforms: &mut Vec<(NodeId, Transform)>,
1279 states: &mut Vec<(NodeId, Vec<usize>)>,
1280 access: &mut Vec<(NodeId, Access, Option<String>)>,
1281 vp: (f32, f32),
1282 // `cap` is the widest this node can end up, from the constraint chain above
1283 // it; `caps` is where each text leaf's own cap is left for the measure hook.
1284 cap: Option<f32>,
1285 caps: &mut HashMap<NodeId, f32>,
1286 // The `r-key` of the row this node is inside, inherited by everything under
1287 // it. A keyed node starts a new row; nothing else changes it.
1288 row: Option<&str>,
1289) -> NodeId {
1290 let own_cap = width_cap(&node.style, cap, vp);
1291 let child_cap = inner_cap(&node.style, own_cap);
1292 let row = node.key.as_deref().or(row);
1293 let id = if let Some(tc) = &node.text {
1294 // Text leaves carry their content as taffy context so the measure hook
1295 // can shape them.
1296 let id = tree
1297 .new_leaf_with_context(to_taffy(&node.style, vp), tc.clone())
1298 .expect("taffy text leaf");
1299 // A text node is a box too: its background and border paint under the
1300 // glyphs. (collect() walks every paint entry for a node, in order.)
1301 paint.push((
1302 id,
1303 PaintKind::Box {
1304 bg: node.style.background.clone(),
1305 radius: node.style.radius,
1306 border_width: node.style.border.top,
1307 border_color: node.style.border_color,
1308 clip: node.style.overflow != Overflow::Visible,
1309 shadow: node.style.box_shadow,
1310 },
1311 ));
1312 paint.push((id, PaintKind::Text(tc.clone())));
1313 // The text wraps inside this box, so its own padding and border come
1314 // out of the width available to the glyphs.
1315 if let Some(c) = child_cap {
1316 caps.insert(id, c);
1317 }
1318 id
1319 } else if let Some(color) = node.tick {
1320 let id = tree.new_leaf(to_taffy(&node.style, vp)).expect("taffy tick");
1321 paint.push((id, PaintKind::Tick(color)));
1322 id
1323 } else if let Some(ic) = &node.image {
1324 // An image with no CSS size falls back to its intrinsic pixel size, the
1325 // way a browser sizes an <img>.
1326 let mut ts = to_taffy(&node.style, vp);
1327 if node.style.width.is_none() {
1328 ts.size.width = length(ic.intrinsic.0);
1329 }
1330 if node.style.height.is_none() {
1331 ts.size.height = length(ic.intrinsic.1);
1332 }
1333 let id = tree.new_leaf(ts).expect("taffy image leaf");
1334 paint.push((
1335 id,
1336 PaintKind::Box {
1337 bg: node.style.background.clone(),
1338 radius: node.style.radius,
1339 border_width: node.style.border.top,
1340 border_color: node.style.border_color,
1341 clip: node.style.overflow != Overflow::Visible,
1342 shadow: node.style.box_shadow,
1343 },
1344 ));
1345 paint.push((id, PaintKind::Image(ic.clone())));
1346 id
1347 } else {
1348 let children: Vec<NodeId> = node
1349 .children
1350 .iter()
1351 .map(|c| build(tree, c, paint, handlers, models, focus_labels, hidden, opacities, scrolls, transforms, states, access, vp, child_cap, caps, row))
1352 .collect();
1353 let id = if children.is_empty() {
1354 tree.new_leaf(to_taffy(&node.style, vp)).expect("taffy leaf")
1355 } else {
1356 tree.new_with_children(to_taffy(&node.style, vp), &children)
1357 .expect("taffy node")
1358 };
1359 paint.push((
1360 id,
1361 PaintKind::Box {
1362 bg: node.style.background.clone(),
1363 radius: node.style.radius,
1364 // Uniform border for rendering (top width is representative).
1365 border_width: node.style.border.top,
1366 border_color: node.style.border_color,
1367 clip: node.style.overflow != Overflow::Visible,
1368 shadow: node.style.box_shadow,
1369 },
1370 ));
1371 id
1372 };
1373 if let Some(handler) = &node.on_tap {
1374 handlers.push((id, handler.clone(), node.style.cursor, node.instance.clone()));
1375 }
1376 if let Some(model) = &node.model {
1377 models.push(Bound {
1378 id,
1379 model: model.clone(),
1380 row: row.map(str::to_string),
1381 multiline: node.multiline,
1382 options: node.options.clone(),
1383 });
1384 }
1385 if let Some(fm) = &node.focus_model {
1386 focus_labels.push((id, fm.clone(), row.map(str::to_string)));
1387 }
1388 if node.hidden {
1389 hidden.push(id);
1390 }
1391 if node.style.opacity < 1.0 {
1392 opacities.push((id, node.style.opacity.max(0.0)));
1393 }
1394 if let Some(tf) = node.style.transform {
1395 transforms.push((id, tf));
1396 }
1397 if node.style.overflow == Overflow::Scroll {
1398 scrolls.push(id);
1399 }
1400 if let Some(path) = &node.state_path {
1401 states.push((id, path.clone()));
1402 }
1403 if node.access.role.is_meaningful() {
1404 access.push((id, node.access.clone(), node.model.clone()));
1405 }
1406 id
1407}
1408
1409#[allow(clippy::too_many_arguments)]
1410fn collect(
1411 tree: &TaffyTree<TextContent>,
1412 id: NodeId,
1413 origin_x: f32,
1414 origin_y: f32,
1415 paint: &[(NodeId, PaintKind)],
1416 handlers: &[(NodeId, String, Cursor, Option<String>)],
1417 models: &[Bound],
1418 focus_labels: &[(NodeId, String, Option<String>)],
1419 hidden: &[NodeId],
1420 opacities: &[(NodeId, f32)],
1421 scrolls: &[NodeId],
1422 transforms: &[(NodeId, Transform)],
1423 states: &[(NodeId, Vec<usize>)],
1424 access: &[(NodeId, Access, Option<String>)],
1425 offsets: &[Offset],
1426 vp: (f32, f32),
1427 // The nearest scroller above this node, so a focus ring can be clipped to
1428 // the box that clips everything else in it.
1429 inside_scroll: Option<usize>,
1430 out: &mut Layout,
1431) {
1432 let layout = tree.layout(id).expect("layout");
1433 let x = origin_x + layout.location.x;
1434 let y = origin_y + layout.location.y;
1435
1436 // r-show=false: the node kept its layout slot but paints nothing (nor its
1437 // subtree, nor its hit regions).
1438 if hidden.contains(&id) {
1439 return;
1440 }
1441
1442 // opacity fades this node and everything under it, so the layer opens
1443 // before the node paints its own background.
1444 let alpha = opacities
1445 .iter()
1446 .find(|(nid, _)| *nid == id)
1447 .map(|(_, a)| *a)
1448 .unwrap_or(1.0);
1449 if alpha < 1.0 {
1450 out.paints.push(Paint::PushOpacity {
1451 alpha,
1452 width: vp.0,
1453 height: vp.1,
1454 });
1455 }
1456
1457 // `transform` wraps the box and its subtree. The parsed matrix is in local
1458 // coords; bake in the origin (CSS default: the box centre) so it applies to
1459 // absolute coordinates directly.
1460 let transform = transforms.iter().find(|(nid, _)| *nid == id).map(|(_, m)| *m);
1461 if let Some(m) = transform {
1462 let (ox, oy) = (x + layout.size.width / 2.0, y + layout.size.height / 2.0);
1463 out.paints.push(Paint::PushTransform(centre_transform(m, ox, oy)));
1464 }
1465
1466 let mut clip = false;
1467 let mut clip_radius = [0.0; 4];
1468 // A node can emit more than one paint (a text node paints its box, then its
1469 // glyphs), so walk every entry it owns, in order.
1470 for (_, kind) in paint.iter().filter(|(nid, _)| *nid == id) {
1471 match kind {
1472 PaintKind::Box {
1473 bg,
1474 radius,
1475 border_width,
1476 border_color,
1477 clip: c,
1478 shadow,
1479 } => {
1480 clip = *c;
1481 clip_radius = *radius;
1482 // The shadow goes down first, so the box's own fill sits on top.
1483 // Outer shadows only for now; inset is parsed but not drawn.
1484 if let Some(sh) = shadow.filter(|s| !s.inset) {
1485 out.paints.push(Paint::Shadow {
1486 x: x + sh.dx - sh.spread,
1487 y: y + sh.dy - sh.spread,
1488 width: layout.size.width + 2.0 * sh.spread,
1489 height: layout.size.height + 2.0 * sh.spread,
1490 // vello's blurred rect takes one radius; use the largest
1491 // corner as a stand-in (per-corner blur isn't supported).
1492 radius: radius.iter().copied().fold(0.0, f32::max),
1493 blur: sh.blur,
1494 color: sh.color,
1495 });
1496 }
1497 let has_border = *border_width > 0.0 && border_color.is_some();
1498 if bg.is_some() || has_border {
1499 out.paints.push(Paint::Rect(PaintRect {
1500 x,
1501 y,
1502 width: layout.size.width,
1503 height: layout.size.height,
1504 background: bg.clone(),
1505 radius: *radius,
1506 border_width: *border_width,
1507 border_color: *border_color,
1508 }));
1509 }
1510 }
1511 // Glyphs go in the *content* box, inside this node's own padding and
1512 // border. Painting them at the border box put a padded label flush
1513 // against the edge of its own background: the box grew, the words
1514 // did not move. The size matters as much as the origin, since it is
1515 // what the run is aligned and wrapped within.
1516 PaintKind::Text(tc) => {
1517 let (cx, cy, cw, ch) = content_box(layout);
1518 out.paints.push(Paint::Text(PaintText {
1519 x: x + cx,
1520 y: y + cy,
1521 width: cw,
1522 height: ch,
1523 content: tc.clone(),
1524 }))
1525 }
1526 PaintKind::Tick(color) => out.paints.push(Paint::Tick(PaintTick {
1527 x,
1528 y,
1529 width: layout.size.width,
1530 height: layout.size.height,
1531 color: *color,
1532 })),
1533 PaintKind::Image(ic) => out.paints.push(Paint::Image(PaintImage {
1534 x,
1535 y,
1536 width: layout.size.width,
1537 height: layout.size.height,
1538 content: ic.clone(),
1539 })),
1540 }
1541 }
1542
1543 // A `for=` label targeting a text input: a focus region at the label's box,
1544 // carrying the *target's* model, so tapping the label focuses that input.
1545 if let Some((_, model, row)) = focus_labels.iter().find(|(nid, ..)| *nid == id) {
1546 out.focuses.push(FocusRegion {
1547 x,
1548 y,
1549 width: layout.size.width,
1550 height: layout.size.height,
1551 model: model.clone(),
1552 row: row.clone(),
1553 text: None,
1554 multiline: false,
1555 scroll_id: None,
1556 });
1557 }
1558
1559 // Assistive technology needs the same geometry the pointer uses, so this rides
1560 // the same walk. `hidden` nodes returned above, so an `r-show="false"` element
1561 // is absent from the a11y tree too, not merely invisible.
1562 if let Some((_, node_access, model)) = access.iter().find(|(nid, ..)| *nid == id) {
1563 out.access.push(AccessNode {
1564 x,
1565 y,
1566 width: layout.size.width,
1567 height: layout.size.height,
1568 access: node_access.clone(),
1569 model: model.clone(),
1570 });
1571 }
1572
1573 // Emitted for any box a `:hover`/`:active` rule could style, tappable or not,
1574 // unlike `cursor`, pointer-state styling is not limited to `@tap` boxes.
1575 if let Some((_, path)) = states.iter().find(|(nid, _)| *nid == id) {
1576 out.states.push(StateRegion {
1577 x,
1578 y,
1579 width: layout.size.width,
1580 height: layout.size.height,
1581 path: path.clone(),
1582 });
1583 }
1584
1585 if let Some((_, handler, cursor, instance)) = handlers.iter().find(|(nid, ..)| *nid == id) {
1586 out.hits.push(HitRegion {
1587 x,
1588 y,
1589 width: layout.size.width,
1590 height: layout.size.height,
1591 on_tap: handler.clone(),
1592 cursor: *cursor,
1593 instance: instance.clone(),
1594 });
1595 }
1596
1597 let (fw, fh) = (layout.size.width, layout.size.height);
1598 if let Some(bound) = models.iter().find(|b| b.id == id) {
1599 if let Some(options) = &bound.options {
1600 // A select: no caret, just a tappable box that opens a dropdown.
1601 out.selects.push(SelectRegion {
1602 x,
1603 y,
1604 width: fw,
1605 height: fh,
1606 model: bound.model.clone(),
1607 row: bound.row.clone(),
1608 options: options.clone(),
1609 });
1610 out.focusables.push(FocusItem {
1611 x,
1612 y,
1613 width: fw,
1614 height: fh,
1615 kind: FocusKind::Select {
1616 model: bound.model.clone(),
1617 row: bound.row.clone(),
1618 options: options.clone(),
1619 },
1620 scroll: inside_scroll,
1621 });
1622 } else {
1623 // A text/textarea input: its value is rendered by its single text
1624 // child; find that child's box so a tap resolves to a caret index.
1625 let text = tree
1626 .children(id)
1627 .ok()
1628 .and_then(|kids| kids.first().copied())
1629 .and_then(|kid| {
1630 let child = tree.layout(kid).ok()?;
1631 let content = paint.iter().find_map(|(nid, k)| match k {
1632 PaintKind::Text(tc) if *nid == kid => Some(tc.clone()),
1633 _ => None,
1634 })?;
1635 // The same content box the glyphs are painted in, or the
1636 // caret would sit at the border box while the text it is
1637 // supposed to be inside sits within the padding.
1638 let (cx, cy, cw, ch) = content_box(child);
1639 Some(PaintText {
1640 x: x + child.location.x + cx,
1641 y: y + child.location.y + cy,
1642 width: cw,
1643 height: ch,
1644 content,
1645 })
1646 });
1647 out.focuses.push(FocusRegion {
1648 x,
1649 y,
1650 width: fw,
1651 height: fh,
1652 model: bound.model.clone(),
1653 row: bound.row.clone(),
1654 text: text.clone(),
1655 multiline: bound.multiline,
1656 // The scroll block below assigns ids as `out.scrolls.len()`, so if
1657 // this node scrolls it will get the current length as its id.
1658 scroll_id: scrolls.contains(&id).then(|| out.scrolls.len()),
1659 });
1660 out.focusables.push(FocusItem {
1661 x,
1662 y,
1663 width: fw,
1664 height: fh,
1665 kind: FocusKind::Text {
1666 model: bound.model.clone(),
1667 row: bound.row.clone(),
1668 multiline: bound.multiline,
1669 text,
1670 },
1671 scroll: inside_scroll,
1672 });
1673 }
1674 } else if let Some((_, handler, _, instance)) = handlers.iter().find(|(nid, ..)| *nid == id) {
1675 // A button / checkbox / radio (anything with a `@tap` handler) is
1676 // keyboard-reachable: Space or Enter runs the same handler as a tap.
1677 out.focusables.push(FocusItem {
1678 x,
1679 y,
1680 width: fw,
1681 height: fh,
1682 kind: FocusKind::Activate { on_tap: handler.clone(), instance: instance.clone() },
1683 scroll: inside_scroll,
1684 });
1685 }
1686
1687 // overflow: clip/scroll, bound the subtree to this box (following its corners).
1688 if clip {
1689 out.paints.push(Paint::PushClip {
1690 x,
1691 y,
1692 width: layout.size.width,
1693 height: layout.size.height,
1694 radius: clip_radius,
1695 });
1696 }
1697
1698 // A scroller shifts its children by the current offset and registers itself
1699 // so the wheel, the scrollbars and the keyboard can find it.
1700 let mut shift = Offset::default();
1701 // What the children are clipped by: this box if it scrolls, otherwise
1702 // whatever was clipping us.
1703 let mut child_scroll = inside_scroll;
1704 if scrolls.contains(&id) {
1705 let sid = out.scrolls.len();
1706 child_scroll = Some(sid);
1707 let max = Offset {
1708 x: (layout.content_size.width - layout.size.width).max(0.0),
1709 y: (layout.content_size.height - layout.size.height).max(0.0),
1710 };
1711 shift = offsets.get(sid).copied().unwrap_or_default().clamp_to(max);
1712 out.scrolls.push(ScrollRegion {
1713 id: sid,
1714 x,
1715 y,
1716 width: layout.size.width,
1717 height: layout.size.height,
1718 content_width: layout.content_size.width,
1719 content_height: layout.content_size.height,
1720 max,
1721 });
1722 }
1723
1724 for child in tree.children(id).expect("children") {
1725 collect(
1726 tree,
1727 child,
1728 x - shift.x,
1729 y - shift.y,
1730 paint,
1731 handlers,
1732 models,
1733 focus_labels,
1734 hidden,
1735 opacities,
1736 scrolls,
1737 transforms,
1738 states,
1739 access,
1740 offsets,
1741 vp,
1742 child_scroll,
1743 out,
1744 );
1745 }
1746 if clip {
1747 out.paints.push(Paint::PopClip);
1748 }
1749 if transform.is_some() {
1750 out.paints.push(Paint::PopTransform);
1751 }
1752 if alpha < 1.0 {
1753 out.paints.push(Paint::PopOpacity);
1754 }
1755}
1756
1757/// Bake a transform-origin at `(ox, oy)` into a local transform matrix `m`, so
1758/// the result maps absolute coordinates: `p ↦ M·(p − o) + o`.
1759fn centre_transform(m: Transform, ox: f32, oy: f32) -> Transform {
1760 let [a, b, c, d, e, f] = m;
1761 [
1762 a,
1763 b,
1764 c,
1765 d,
1766 e + ox - a * ox - c * oy,
1767 f + oy - b * ox - d * oy,
1768 ]
1769}
1770
1771/// Lay out `root` into an `avail_w` x `avail_h` viewport, returning paint items
1772/// and hit regions. Text leaves are sized via `measure`.
1773pub fn layout(root: &Node, avail_w: f32, avail_h: f32, measure: &mut Measure) -> Layout {
1774 layout_scrolled(root, avail_w, avail_h, &[], measure)
1775}
1776
1777/// Lay out with the shell's current scroll offsets (one per scrollable box, in
1778/// tree order). A missing entry is 0.
1779pub fn layout_scrolled(
1780 root: &Node,
1781 avail_w: f32,
1782 avail_h: f32,
1783 offsets: &[Offset],
1784 measure: &mut Measure,
1785) -> Layout {
1786 let mut tree: TaffyTree<TextContent> = TaffyTree::new();
1787 // Taffy rounds boxes to whole pixels by default, which can shave a fraction
1788 // off a text box and make paint re-wrap the last word into a line the box
1789 // has no height for. Keep the exact sizes measure asked for.
1790 tree.disable_rounding();
1791 let mut paint = Vec::new();
1792 let mut handlers = Vec::new();
1793 let mut models = Vec::new();
1794 let mut focus_labels = Vec::new();
1795 let mut hidden = Vec::new();
1796 let mut opacities = Vec::new();
1797 let mut scrolls = Vec::new();
1798 let mut transforms = Vec::new();
1799 let mut states = Vec::new();
1800 let mut access = Vec::new();
1801 let vp = (avail_w, avail_h);
1802 let mut caps: HashMap<NodeId, f32> = HashMap::new();
1803 let root_id = build(
1804 &mut tree,
1805 root,
1806 &mut paint,
1807 &mut handlers,
1808 &mut models,
1809 &mut focus_labels,
1810 &mut hidden,
1811 &mut opacities,
1812 &mut scrolls,
1813 &mut transforms,
1814 &mut states,
1815 &mut access,
1816 vp,
1817 // The root is forced to the viewport below, so that is the widest
1818 // anything can be.
1819 Some(avail_w),
1820 &mut caps,
1821 None, // the root is not inside any row
1822 );
1823
1824 // Force the root to fill the viewport so a `screen` always covers the window.
1825 let mut root_style = to_taffy(&root.style, vp);
1826 root_style.size = Size {
1827 width: length(avail_w),
1828 height: length(avail_h),
1829 };
1830 tree.set_style(root_id, root_style).expect("set root style");
1831
1832 tree.compute_layout_with_measure(
1833 root_id,
1834 Size {
1835 width: AvailableSpace::Definite(avail_w),
1836 height: AvailableSpace::Definite(avail_h),
1837 },
1838 |known, available, id, ctx, _style| {
1839 if let (Some(w), Some(h)) = (known.width, known.height) {
1840 return Size { width: w, height: h };
1841 }
1842 match ctx {
1843 Some(tc) => {
1844 // Wrap to a definite width; otherwise (content sizing) let
1845 // the text take its natural single-line width.
1846 let max = known.width.or(match available.width {
1847 AvailableSpace::Definite(w) => Some(w),
1848 // Min-content is the narrowest the box can be without
1849 // its content spilling, which for text is the longest
1850 // unbreakable word. Wrapping at zero asks exactly that.
1851 // Answering it with the single-line width (which is
1852 // what "no constraint" means here) told taffy the box
1853 // could never be narrower than one long line.
1854 AvailableSpace::MinContent => Some(0.0),
1855 AvailableSpace::MaxContent => None,
1856 });
1857 // Never measure at a width this box can never have. Taffy
1858 // sizes a capped box from its *un*capped content, clamps
1859 // the width afterwards, and does not revisit the height, so
1860 // a `max-width` card was measured as one long line and
1861 // drawn as three. Wrapping at the cap up front is what
1862 // makes the measured height the height that gets drawn.
1863 let cap = caps.get(&id).copied();
1864 let max = match (max, cap) {
1865 (Some(m), Some(c)) => Some(m.min(c)),
1866 (None, Some(c)) => Some(c),
1867 (m, None) => m,
1868 };
1869 let (w, h) = measure(tc, max);
1870 Size {
1871 width: known.width.unwrap_or(w),
1872 height: known.height.unwrap_or(h),
1873 }
1874 }
1875 None => Size {
1876 width: 0.0,
1877 height: 0.0,
1878 },
1879 }
1880 },
1881 )
1882 .expect("compute layout");
1883
1884 let mut out = Layout::default();
1885 collect(
1886 &tree, root_id, 0.0, 0.0, &paint, &handlers, &models, &focus_labels, &hidden, &opacities,
1887 &scrolls, &transforms, &states, &access, offsets, vp, None, &mut out,
1888 );
1889 out
1890}