Skip to main content

aetna_core/
layout.rs

1//! Flex-style layout pass over the [`El`] tree.
2//!
3//! Sizing per axis:
4//! - `Fixed(px)` — exact size on its axis.
5//! - `Hug` — intrinsic size (text width, sum of children, etc.). Default.
6//! - `Fill(weight)` — share leftover main-axis space proportionally.
7//!
8//! Defaults match CSS flex's `flex: 0 1 auto`: children content-size
9//! on the main axis, defer to the parent's [`Align`] on the cross
10//! axis. `Align::Stretch` (the column / scroll default) stretches both
11//! `Hug` and `Fill` children to the container's full cross extent —
12//! the analog of CSS `align-items: stretch`. `Align::Center | Start |
13//! End` shrinks them to intrinsic so the alignment can actually
14//! position them — matching CSS's behavior when align-items is
15//! non-stretch. Main-axis distribution is governed by [`Justify`] (or
16//! insert a [`spacer`]).
17//!
18//! The layout pass also assigns each node a stable path-based
19//! [`El::computed_id`]: `root.0.card[account].2.button` — a node's ID is
20//! parent-id + dot + role-or-key + sibling-index. IDs survive minor
21//! refactors and are usable as patch / lint / draw-op targets.
22//!
23//! Rects do not live on `El` — the layout pass writes them to
24//! `UiState`'s computed-rect side map, keyed by `computed_id`. The
25//! container rect flows down the recursion as a parameter; child rects
26//! are computed per-axis and inserted into the side map. Scroll offsets
27//! likewise read/write `UiState`'s scroll-offset side map directly.
28//!
29//! Text intrinsic measurement uses bundled-font glyph advances via
30//! [`crate::text::metrics`]. Full shaping still belongs to the renderer
31//! for now; this keeps layout/lint/SVG close enough to glyphon output
32//! without committing to the final text stack.
33
34use std::sync::Arc;
35
36use crate::scroll::{ScrollAlignment, ScrollRequest};
37use crate::state::UiState;
38use crate::text::metrics as text_metrics;
39use crate::tree::*;
40
41/// Second escape hatch: author-supplied layout function.
42///
43/// When set on a node via [`El::layout`], the layout pass calls this
44/// function instead of running the column/row/overlay distribution for
45/// that node's direct children. The function returns one [`Rect`] per
46/// child (in source order), positioned anywhere inside the container.
47/// The library still recurses into each child (so descendants lay out
48/// normally) and still drives hit-test, focus, animation, scroll —
49/// those all read from `UiState`'s computed-rect side map, which receives the
50/// rects this function produces.
51///
52/// Authors typically write a free `fn(LayoutCtx) -> Vec<Rect>` and
53/// pass it directly: `column(children).layout(my_layout)`.
54///
55/// ## What you get
56///
57/// - [`LayoutCtx::container`] — the rect available for placement
58///   (parent rect minus this node's padding).
59/// - [`LayoutCtx::children`] — read-only slice of the node's children;
60///   index here matches the index in your returned `Vec<Rect>`.
61/// - [`LayoutCtx::measure`] — call to get a child's intrinsic
62///   `(width, height)` if you need it for sizing decisions.
63///
64/// ## Scope limits (will panic)
65///
66/// - The custom-layout node itself must size with [`Size::Fixed`] or
67///   [`Size::Fill`] on both axes. `Size::Hug` would require a separate
68///   intrinsic callback and is not yet supported.
69/// - The returned `Vec<Rect>` length must equal `children.len()`.
70#[derive(Clone)]
71pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
72
73impl LayoutFn {
74    pub fn new<F>(f: F) -> Self
75    where
76        F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
77    {
78        LayoutFn(Arc::new(f))
79    }
80}
81
82impl std::fmt::Debug for LayoutFn {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str("LayoutFn(<fn>)")
85    }
86}
87
88/// Virtualized list state attached to a [`Kind::VirtualList`] node.
89/// Holds the row count, the row-height policy, and the closure that
90/// realizes a row by global index. Set via [`crate::virtual_list`] or
91/// [`crate::virtual_list_dyn`]; the layout pass calls `build_row(i)`
92/// only for indices whose rect intersects the viewport.
93///
94/// ## Row-height policies
95///
96/// - [`VirtualMode::Fixed`] — every row is the same logical-pixel
97///   height. Scroll → visible-range is O(1).
98/// - [`VirtualMode::Dynamic`] — rows vary in height. The library uses
99///   `estimated_row_height` as a placeholder for unmeasured rows and
100///   measures (via the intrinsic pass) each row that becomes visible,
101///   caching the result on `UiState`. After enough scrolling the cache
102///   is fully warm; before then, the scroll position may shift slightly
103///   as estimates resolve to actual heights.
104///
105/// ## Other current scope
106///
107/// - **Vertical only** — feed/chat-log-shaped lists are the target.
108///   A horizontal variant can come later.
109/// - **No row pooling** — visible rows are rebuilt from scratch each
110///   layout pass. Fine for thousands of items; if it bottlenecks we
111///   add a pool keyed by stable row keys.
112#[derive(Clone, Debug)]
113pub enum VirtualMode {
114    /// Every row is exactly `row_height` logical pixels tall.
115    Fixed { row_height: f32 },
116    /// Rows have variable heights. `estimated_row_height` seeds the
117    /// content-height total and the visible-range walk for rows that
118    /// haven't been measured yet.
119    Dynamic { estimated_row_height: f32 },
120}
121
122#[derive(Clone)]
123#[non_exhaustive]
124pub struct VirtualItems {
125    pub count: usize,
126    pub mode: VirtualMode,
127    pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
128}
129
130impl VirtualItems {
131    pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
132    where
133        F: Fn(usize) -> El + Send + Sync + 'static,
134    {
135        assert!(
136            row_height > 0.0,
137            "VirtualItems::new requires row_height > 0.0 (got {row_height})"
138        );
139        VirtualItems {
140            count,
141            mode: VirtualMode::Fixed { row_height },
142            build_row: Arc::new(build_row),
143        }
144    }
145
146    pub fn new_dyn<F>(count: usize, estimated_row_height: f32, build_row: F) -> Self
147    where
148        F: Fn(usize) -> El + Send + Sync + 'static,
149    {
150        assert!(
151            estimated_row_height > 0.0,
152            "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
153        );
154        VirtualItems {
155            count,
156            mode: VirtualMode::Dynamic {
157                estimated_row_height,
158            },
159            build_row: Arc::new(build_row),
160        }
161    }
162}
163
164impl std::fmt::Debug for VirtualItems {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("VirtualItems")
167            .field("count", &self.count)
168            .field("mode", &self.mode)
169            .field("build_row", &"<fn>")
170            .finish()
171    }
172}
173
174/// Context handed to a [`LayoutFn`]. Marked `#[non_exhaustive]` so
175/// future fields (intrinsic-at-width, scroll context, …) can be added
176/// without breaking author code that currently reads `container` /
177/// `children` / `measure`.
178#[non_exhaustive]
179pub struct LayoutCtx<'a> {
180    /// Inner rect of the parent (after padding) — the area available
181    /// for child placement. Children may be positioned anywhere; the
182    /// library does not clamp returned rects to this region.
183    pub container: Rect,
184    /// Direct children of the node, in source order. Read-only — return
185    /// positions through your `Vec<Rect>`.
186    pub children: &'a [El],
187    /// Intrinsic `(width, height)` for any child. Wrapped text returns
188    /// its unwrapped width here; if you need width-dependent wrapping
189    /// you'll need to size the child with `Fixed` / `Fill` instead.
190    pub measure: &'a dyn Fn(&El) -> (f32, f32),
191    /// Look up any keyed node's laid-out rect. Returns `None` when the
192    /// key is absent from the tree, when the node hasn't been laid out
193    /// yet (siblings later in source order), or when the key was used
194    /// on a node without a recorded rect. Used by widgets like
195    /// [`crate::widgets::popover::popover`] to position children
196    /// relative to elements outside their own subtree.
197    pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
198    /// Look up a node's laid-out rect by its `computed_id`. Same
199    /// semantics as [`Self::rect_of_key`] but skips the `key →
200    /// computed_id` translation — useful for runtime-synthesized
201    /// layers (tooltips, focus rings) that anchor to a node the
202    /// library already knows by id.
203    pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
204}
205
206/// Lay out the whole tree into the given viewport rect. Assigns
207/// `computed_id`s, rebuilds the key index, and runs the layout walk.
208///
209/// Hosts that drive their own pipeline (the Aetna runtime does this in
210/// [`crate::runtime::RunnerCore::prepare_layout`]) typically call
211/// [`assign_ids`] before synthesizing floating layers (tooltips,
212/// toasts), then route the laid-out call through
213/// [`layout_post_assign`] so the id walk doesn't run twice per frame.
214pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
215    {
216        crate::profile_span!("layout::assign_ids");
217        assign_id(root, "root");
218    }
219    layout_post_assign(root, ui_state, viewport);
220}
221
222/// Like [`layout`], but skips the recursive `assign_id` walk. Callers
223/// are responsible for ensuring every node's `computed_id` is already
224/// set — typically by invoking [`assign_ids`] earlier in the pipeline,
225/// then having any per-frame floating-layer synthesis pass call
226/// [`assign_id_appended`] on its newly pushed layer.
227pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
228    {
229        crate::profile_span!("layout::root_setup");
230        ui_state
231            .layout
232            .computed_rects
233            .insert(root.computed_id.clone(), viewport);
234        rebuild_key_index(root, ui_state);
235        // Per-scrollable scratch is rebuilt every layout — entries for
236        // scrollables that disappeared mid-frame must not leave stale
237        // thumb rects behind for hit-test or paint to find.
238        ui_state.scroll.metrics.clear();
239        ui_state.scroll.thumb_rects.clear();
240        ui_state.scroll.thumb_tracks.clear();
241    }
242    crate::profile_span!("layout::children");
243    layout_children(root, viewport, ui_state);
244}
245
246/// Assign `computed_id`s to a child that was just appended to an
247/// already-id-assigned `parent`. Companion to [`layout_post_assign`]:
248/// floating-layer synthesis (tooltip, toast) pushes one new child onto
249/// the root and uses this to give the new subtree the same path-style
250/// ids the recursive `assign_id` would have, without re-walking the
251/// rest of the tree.
252pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
253    let role = role_token(&child.kind);
254    let suffix = match (&child.key, role) {
255        (Some(k), r) => format!("{r}[{k}]"),
256        (None, r) => format!("{r}.{child_index}"),
257    };
258    assign_id(child, &format!("{parent_id}.{suffix}"));
259}
260
261/// Walk the tree once and refresh `ui_state.layout.key_index` so
262/// `LayoutCtx::rect_of_key` can resolve `key → computed_id` without
263/// re-scanning the tree per lookup. First key wins — duplicate keys
264/// are an author bug, but we don't want to crash layout over it.
265fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
266    ui_state.layout.key_index.clear();
267    fn visit(node: &El, index: &mut rustc_hash::FxHashMap<String, String>) {
268        if let Some(key) = &node.key {
269            index
270                .entry(key.clone())
271                .or_insert_with(|| node.computed_id.clone());
272        }
273        for c in &node.children {
274            visit(c, index);
275        }
276    }
277    visit(root, &mut ui_state.layout.key_index);
278}
279
280/// Assign every node's `computed_id` without positioning anything else.
281/// Useful when callers need to read or seed side-map entries (e.g.,
282/// scroll offsets) before `layout` runs.
283pub fn assign_ids(root: &mut El) {
284    assign_id(root, "root");
285}
286
287fn assign_id(node: &mut El, path: &str) {
288    node.computed_id = path.to_string();
289    for (i, c) in node.children.iter_mut().enumerate() {
290        let role = role_token(&c.kind);
291        let suffix = match (&c.key, role) {
292            (Some(k), r) => format!("{r}[{k}]"),
293            (None, r) => format!("{r}.{i}"),
294        };
295        let child_path = format!("{path}.{suffix}");
296        assign_id(c, &child_path);
297    }
298}
299
300fn role_token(k: &Kind) -> &'static str {
301    match k {
302        Kind::Group => "group",
303        Kind::Card => "card",
304        Kind::Button => "button",
305        Kind::Badge => "badge",
306        Kind::Text => "text",
307        Kind::Heading => "heading",
308        Kind::Spacer => "spacer",
309        Kind::Divider => "divider",
310        Kind::Overlay => "overlay",
311        Kind::Scrim => "scrim",
312        Kind::Modal => "modal",
313        Kind::Scroll => "scroll",
314        Kind::VirtualList => "virtual_list",
315        Kind::Inlines => "inlines",
316        Kind::HardBreak => "hard_break",
317        Kind::Image => "image",
318        Kind::Surface => "surface",
319        Kind::Vector => "vector",
320        Kind::Custom(name) => name,
321    }
322}
323
324fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
325    if matches!(node.kind, Kind::Inlines) {
326        // The paragraph paints as a single AttributedText DrawOp;
327        // child Text/HardBreak nodes are aggregated by draw_ops::
328        // push_node and don't paint independently. Give each child a
329        // zero-size rect so the rest of the engine (hit-test, focus,
330        // animation, lint) treats them as non-paint pseudo-nodes. The
331        // paragraph's hit-test target is the Inlines node itself,
332        // sized by node_rect.
333        for c in &mut node.children {
334            ui_state.layout.computed_rects.insert(
335                c.computed_id.clone(),
336                Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
337            );
338            // Recurse so descendants of Text/HardBreak nodes (rare —
339            // these are leaves in practice — but keeping the invariant
340            // simple) still get their rects assigned.
341            layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
342        }
343        return;
344    }
345    if let Some(items) = node.virtual_items.clone() {
346        layout_virtual(node, node_rect, items, ui_state);
347        return;
348    }
349    if let Some(layout_fn) = node.layout_override.clone() {
350        layout_custom(node, node_rect, layout_fn, ui_state);
351        if node.scrollable {
352            apply_scroll_offset(node, node_rect, ui_state);
353        }
354        return;
355    }
356    match node.axis {
357        Axis::Overlay => {
358            let inner = node_rect.inset(node.padding);
359            for c in &mut node.children {
360                let c_rect = overlay_rect(c, inner, node.align, node.justify);
361                ui_state
362                    .layout
363                    .computed_rects
364                    .insert(c.computed_id.clone(), c_rect);
365                layout_children(c, c_rect, ui_state);
366            }
367        }
368        Axis::Column => layout_axis(node, node_rect, true, ui_state),
369        Axis::Row => layout_axis(node, node_rect, false, ui_state),
370    }
371    if node.scrollable {
372        apply_scroll_offset(node, node_rect, ui_state);
373    }
374}
375
376fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
377    let inner = node_rect.inset(node.padding);
378    let measure = |c: &El| intrinsic(c);
379    // Split-borrow `ui_state` so the `rect_of_key` closure reads the
380    // key index + computed rects while the surrounding function still
381    // holds the mutable borrow needed to insert this node's children
382    // back into `computed_rects` afterwards.
383    let key_index = &ui_state.layout.key_index;
384    let computed_rects = &ui_state.layout.computed_rects;
385    let rect_of_key = |key: &str| -> Option<Rect> {
386        let id = key_index.get(key)?;
387        computed_rects.get(id).copied()
388    };
389    let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
390    let rects = (layout_fn.0)(LayoutCtx {
391        container: inner,
392        children: &node.children,
393        measure: &measure,
394        rect_of_key: &rect_of_key,
395        rect_of_id: &rect_of_id,
396    });
397    assert_eq!(
398        rects.len(),
399        node.children.len(),
400        "LayoutFn for {:?} returned {} rects for {} children",
401        node.computed_id,
402        rects.len(),
403        node.children.len(),
404    );
405    for (c, c_rect) in node.children.iter_mut().zip(rects) {
406        ui_state
407            .layout
408            .computed_rects
409            .insert(c.computed_id.clone(), c_rect);
410        layout_children(c, c_rect, ui_state);
411    }
412}
413
414/// Virtualized list realization. Dispatches by [`VirtualMode`] —
415/// `Fixed` uses an O(1) division to find the visible range; `Dynamic`
416/// walks measured-or-estimated heights, measures each visible row's
417/// natural intrinsic height, and writes the result back to the height
418/// cache on `UiState` so subsequent frames have it available.
419fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
420    let inner = node_rect.inset(node.padding);
421    match items.mode {
422        VirtualMode::Fixed { row_height } => layout_virtual_fixed(
423            node,
424            inner,
425            items.count,
426            row_height,
427            items.build_row,
428            ui_state,
429        ),
430        VirtualMode::Dynamic {
431            estimated_row_height,
432        } => layout_virtual_dynamic(
433            node,
434            inner,
435            items.count,
436            estimated_row_height,
437            items.build_row,
438            ui_state,
439        ),
440    }
441}
442
443/// Consume any pending [`ScrollRequest`]s targeting this list's `key`,
444/// resolving each into a target offset using the live viewport rect and
445/// the caller-supplied row-extent function. Writes the resolved offset
446/// directly into `scroll.offsets`; the immediately-following
447/// `write_virtual_scroll_state` call clamps it to `[0, max_offset]`.
448///
449/// Requests for other lists are left in the queue for sibling lists in
450/// the same layout pass. Anything still queued after layout completes is
451/// dropped by the runtime (see `prepare_layout`).
452fn resolve_scroll_requests<F>(
453    node: &El,
454    inner: Rect,
455    count: usize,
456    row_extent: F,
457    ui_state: &mut UiState,
458) where
459    F: Fn(usize) -> (f32, f32),
460{
461    if ui_state.scroll.pending_requests.is_empty() {
462        return;
463    }
464    let Some(key) = node.key.as_deref() else {
465        return;
466    };
467    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
468    let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
469        pending.into_iter().partition(|req| match req {
470            ScrollRequest::ToRow { list_key, .. } => list_key == key,
471            // EnsureVisible isn't a virtual-list-row request; let the
472            // non-virtual scroll resolver pick it up downstream.
473            ScrollRequest::EnsureVisible { .. } => false,
474        });
475    ui_state.scroll.pending_requests = remaining;
476
477    for req in matched {
478        let ScrollRequest::ToRow { row, align, .. } = req else {
479            continue;
480        };
481        if row >= count {
482            continue;
483        }
484        let (row_top, row_h) = row_extent(row);
485        let row_bottom = row_top + row_h;
486        let viewport_h = inner.h;
487        let current = ui_state
488            .scroll
489            .offsets
490            .get(&node.computed_id)
491            .copied()
492            .unwrap_or(0.0);
493        let new_offset = match align {
494            ScrollAlignment::Start => row_top,
495            ScrollAlignment::End => row_bottom - viewport_h,
496            ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
497            ScrollAlignment::Visible => {
498                if row_top < current {
499                    row_top
500                } else if row_bottom > current + viewport_h {
501                    row_bottom - viewport_h
502                } else {
503                    continue;
504                }
505            }
506        };
507        ui_state
508            .scroll
509            .offsets
510            .insert(node.computed_id.clone(), new_offset);
511    }
512}
513
514/// Clamp the stored scroll offset, write the metrics + thumb rect, and
515/// return the clamped offset. Shared scaffold for both virtual modes.
516fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
517    let max_offset = (total_h - inner.h).max(0.0);
518    let stored = ui_state
519        .scroll
520        .offsets
521        .get(&node.computed_id)
522        .copied()
523        .unwrap_or(0.0);
524    let stored = resolve_pin_end(node, stored, max_offset, ui_state);
525    let offset = stored.clamp(0.0, max_offset);
526    ui_state
527        .scroll
528        .offsets
529        .insert(node.computed_id.clone(), offset);
530    ui_state.scroll.metrics.insert(
531        node.computed_id.clone(),
532        crate::state::ScrollMetrics {
533            viewport_h: inner.h,
534            content_h: total_h,
535            max_offset,
536        },
537    );
538    write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
539    offset
540}
541
542/// Assign the realized row a path-style `computed_id` matching the
543/// regular tree's role/key/index convention so hit-test, focus, and
544/// state lookups remain stable across scrolls.
545fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
546    let role = role_token(&child.kind);
547    let suffix = match (&child.key, role) {
548        (Some(k), r) => format!("{r}[{k}]"),
549        (None, r) => format!("{r}.{global_i}"),
550    };
551    assign_id(child, &format!("{parent_id}.{suffix}"));
552}
553
554fn layout_virtual_fixed(
555    node: &mut El,
556    inner: Rect,
557    count: usize,
558    row_height: f32,
559    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
560    ui_state: &mut UiState,
561) {
562    let total_h = count as f32 * row_height;
563    resolve_scroll_requests(
564        node,
565        inner,
566        count,
567        |i| (i as f32 * row_height, row_height),
568        ui_state,
569    );
570    let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
571
572    if count == 0 {
573        node.children.clear();
574        return;
575    }
576
577    // Visible index range — `start` floors, `end` ceils, both clamped.
578    let start = (offset / row_height).floor() as usize;
579    let end = (((offset + inner.h) / row_height).ceil() as usize).min(count);
580
581    let mut realized: Vec<El> = (start..end).map(|i| (build_row)(i)).collect();
582    for (vis_i, child) in realized.iter_mut().enumerate() {
583        let global_i = start + vis_i;
584        assign_virtual_row_id(child, &node.computed_id, global_i);
585
586        let row_y = inner.y + global_i as f32 * row_height - offset;
587        let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
588        ui_state
589            .layout
590            .computed_rects
591            .insert(child.computed_id.clone(), c_rect);
592        layout_children(child, c_rect, ui_state);
593    }
594    node.children = realized;
595}
596
597/// Variable-height virtualization. Each row's height comes from the
598/// `UiState` measurement cache if the row has been seen before, else
599/// from `estimated_row_height`. Visible rows are measured via
600/// [`intrinsic_constrained`] at the viewport width; the measured value
601/// is what positions sibling rows on this frame *and* gets written to
602/// the cache for the next.
603///
604/// Trade-off: when a row is first seen, the estimate it replaced may
605/// have been wrong by ~tens of pixels. The cumulative offset of the
606/// rows above it is then slightly off, so the scroll position appears
607/// to jump as the user scrolls into never-seen regions. Once the cache
608/// is warm for a region, scrolling is stable.
609fn layout_virtual_dynamic(
610    node: &mut El,
611    inner: Rect,
612    count: usize,
613    estimated_row_height: f32,
614    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
615    ui_state: &mut UiState,
616) {
617    // Drop measurements past the new end if the data shrunk.
618    if let Some(map) = ui_state
619        .scroll
620        .measured_row_heights
621        .get_mut(&node.computed_id)
622    {
623        map.retain(|i, _| *i < count);
624        if map.is_empty() {
625            ui_state
626                .scroll
627                .measured_row_heights
628                .remove(&node.computed_id);
629        }
630    }
631
632    let (measured_sum, measured_count) = ui_state
633        .scroll
634        .measured_row_heights
635        .get(&node.computed_id)
636        .map(|m| (m.values().sum::<f32>(), m.len()))
637        .unwrap_or((0.0, 0));
638    let unmeasured = count.saturating_sub(measured_count);
639    let total_h = measured_sum + (unmeasured as f32) * estimated_row_height;
640
641    // Skip the cache snapshot entirely when nothing in the queue
642    // targets this list — a hot path on dynamic lists with warm
643    // caches (potentially thousands of entries) that would otherwise
644    // pay a per-frame HashMap clone for an operation that fires
645    // maybe once a minute.
646    let has_request = node.key.as_deref().is_some_and(|k| {
647        ui_state.scroll.pending_requests.iter().any(|r| match r {
648            ScrollRequest::ToRow { list_key, .. } => list_key == k,
649            ScrollRequest::EnsureVisible { .. } => false,
650        })
651    });
652    if has_request {
653        // Snapshot the cache so the closure can read it while
654        // `ui_state` stays mutably borrowed for the offsets write.
655        let measured = ui_state
656            .scroll
657            .measured_row_heights
658            .get(&node.computed_id)
659            .cloned();
660        resolve_scroll_requests(
661            node,
662            inner,
663            count,
664            |target| {
665                let row_h = |i: usize| -> f32 {
666                    measured
667                        .as_ref()
668                        .and_then(|m| m.get(&i).copied())
669                        .unwrap_or(estimated_row_height)
670                };
671                let mut top = 0.0_f32;
672                for i in 0..target {
673                    top += row_h(i);
674                }
675                (top, row_h(target))
676            },
677            ui_state,
678        );
679    }
680
681    let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
682
683    if count == 0 {
684        node.children.clear();
685        return;
686    }
687
688    // Find the first row whose bottom edge is past `offset` using a
689    // scoped immutable borrow; releasing it before the render loop
690    // keeps `ui_state` mutably available below.
691    let (start, start_y) = {
692        let measured = ui_state.scroll.measured_row_heights.get(&node.computed_id);
693        let row_h = |i: usize| -> f32 {
694            measured
695                .and_then(|m| m.get(&i).copied())
696                .unwrap_or(estimated_row_height)
697        };
698        let mut y = 0.0_f32;
699        let mut start = 0;
700        while start < count {
701            let h = row_h(start);
702            if y + h > offset {
703                break;
704            }
705            y += h;
706            start += 1;
707        }
708        (start, y)
709    };
710    let mut cursor_y = start_y;
711    let mut idx = start;
712
713    let mut realized: Vec<El> = Vec::new();
714    let mut new_measurements: Vec<(usize, f32)> = Vec::new();
715
716    while idx < count && cursor_y < offset + inner.h {
717        let mut child = (build_row)(idx);
718        assign_virtual_row_id(&mut child, &node.computed_id, idx);
719
720        // Mirror the column-child sizing rules from `layout_axis`:
721        // Fixed → literal, Hug → intrinsic, Fill → invalid here.
722        let actual_h = match child.height {
723            Size::Fixed(v) => v.max(0.0),
724            Size::Hug => intrinsic_constrained(&child, Some(inner.w)).1.max(0.0),
725            Size::Fill(_) => panic!(
726                "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
727                 Size::Fill would absorb the viewport's height and break virtualization",
728                node.computed_id,
729            ),
730        };
731        new_measurements.push((idx, actual_h));
732
733        let row_y = inner.y + cursor_y - offset;
734        let c_rect = Rect::new(inner.x, row_y, inner.w, actual_h);
735        ui_state
736            .layout
737            .computed_rects
738            .insert(child.computed_id.clone(), c_rect);
739        layout_children(&mut child, c_rect, ui_state);
740
741        realized.push(child);
742        cursor_y += actual_h;
743        idx += 1;
744    }
745
746    if !new_measurements.is_empty() {
747        let entry = ui_state
748            .scroll
749            .measured_row_heights
750            .entry(node.computed_id.clone())
751            .or_default();
752        for (i, h) in new_measurements {
753            entry.insert(i, h);
754        }
755    }
756
757    node.children = realized;
758}
759
760/// Scrollable post-pass: measure content height from the laid-out
761/// children's stored rects, clamp the scroll offset to the available
762/// range, and shift every descendant rect by `-offset`.
763///
764/// Children should size with `Hug` or `Fixed` on the main axis —
765/// `Fill` children would absorb the viewport's height and there would
766/// be nothing to scroll.
767fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
768    let inner = node_rect.inset(node.padding);
769    if node.children.is_empty() {
770        ui_state
771            .scroll
772            .offsets
773            .insert(node.computed_id.clone(), 0.0);
774        ui_state.scroll.metrics.insert(
775            node.computed_id.clone(),
776            crate::state::ScrollMetrics {
777                viewport_h: inner.h,
778                content_h: 0.0,
779                max_offset: 0.0,
780            },
781        );
782        return;
783    }
784    let content_bottom = node
785        .children
786        .iter()
787        .map(|c| ui_state.rect(&c.computed_id).bottom())
788        .fold(f32::NEG_INFINITY, f32::max);
789    let content_h = (content_bottom - inner.y).max(0.0);
790    let max_offset = (content_h - inner.h).max(0.0);
791
792    // Resolve any matching `ScrollRequest::EnsureVisible` against
793    // this scroll BEFORE reading the stored offset, so the request's
794    // chosen offset wins (and gets clamped below, just like
795    // wheel-driven offsets do). A request matches when the node
796    // keyed `container_key` is an ancestor of this scroll —
797    // `key_index` resolves the key to a computed_id and a
798    // prefix-match on `node.computed_id` tells us we're inside.
799    resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
800
801    let stored = ui_state
802        .scroll
803        .offsets
804        .get(&node.computed_id)
805        .copied()
806        .unwrap_or(0.0);
807    let stored = resolve_pin_end(node, stored, max_offset, ui_state);
808    let clamped = stored.clamp(0.0, max_offset);
809    if clamped > 0.0 {
810        for c in &node.children {
811            shift_subtree_y(c, -clamped, ui_state);
812        }
813    }
814    ui_state
815        .scroll
816        .offsets
817        .insert(node.computed_id.clone(), clamped);
818    ui_state.scroll.metrics.insert(
819        node.computed_id.clone(),
820        crate::state::ScrollMetrics {
821            viewport_h: inner.h,
822            content_h,
823            max_offset,
824        },
825    );
826
827    write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
828}
829
830/// Stored offset within this much of `max_offset` counts as "at the
831/// tail" for [`El::pin_end`]. Wheel deltas are integer pixels, so a
832/// half-pixel slack absorbs floating-point rounding without admitting
833/// any deliberate user scroll.
834const PIN_END_EPSILON: f32 = 0.5;
835
836/// Apply [`El::pin_end`] semantics to `stored`. Reads the previous
837/// frame's `max_offset` from `scroll.metrics` to decide whether the
838/// stored offset has moved off the tail since last frame (user wheel /
839/// drag / programmatic write), and updates `scroll.pin_active`
840/// accordingly. Returns the offset that should be clamped + written
841/// downstream — `max_offset` when the pin is engaged, the input
842/// `stored` otherwise.
843///
844/// First frame for an opted-in container starts pinned, so a freshly
845/// mounted `scroll([...]).pin_end()` paints with its tail visible.
846fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
847    if !node.pin_end {
848        ui_state.scroll.pin_active.remove(&node.computed_id);
849        ui_state.scroll.pin_prev_max.remove(&node.computed_id);
850        return stored;
851    }
852    let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
853    let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
854    let active = match prev_active {
855        None => true,
856        Some(prev) => {
857            let prev_max = prev_max.unwrap_or(0.0);
858            if prev && stored < prev_max - PIN_END_EPSILON {
859                // Wheel / drag / EnsureVisible moved the offset off
860                // the tail since last frame.
861                false
862            } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
863                // Returned to (or past) the previous tail (wheel-back,
864                // jump-to-latest EnsureVisible, programmatic
865                // set_scroll_offset). Compared against `prev_max`
866                // rather than the current `max_offset` so a wheel-back
867                // to the bottom re-engages even when content grew
868                // between frames.
869                true
870            } else {
871                prev
872            }
873        }
874    };
875    ui_state
876        .scroll
877        .pin_active
878        .insert(node.computed_id.clone(), active);
879    ui_state
880        .scroll
881        .pin_prev_max
882        .insert(node.computed_id.clone(), max_offset);
883    if active { max_offset } else { stored }
884}
885
886/// Walk pending `ScrollRequest::EnsureVisible` requests and pop any
887/// whose `container_key` resolves to an ancestor of `node`. For each
888/// match, write a stored offset that brings the request's content-
889/// space `y..y+h` range into the viewport using minimal-displacement
890/// semantics (top edge if above, bottom edge if below, leave alone if
891/// already inside). The clamp + shift downstream of this call ensures
892/// the resulting offset stays inside `[0, max_offset]`.
893///
894/// Matching is by computed-id prefix on the keyed ancestor — a
895/// scroll is "inside" the keyed widget when its id starts with the
896/// ancestor's id followed by `.`, the same rule used by
897/// [`crate::state::query::target_in_subtree`].
898fn resolve_ensure_visible_for_scroll(
899    node: &El,
900    inner: Rect,
901    content_h: f32,
902    ui_state: &mut UiState,
903) {
904    if ui_state.scroll.pending_requests.is_empty() {
905        return;
906    }
907    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
908    let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
909    for req in pending {
910        let ScrollRequest::EnsureVisible {
911            container_key,
912            y,
913            h,
914        } = &req
915        else {
916            remaining.push(req);
917            continue;
918        };
919        let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
920            // Container hasn't been laid out yet (or its key isn't
921            // in this tree). Keep the request for a future frame —
922            // dropped at end-of-frame like row requests for
923            // missing lists.
924            remaining.push(req);
925            continue;
926        };
927        // Match this scroll only if it sits inside the keyed widget.
928        // Same prefix rule as `target_in_subtree`.
929        let inside = node.computed_id == *ancestor_id
930            || node
931                .computed_id
932                .strip_prefix(ancestor_id.as_str())
933                .is_some_and(|rest| rest.starts_with('.'));
934        if !inside {
935            remaining.push(req);
936            continue;
937        }
938        let current = ui_state
939            .scroll
940            .offsets
941            .get(&node.computed_id)
942            .copied()
943            .unwrap_or(0.0);
944        let target_top = *y;
945        let target_bottom = *y + *h;
946        let viewport_h = inner.h;
947        // Minimal-displacement: if the range is fully visible, no
948        // change. If it's above the viewport top, scroll up to it.
949        // If it's below the viewport bottom, scroll just enough to
950        // expose the bottom edge — but never less than 0 or more
951        // than `content_h - viewport_h` (the clamp downstream will
952        // do that anyway).
953        let new_offset = if target_top < current {
954            target_top
955        } else if target_bottom > current + viewport_h {
956            target_bottom - viewport_h
957        } else {
958            // Already visible: don't override an in-progress
959            // manual scroll just because the caret happens to be
960            // mid-viewport. Skip this request without disturbing
961            // the offset.
962            continue;
963        };
964        // Clamp against the live content extent so we don't write
965        // a wildly-out-of-range offset when the request races a
966        // layout pass that hasn't yet measured all rows.
967        let max = (content_h - viewport_h).max(0.0);
968        let new_offset = new_offset.clamp(0.0, max);
969        ui_state
970            .scroll
971            .offsets
972            .insert(node.computed_id.clone(), new_offset);
973    }
974    ui_state.scroll.pending_requests = remaining;
975}
976
977/// Compute and store the scrollbar thumb + track rects for `node`
978/// when the author opted into a visible scrollbar AND content
979/// overflows. Both rects are anchored to the right edge of `inner`.
980/// The visible thumb is `SCROLLBAR_THUMB_WIDTH` wide and tracks the
981/// scroll offset; the track is `SCROLLBAR_HITBOX_WIDTH` wide and
982/// covers the full inner height so a press above/below the thumb
983/// can page-scroll.
984fn write_thumb_rect(
985    node: &El,
986    inner: Rect,
987    content_h: f32,
988    max_offset: f32,
989    offset: f32,
990    ui_state: &mut UiState,
991) {
992    if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
993        return;
994    }
995    let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
996    let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
997    let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
998    let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
999    let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1000    let track_remaining = (inner.h - thumb_h).max(0.0);
1001    let thumb_y = inner.y + track_remaining * (offset / max_offset);
1002    let thumb_x = inner.right() - thumb_w - track_inset;
1003    let track_x = inner.right() - track_w - track_inset;
1004    ui_state.scroll.thumb_rects.insert(
1005        node.computed_id.clone(),
1006        Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1007    );
1008    ui_state.scroll.thumb_tracks.insert(
1009        node.computed_id.clone(),
1010        Rect::new(track_x, inner.y, track_w, inner.h),
1011    );
1012}
1013
1014fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1015    if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1016        rect.y += dy;
1017    }
1018    for c in &node.children {
1019        shift_subtree_y(c, dy, ui_state);
1020    }
1021}
1022
1023fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1024    let inner = node_rect.inset(node.padding);
1025    let n = node.children.len();
1026    if n == 0 {
1027        return;
1028    }
1029
1030    let total_gap = node.gap * n.saturating_sub(1) as f32;
1031    let main_extent = if vertical { inner.h } else { inner.w };
1032    let cross_extent = if vertical { inner.w } else { inner.h };
1033
1034    let intrinsics: Vec<(f32, f32)> = {
1035        crate::profile_span!("layout::axis::intrinsics");
1036        node.children
1037            .iter()
1038            .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1039            .collect()
1040    };
1041
1042    let mut consumed = 0.0;
1043    let mut fill_weight_total = 0.0;
1044    for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1045        match main_size_of(c, *iw, *ih, vertical) {
1046            MainSize::Resolved(v) => consumed += v,
1047            MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1048        }
1049    }
1050    let remaining = (main_extent - consumed - total_gap).max(0.0);
1051
1052    // Free space after children + gaps. When there are Fill children they
1053    // claim it all, so justify is moot; otherwise this is what center/end
1054    // distribute around.
1055    let free_after_used = if fill_weight_total == 0.0 {
1056        remaining
1057    } else {
1058        0.0
1059    };
1060    let mut cursor = match node.justify {
1061        Justify::Start => 0.0,
1062        Justify::Center => free_after_used * 0.5,
1063        Justify::End => free_after_used,
1064        Justify::SpaceBetween => 0.0,
1065    };
1066    let between_extra =
1067        if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1068            remaining / (n - 1) as f32
1069        } else {
1070            0.0
1071        };
1072
1073    crate::profile_span!("layout::axis::place");
1074    for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1075        let main_size = match main_size_of(c, iw, ih, vertical) {
1076            MainSize::Resolved(v) => v,
1077            MainSize::Fill(w) => remaining * w.max(0.001) / fill_weight_total.max(0.001),
1078        };
1079
1080        let cross_intent = if vertical { c.width } else { c.height };
1081        let cross_intrinsic = if vertical { iw } else { ih };
1082        // CSS-flex parity for cross-axis sizing: `Size::Fixed` is an
1083        // explicit author override and always wins. Otherwise the
1084        // parent's `Align` decides — `Stretch` (the column default)
1085        // stretches non-fixed children to the container, `Center` /
1086        // `Start` / `End` shrink to intrinsic so the alignment can
1087        // actually position them. This collapses Hug and Fill on the
1088        // cross axis (both are "follow align-items"), the same way
1089        // CSS flex doesn't distinguish between them on the cross axis.
1090        let cross_size = match cross_intent {
1091            Size::Fixed(v) => v,
1092            Size::Hug | Size::Fill(_) => match node.align {
1093                Align::Stretch => cross_extent,
1094                Align::Start | Align::Center | Align::End => cross_intrinsic,
1095            },
1096        };
1097
1098        let cross_off = match node.align {
1099            Align::Start | Align::Stretch => 0.0,
1100            Align::Center => (cross_extent - cross_size) * 0.5,
1101            Align::End => cross_extent - cross_size,
1102        };
1103
1104        let c_rect = if vertical {
1105            Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1106        } else {
1107            Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1108        };
1109        ui_state
1110            .layout
1111            .computed_rects
1112            .insert(c.computed_id.clone(), c_rect);
1113        layout_children(c, c_rect, ui_state);
1114
1115        cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1116    }
1117}
1118
1119enum MainSize {
1120    Resolved(f32),
1121    Fill(f32),
1122}
1123
1124fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1125    let s = if vertical { c.height } else { c.width };
1126    let intr = if vertical { ih } else { iw };
1127    match s {
1128        Size::Fixed(v) => MainSize::Resolved(v),
1129        Size::Hug => MainSize::Resolved(intr),
1130        Size::Fill(w) => MainSize::Fill(w),
1131    }
1132}
1133
1134fn child_intrinsic(
1135    c: &El,
1136    vertical: bool,
1137    parent_cross_extent: f32,
1138    parent_align: Align,
1139) -> (f32, f32) {
1140    if !vertical {
1141        return intrinsic(c);
1142    }
1143    let available_width = match c.width {
1144        Size::Fixed(v) => Some(v),
1145        Size::Fill(_) => Some(parent_cross_extent),
1146        Size::Hug => match parent_align {
1147            Align::Stretch => Some(parent_cross_extent),
1148            Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1149        },
1150    };
1151    intrinsic_constrained(c, available_width)
1152}
1153
1154fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1155    // Wrap-text height depends on width, so constrain the intrinsic
1156    // measurement to the width the child will actually be laid out at
1157    // — same shape as `child_intrinsic` does for column/row children.
1158    // Without this, a Fixed-width modal with a wrappable paragraph
1159    // measures as a single-line block and the modal's Hug height ends
1160    // up shorter than the actual content needs, eating bottom padding.
1161    let constrained_width = match c.width {
1162        Size::Fixed(v) => Some(v),
1163        Size::Fill(_) | Size::Hug => Some(parent.w),
1164    };
1165    let (iw, ih) = intrinsic_constrained(c, constrained_width);
1166    let w = match c.width {
1167        Size::Fixed(v) => v,
1168        Size::Hug => iw.min(parent.w),
1169        Size::Fill(_) => parent.w,
1170    };
1171    let h = match c.height {
1172        Size::Fixed(v) => v,
1173        Size::Hug => ih.min(parent.h),
1174        Size::Fill(_) => parent.h,
1175    };
1176    let x = match align {
1177        Align::Start | Align::Stretch => parent.x,
1178        Align::Center => parent.x + (parent.w - w) * 0.5,
1179        Align::End => parent.right() - w,
1180    };
1181    let y = match justify {
1182        Justify::Start | Justify::SpaceBetween => parent.y,
1183        Justify::Center => parent.y + (parent.h - h) * 0.5,
1184        Justify::End => parent.bottom() - h,
1185    };
1186    Rect::new(x, y, w, h)
1187}
1188
1189/// Intrinsic (width, height) for hugging layouts.
1190pub fn intrinsic(c: &El) -> (f32, f32) {
1191    intrinsic_constrained(c, None)
1192}
1193
1194fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1195    if c.layout_override.is_some() {
1196        // Custom-layout nodes don't define an intrinsic. Authors must
1197        // size them with `Fixed` or `Fill` on both axes; the returned
1198        // (0.0, 0.0) is replaced by `apply_min` for `Fixed` and is
1199        // unread for `Fill` (parent's distribution decides).
1200        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1201            panic!(
1202                "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1203                 Size::Hug is not supported for custom layouts",
1204                c.computed_id,
1205            );
1206        }
1207        return apply_min(c, 0.0, 0.0);
1208    }
1209    if c.virtual_items.is_some() {
1210        // VirtualList sizes the whole viewport (the parent decides) and
1211        // realizes only on-screen rows. Hug-sizing it would mean
1212        // "shrink to fit all rows", defeating virtualization. Same
1213        // shape as the layout_override guard.
1214        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1215            panic!(
1216                "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1217                 Size::Hug would defeat virtualization",
1218                c.computed_id,
1219            );
1220        }
1221        return apply_min(c, 0.0, 0.0);
1222    }
1223    if matches!(c.kind, Kind::Inlines) {
1224        return inline_paragraph_intrinsic(c, available_width);
1225    }
1226    if matches!(c.kind, Kind::HardBreak) {
1227        // HardBreak is meaningful only inside Inlines (where draw_ops
1228        // encodes it as `\n` in the attributed text). Outside Inlines
1229        // it's a no-op layout-wise.
1230        return apply_min(c, 0.0, 0.0);
1231    }
1232    if c.icon.is_some() {
1233        return apply_min(
1234            c,
1235            c.font_size + c.padding.left + c.padding.right,
1236            c.font_size + c.padding.top + c.padding.bottom,
1237        );
1238    }
1239    if let Some(img) = &c.image {
1240        // Natural pixel size as a logical-pixel intrinsic. Authors who
1241        // want a different sized box set `.width()` / `.height()`;
1242        // the projection inside that box is decided by `image_fit`.
1243        let w = img.width() as f32 + c.padding.left + c.padding.right;
1244        let h = img.height() as f32 + c.padding.top + c.padding.bottom;
1245        return apply_min(c, w, h);
1246    }
1247    if let Some(text) = &c.text {
1248        let unwrapped = text_metrics::layout_text_with_family(
1249            text,
1250            c.font_size,
1251            c.font_family,
1252            c.font_weight,
1253            c.font_mono,
1254            TextWrap::NoWrap,
1255            None,
1256        );
1257        let content_available = match c.text_wrap {
1258            TextWrap::NoWrap => None,
1259            TextWrap::Wrap => available_width
1260                .or(match c.width {
1261                    Size::Fixed(v) => Some(v),
1262                    Size::Fill(_) | Size::Hug => None,
1263                })
1264                .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1265        };
1266        let display = display_text_for_measure(c, text, content_available);
1267        let layout = text_metrics::layout_text_with_line_height_and_family(
1268            &display,
1269            c.font_size,
1270            c.line_height,
1271            c.font_family,
1272            c.font_weight,
1273            c.font_mono,
1274            c.text_wrap,
1275            content_available,
1276        );
1277        let w = content_available
1278            .map(|available| unwrapped.width.min(available) + c.padding.left + c.padding.right)
1279            .unwrap_or(layout.width + c.padding.left + c.padding.right);
1280        let h = layout.height + c.padding.top + c.padding.bottom;
1281        return apply_min(c, w, h);
1282    }
1283    match c.axis {
1284        Axis::Overlay => {
1285            let mut w: f32 = 0.0;
1286            let mut h: f32 = 0.0;
1287            for ch in &c.children {
1288                let child_available =
1289                    available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1290                let (cw, chh) = intrinsic_constrained(ch, child_available);
1291                w = w.max(cw);
1292                h = h.max(chh);
1293            }
1294            apply_min(
1295                c,
1296                w + c.padding.left + c.padding.right,
1297                h + c.padding.top + c.padding.bottom,
1298            )
1299        }
1300        Axis::Column => {
1301            let mut w: f32 = 0.0;
1302            let mut h: f32 = c.padding.top + c.padding.bottom;
1303            let n = c.children.len();
1304            let child_available =
1305                available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1306            for (i, ch) in c.children.iter().enumerate() {
1307                let (cw, chh) = intrinsic_constrained(ch, child_available);
1308                w = w.max(cw);
1309                h += chh;
1310                if i + 1 < n {
1311                    h += c.gap;
1312                }
1313            }
1314            apply_min(c, w + c.padding.left + c.padding.right, h)
1315        }
1316        Axis::Row => {
1317            // Two-pass measurement so that wrappable Fill children see
1318            // the width they will actually be laid out at. Without
1319            // this, a `Size::Fill` paragraph inside a row falls through
1320            // `inline_paragraph_intrinsic`'s `available_width` fallback
1321            // with `None` and reports its unwrapped single-line height
1322            // — the row then under-reserves vertical space and the
1323            // wrapped text overflows downward into the next row. This
1324            // mirrors how `layout_axis` (the runtime pass) already
1325            // splits Resolved vs. Fill main-axis sizing.
1326            let n = c.children.len();
1327            let total_gap = c.gap * n.saturating_sub(1) as f32;
1328            let inner_available = available_width
1329                .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
1330
1331            // First pass: Fixed and Hug children measure unconstrained.
1332            // Fixed-width wrappable children self-resolve their wrap
1333            // width via `inline_paragraph_intrinsic`'s own Fixed
1334            // fallback; Hug children take their natural width. We only
1335            // need to feed an explicit available width to Fill.
1336            let mut consumed: f32 = 0.0;
1337            let mut fill_weight_total: f32 = 0.0;
1338            let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1339            for ch in &c.children {
1340                match ch.width {
1341                    Size::Fill(w) => {
1342                        fill_weight_total += w.max(0.001);
1343                        sizes.push(None);
1344                    }
1345                    _ => {
1346                        let (cw, chh) = intrinsic(ch);
1347                        consumed += cw;
1348                        sizes.push(Some((cw, chh)));
1349                    }
1350                }
1351            }
1352
1353            // Second pass: distribute the leftover among Fill children
1354            // by weight and remeasure each with its share. Without an
1355            // available_width hint (row inside a Hug ancestor with no
1356            // outer constraint) we fall back to unconstrained
1357            // measurement — same lossy shape as the prior code, but
1358            // limited to the case where there's genuinely no width to
1359            // distribute.
1360            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
1361            let mut w_total: f32 = c.padding.left + c.padding.right;
1362            let mut h_max: f32 = 0.0;
1363            for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
1364                let (cw, chh) = match slot {
1365                    Some(rc) => rc,
1366                    None => match (fill_remaining, fill_weight_total > 0.0) {
1367                        (Some(av), true) => {
1368                            let weight = match ch.width {
1369                                Size::Fill(w) => w.max(0.001),
1370                                _ => 1.0,
1371                            };
1372                            intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
1373                        }
1374                        _ => intrinsic(ch),
1375                    },
1376                };
1377                w_total += cw;
1378                if i + 1 < n {
1379                    w_total += c.gap;
1380                }
1381                h_max = h_max.max(chh);
1382            }
1383            apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
1384        }
1385    }
1386}
1387
1388pub(crate) fn text_layout(
1389    c: &El,
1390    available_width: Option<f32>,
1391) -> Option<text_metrics::TextLayout> {
1392    let text = c.text.as_ref()?;
1393    let content_available = match c.text_wrap {
1394        TextWrap::NoWrap => None,
1395        TextWrap::Wrap => available_width
1396            .or(match c.width {
1397                Size::Fixed(v) => Some(v),
1398                Size::Fill(_) | Size::Hug => None,
1399            })
1400            .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1401    };
1402    let display = display_text_for_measure(c, text, content_available);
1403    Some(text_metrics::layout_text_with_line_height_and_family(
1404        &display,
1405        c.font_size,
1406        c.line_height,
1407        c.font_family,
1408        c.font_weight,
1409        c.font_mono,
1410        c.text_wrap,
1411        content_available,
1412    ))
1413}
1414
1415fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
1416    if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
1417        (c.text_wrap, c.text_max_lines, available_width)
1418    {
1419        text_metrics::clamp_text_to_lines_with_family(
1420            text,
1421            c.font_size,
1422            c.font_family,
1423            c.font_weight,
1424            c.font_mono,
1425            width,
1426            max_lines,
1427        )
1428    } else {
1429        text.to_string()
1430    }
1431}
1432
1433fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
1434    if let Size::Fixed(v) = c.width {
1435        w = v;
1436    }
1437    if let Size::Fixed(v) = c.height {
1438        h = v;
1439    }
1440    (w, h)
1441}
1442
1443/// Approximate intrinsic measurement for `Kind::Inlines` paragraphs.
1444///
1445/// The paragraph paints through cosmic-text's rich-text shaping (which
1446/// resolves bold/italic/mono runs against fontdb), but layout needs a
1447/// width and height *before* we get to the renderer. We concatenate
1448/// the runs' text into one string and call `text_metrics::layout_text`
1449/// at the dominant font size — same approximation the lint pass uses
1450/// for single-style text. Bold/italic widths are slightly different
1451/// from regular; for body-text paragraphs that difference is well
1452/// under one wrap-line and we accept it. If a fixture wraps within
1453/// 1-2 characters of a boundary the rendered glyphs may straddle the
1454/// laid-out rect by a fraction of a glyph.
1455fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
1456    let concat = concat_inline_text(&node.children);
1457    let size = inline_paragraph_size(node);
1458    let line_height = inline_paragraph_line_height(node);
1459    let unwrapped = text_metrics::layout_text_with_line_height_and_family(
1460        &concat,
1461        size,
1462        line_height,
1463        node.font_family,
1464        FontWeight::Regular,
1465        false,
1466        TextWrap::NoWrap,
1467        None,
1468    );
1469    let content_available = match node.text_wrap {
1470        TextWrap::NoWrap => None,
1471        TextWrap::Wrap => available_width
1472            .or(match node.width {
1473                Size::Fixed(v) => Some(v),
1474                Size::Fill(_) | Size::Hug => None,
1475            })
1476            .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
1477    };
1478    let layout = text_metrics::layout_text_with_line_height_and_family(
1479        &concat,
1480        size,
1481        line_height,
1482        node.font_family,
1483        FontWeight::Regular,
1484        false,
1485        node.text_wrap,
1486        content_available,
1487    );
1488    let w = content_available
1489        .map(|av| unwrapped.width.min(av) + node.padding.left + node.padding.right)
1490        .unwrap_or(layout.width + node.padding.left + node.padding.right);
1491    let h = layout.height + node.padding.top + node.padding.bottom;
1492    apply_min(node, w, h)
1493}
1494
1495/// Walk an Inlines paragraph's children and produce the source-order
1496/// concatenation that draw_ops will hand to the atlas. `Kind::Text`
1497/// contributes its `text` field; `Kind::HardBreak` contributes a
1498/// newline; anything else contributes nothing (an unsupported child
1499/// kind inside Inlines is a programmer error elsewhere — measurement
1500/// silently ignores it).
1501fn concat_inline_text(children: &[El]) -> String {
1502    let mut s = String::new();
1503    for c in children {
1504        match c.kind {
1505            Kind::Text => {
1506                if let Some(t) = &c.text {
1507                    s.push_str(t);
1508                }
1509            }
1510            Kind::HardBreak => s.push('\n'),
1511            _ => {}
1512        }
1513    }
1514    s
1515}
1516
1517/// Pick the font size that drives the paragraph's measurement. We use
1518/// the maximum across text children rather than the parent's own
1519/// `font_size`, because builders set sizes on the leaf text nodes.
1520fn inline_paragraph_size(node: &El) -> f32 {
1521    let mut size: f32 = node.font_size;
1522    for c in &node.children {
1523        if matches!(c.kind, Kind::Text) {
1524            size = size.max(c.font_size);
1525        }
1526    }
1527    size
1528}
1529
1530fn inline_paragraph_line_height(node: &El) -> f32 {
1531    let mut line_height: f32 = node.line_height;
1532    let mut max_size: f32 = node.font_size;
1533    for c in &node.children {
1534        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
1535            max_size = c.font_size;
1536            line_height = c.line_height;
1537        }
1538    }
1539    line_height
1540}
1541
1542#[cfg(test)]
1543mod tests {
1544    use super::*;
1545    use crate::state::UiState;
1546
1547    /// CSS-flex parity: a `Size::Fill` child of a column with
1548    /// `align(Center)` should shrink to its intrinsic cross-axis size
1549    /// and be horizontally centered, matching `align-items: center`
1550    /// in CSS flex (which causes flex items to lose their stretch).
1551    #[test]
1552    fn align_center_shrinks_fill_child_to_intrinsic() {
1553        // Column with align(Center). Inner row has the default
1554        // El::new width = Fill(1.0); without Proposal B it would
1555        // claim the full 200px and align would be a no-op.
1556        let mut root = column([crate::row([crate::widgets::text::text("hi")
1557            .width(Size::Fixed(40.0))
1558            .height(Size::Fixed(20.0))])])
1559        .align(Align::Center)
1560        .width(Size::Fixed(200.0))
1561        .height(Size::Fixed(100.0));
1562        let mut state = UiState::new();
1563        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1564        let row_rect = state.rect(&root.children[0].computed_id);
1565        // Row's intrinsic width = 40 (single fixed child). 200 - 40 = 160
1566        // leftover; centered → row starts at x=80.
1567        assert!(
1568            (row_rect.x - 80.0).abs() < 0.5,
1569            "expected x≈80 (centered), got {}",
1570            row_rect.x
1571        );
1572        assert!(
1573            (row_rect.w - 40.0).abs() < 0.5,
1574            "expected w≈40 (shrunk to intrinsic), got {}",
1575            row_rect.w
1576        );
1577    }
1578
1579    /// `align(Stretch)` (the default) preserves Fill stretching: a
1580    /// Fill-width child still claims the full cross axis.
1581    #[test]
1582    fn align_stretch_preserves_fill_stretch() {
1583        let mut root = column([crate::row([crate::widgets::text::text("hi")
1584            .width(Size::Fixed(40.0))
1585            .height(Size::Fixed(20.0))])])
1586        .align(Align::Stretch)
1587        .width(Size::Fixed(200.0))
1588        .height(Size::Fixed(100.0));
1589        let mut state = UiState::new();
1590        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1591        let row_rect = state.rect(&root.children[0].computed_id);
1592        assert!(
1593            (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
1594            "expected stretched (x=0, w=200), got x={} w={}",
1595            row_rect.x,
1596            row_rect.w
1597        );
1598    }
1599
1600    /// When all children are Hug-sized, `Justify::Center` should split
1601    /// the leftover space symmetrically across the main axis.
1602    #[test]
1603    fn justify_center_centers_hug_children() {
1604        let mut root = column([crate::widgets::text::text("hi")
1605            .width(Size::Fixed(40.0))
1606            .height(Size::Fixed(20.0))])
1607        .justify(Justify::Center)
1608        .height(Size::Fill(1.0));
1609        let mut state = UiState::new();
1610        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
1611        let child_rect = state.rect(&root.children[0].computed_id);
1612        // Expected: 100 - 20 = 80 leftover; centered → starts at y=40.
1613        assert!(
1614            (child_rect.y - 40.0).abs() < 0.5,
1615            "expected y≈40, got {}",
1616            child_rect.y
1617        );
1618    }
1619
1620    #[test]
1621    fn justify_end_pushes_to_bottom() {
1622        let mut root = column([crate::widgets::text::text("hi")
1623            .width(Size::Fixed(40.0))
1624            .height(Size::Fixed(20.0))])
1625        .justify(Justify::End)
1626        .height(Size::Fill(1.0));
1627        let mut state = UiState::new();
1628        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
1629        let child_rect = state.rect(&root.children[0].computed_id);
1630        assert!(
1631            (child_rect.y - 80.0).abs() < 0.5,
1632            "expected y≈80, got {}",
1633            child_rect.y
1634        );
1635    }
1636
1637    /// CSS `justify-content: space-between`: when no main-axis Fill
1638    /// children claim the slack, the leftover space is distributed
1639    /// evenly *between* (not around) the children — outer edges flush.
1640    #[test]
1641    fn justify_space_between_distributes_evenly() {
1642        let row_child = || {
1643            crate::widgets::text::text("x")
1644                .width(Size::Fixed(20.0))
1645                .height(Size::Fixed(20.0))
1646        };
1647        let mut root = column([row_child(), row_child(), row_child()])
1648            .justify(Justify::SpaceBetween)
1649            .height(Size::Fixed(200.0));
1650        let mut state = UiState::new();
1651        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
1652        // Used main = 3 * 20 = 60. Leftover = 140 over (n-1) = 2 gaps
1653        // → 70 between. Positions: 0, 90, 180.
1654        let y0 = state.rect(&root.children[0].computed_id).y;
1655        let y1 = state.rect(&root.children[1].computed_id).y;
1656        let y2 = state.rect(&root.children[2].computed_id).y;
1657        assert!(
1658            y0.abs() < 0.5,
1659            "first child should be flush at y=0, got {y0}"
1660        );
1661        assert!(
1662            (y1 - 90.0).abs() < 0.5,
1663            "middle child should be at y≈90, got {y1}"
1664        );
1665        assert!(
1666            (y2 - 180.0).abs() < 0.5,
1667            "last child should be flush at y≈180, got {y2}"
1668        );
1669    }
1670
1671    /// CSS `flex: <weight>`: when multiple `Size::Fill` children share
1672    /// a container, the available space is distributed in proportion
1673    /// to their weights.
1674    #[test]
1675    fn fill_weight_distributes_proportionally() {
1676        let big = crate::widgets::text::text("big")
1677            .width(Size::Fixed(40.0))
1678            .height(Size::Fill(2.0));
1679        let small = crate::widgets::text::text("small")
1680            .width(Size::Fixed(40.0))
1681            .height(Size::Fill(1.0));
1682        let mut root = column([big, small]).height(Size::Fixed(300.0));
1683        let mut state = UiState::new();
1684        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
1685        // Total weight = 3, available = 300. Big = 200, small = 100.
1686        let big_h = state.rect(&root.children[0].computed_id).h;
1687        let small_h = state.rect(&root.children[1].computed_id).h;
1688        assert!(
1689            (big_h - 200.0).abs() < 0.5,
1690            "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
1691        );
1692        assert!(
1693            (small_h - 100.0).abs() < 0.5,
1694            "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
1695        );
1696    }
1697
1698    /// `padding` on a `Hug`-sized container is included in the
1699    /// container's intrinsic — matching CSS `box-sizing: content-box`
1700    /// where padding adds to the rendered size.
1701    #[test]
1702    fn padding_on_hug_includes_in_intrinsic() {
1703        let root = column([crate::widgets::text::text("x")
1704            .width(Size::Fixed(40.0))
1705            .height(Size::Fixed(40.0))])
1706        .padding(Sides::all(20.0));
1707        let (w, h) = intrinsic(&root);
1708        // 40 content + 2*20 padding on each axis = 80.
1709        assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
1710        assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
1711    }
1712
1713    /// Cross-axis `Align::End` on a row pins children to the bottom
1714    /// edge — CSS `align-items: flex-end`. Mirror of `justify_end`
1715    /// but on the cross axis instead of the main axis.
1716    #[test]
1717    fn align_end_pins_to_cross_axis_far_edge() {
1718        let mut root = crate::row([crate::widgets::text::text("hi")
1719            .width(Size::Fixed(40.0))
1720            .height(Size::Fixed(20.0))])
1721        .align(Align::End)
1722        .width(Size::Fixed(200.0))
1723        .height(Size::Fixed(100.0));
1724        let mut state = UiState::new();
1725        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1726        let child_rect = state.rect(&root.children[0].computed_id);
1727        // Row cross axis = height. End → child y = 100 - 20 = 80.
1728        assert!(
1729            (child_rect.y - 80.0).abs() < 0.5,
1730            "expected y≈80 (pinned to bottom), got {}",
1731            child_rect.y
1732        );
1733    }
1734
1735    #[test]
1736    fn overlay_can_center_hug_child() {
1737        let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
1738            .width(Size::Fixed(200.0))
1739            .height(Size::Hug)])
1740        .align(Align::Center)
1741        .justify(Justify::Center);
1742        let mut state = UiState::new();
1743        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
1744        let child_rect = state.rect(&root.children[0].computed_id);
1745        assert!(
1746            (child_rect.x - 200.0).abs() < 0.5,
1747            "expected x≈200, got {}",
1748            child_rect.x
1749        );
1750        assert!(
1751            child_rect.y > 100.0 && child_rect.y < 200.0,
1752            "expected centered y, got {}",
1753            child_rect.y
1754        );
1755    }
1756
1757    #[test]
1758    fn scroll_offset_translates_children_and_clamps_to_content() {
1759        // Six 50px-tall rows in a 200px-tall scroll viewport.
1760        // Content height = 6 * 50 + 5 * 12 (gap) = 360 px. Visible
1761        // viewport (no padding) = 200 px → max_offset = 160.
1762        let mut root = scroll(
1763            (0..6)
1764                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1765        )
1766        .key("list")
1767        .gap(12.0)
1768        .height(Size::Fixed(200.0));
1769        let mut state = UiState::new();
1770        assign_ids(&mut root);
1771        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
1772
1773        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1774
1775        // Offset is in range, applied verbatim.
1776        let stored = state
1777            .scroll
1778            .offsets
1779            .get(&root.computed_id)
1780            .copied()
1781            .unwrap_or(0.0);
1782        assert!(
1783            (stored - 80.0).abs() < 0.01,
1784            "offset clamped unexpectedly: {stored}"
1785        );
1786        // First child shifted up by 80.
1787        let c0 = state.rect(&root.children[0].computed_id);
1788        assert!(
1789            (c0.y - (-80.0)).abs() < 0.01,
1790            "child 0 y = {} (expected -80)",
1791            c0.y
1792        );
1793        // Now overshoot — should clamp to max_offset=160.
1794        state
1795            .scroll
1796            .offsets
1797            .insert(root.computed_id.clone(), 9999.0);
1798        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1799        let stored = state
1800            .scroll
1801            .offsets
1802            .get(&root.computed_id)
1803            .copied()
1804            .unwrap_or(0.0);
1805        assert!(
1806            (stored - 160.0).abs() < 0.01,
1807            "overshoot clamped to {stored}"
1808        );
1809        // Content fits → offset clamps to 0.
1810        let mut tiny =
1811            scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
1812                .height(Size::Fixed(200.0));
1813        let mut tiny_state = UiState::new();
1814        assign_ids(&mut tiny);
1815        tiny_state
1816            .scroll
1817            .offsets
1818            .insert(tiny.computed_id.clone(), 50.0);
1819        layout(
1820            &mut tiny,
1821            &mut tiny_state,
1822            Rect::new(0.0, 0.0, 300.0, 200.0),
1823        );
1824        assert_eq!(
1825            tiny_state
1826                .scroll
1827                .offsets
1828                .get(&tiny.computed_id)
1829                .copied()
1830                .unwrap_or(0.0),
1831            0.0
1832        );
1833    }
1834
1835    #[test]
1836    fn scrollbar_thumb_size_and_position_track_overflow() {
1837        // 6 rows x 50px + 5 gaps x 12 = 360 content; 200 viewport.
1838        // viewport/content = 200/360 ≈ 0.555 → thumb_h ≈ 111.1.
1839        let mut root = scroll(
1840            (0..6)
1841                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1842        )
1843        .gap(12.0)
1844        .height(Size::Fixed(200.0));
1845        let mut state = UiState::new();
1846        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1847
1848        let metrics = state
1849            .scroll
1850            .metrics
1851            .get(&root.computed_id)
1852            .copied()
1853            .expect("scrollable should have metrics");
1854        assert!((metrics.viewport_h - 200.0).abs() < 0.01);
1855        assert!((metrics.content_h - 360.0).abs() < 0.01);
1856        assert!((metrics.max_offset - 160.0).abs() < 0.01);
1857
1858        let thumb = state
1859            .scroll
1860            .thumb_rects
1861            .get(&root.computed_id)
1862            .copied()
1863            .expect("scrollable with scrollbar() and overflow gets a thumb");
1864        // viewport^2 / content_h = 200^2 / 360 = 111.11..
1865        assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
1866        assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
1867        // At offset 0, thumb sits at the top of the inner rect.
1868        assert!(thumb.y.abs() < 0.01);
1869        // Right-anchored: thumb_x + thumb_w + track_inset == viewport_right.
1870        assert!(
1871            (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
1872            "thumb anchored at {} (expected {})",
1873            thumb.x,
1874            300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
1875        );
1876
1877        // Slide to half — thumb should be at half the track_remaining.
1878        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
1879        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1880        let thumb = state
1881            .scroll
1882            .thumb_rects
1883            .get(&root.computed_id)
1884            .copied()
1885            .unwrap();
1886        let track_remaining = 200.0 - thumb.h;
1887        let expected_y = track_remaining * (80.0 / 160.0);
1888        assert!(
1889            (thumb.y - expected_y).abs() < 0.5,
1890            "thumb at half-scroll y = {} (expected {expected_y})",
1891            thumb.y,
1892        );
1893    }
1894
1895    #[test]
1896    fn scrollbar_track_is_wider_than_thumb_and_full_height() {
1897        // The track is the click hitbox: wider than the visible
1898        // thumb (Fitts's law) and tall enough to detect track
1899        // clicks above and below the thumb for paging.
1900        let mut root = scroll(
1901            (0..6)
1902                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1903        )
1904        .gap(12.0)
1905        .height(Size::Fixed(200.0));
1906        let mut state = UiState::new();
1907        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1908
1909        let thumb = state
1910            .scroll
1911            .thumb_rects
1912            .get(&root.computed_id)
1913            .copied()
1914            .unwrap();
1915        let track = state
1916            .scroll
1917            .thumb_tracks
1918            .get(&root.computed_id)
1919            .copied()
1920            .unwrap();
1921        // Track wider than thumb on the same right edge.
1922        assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
1923        assert!(
1924            (track.right() - thumb.right()).abs() < 0.01,
1925            "track and thumb must share the right edge",
1926        );
1927        // Track spans the full inner viewport (so above/below thumb
1928        // are both inside it for click-to-page).
1929        assert!(
1930            (track.h - 200.0).abs() < 0.01,
1931            "track height = {} (expected 200)",
1932            track.h,
1933        );
1934    }
1935
1936    #[test]
1937    fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
1938        // Same scrollable, but author opted out — no thumb_rect.
1939        let mut suppressed = scroll(
1940            (0..6)
1941                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1942        )
1943        .no_scrollbar()
1944        .height(Size::Fixed(200.0));
1945        let mut state = UiState::new();
1946        layout(
1947            &mut suppressed,
1948            &mut state,
1949            Rect::new(0.0, 0.0, 300.0, 200.0),
1950        );
1951        assert!(
1952            !state
1953                .scroll
1954                .thumb_rects
1955                .contains_key(&suppressed.computed_id)
1956        );
1957
1958        // Same scrollable, content fits → no thumb either.
1959        let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
1960            .height(Size::Fixed(200.0));
1961        let mut tiny_state = UiState::new();
1962        layout(
1963            &mut tiny,
1964            &mut tiny_state,
1965            Rect::new(0.0, 0.0, 300.0, 200.0),
1966        );
1967        assert!(
1968            !tiny_state
1969                .scroll
1970                .thumb_rects
1971                .contains_key(&tiny.computed_id)
1972        );
1973    }
1974
1975    #[test]
1976    fn layout_override_places_children_at_returned_rects() {
1977        // A custom layout that just stacks children diagonally inside the container.
1978        let mut root = column((0..3).map(|i| {
1979            crate::widgets::text::text(format!("dot {i}"))
1980                .width(Size::Fixed(20.0))
1981                .height(Size::Fixed(20.0))
1982        }))
1983        .width(Size::Fixed(200.0))
1984        .height(Size::Fixed(200.0))
1985        .layout(|ctx| {
1986            ctx.children
1987                .iter()
1988                .enumerate()
1989                .map(|(i, _)| {
1990                    let off = i as f32 * 30.0;
1991                    Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
1992                })
1993                .collect()
1994        });
1995        let mut state = UiState::new();
1996        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
1997        let r0 = state.rect(&root.children[0].computed_id);
1998        let r1 = state.rect(&root.children[1].computed_id);
1999        let r2 = state.rect(&root.children[2].computed_id);
2000        assert_eq!((r0.x, r0.y), (0.0, 0.0));
2001        assert_eq!((r1.x, r1.y), (30.0, 30.0));
2002        assert_eq!((r2.x, r2.y), (60.0, 60.0));
2003    }
2004
2005    #[test]
2006    fn layout_override_rect_of_key_resolves_earlier_sibling() {
2007        // The popover-anchor pattern: a custom-laid-out node positions
2008        // its child by reading another keyed node's rect via the new
2009        // LayoutCtx::rect_of_key callback. The trigger lives in an
2010        // earlier sibling so its rect is already in `computed_rects`
2011        // by the time the popover layer's layout_override runs.
2012        use crate::tree::stack;
2013        let trigger_x = 40.0;
2014        let trigger_y = 20.0;
2015        let trigger_w = 60.0;
2016        let trigger_h = 30.0;
2017        let mut root = stack([
2018            // Earlier sibling: the trigger.
2019            crate::widgets::button::button("Open")
2020                .key("trig")
2021                .width(Size::Fixed(trigger_w))
2022                .height(Size::Fixed(trigger_h)),
2023            // Later sibling: a custom-laid-out container that reads
2024            // the trigger's rect to position its single child.
2025            stack([crate::widgets::text::text("popover")
2026                .width(Size::Fixed(80.0))
2027                .height(Size::Fixed(20.0))])
2028            .width(Size::Fill(1.0))
2029            .height(Size::Fill(1.0))
2030            .layout(|ctx| {
2031                let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
2032                vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
2033            }),
2034        ])
2035        .padding(Sides::xy(trigger_x, trigger_y));
2036        let mut state = UiState::new();
2037        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2038
2039        let popover_layer = &root.children[1];
2040        let panel_id = &popover_layer.children[0].computed_id;
2041        let panel_rect = state.rect(panel_id);
2042        // Anchored to (trigger.x, trigger.bottom() + 4.0). With padding
2043        // (40, 20) and trigger height 30 → expect (40, 54).
2044        assert!(
2045            (panel_rect.x - trigger_x).abs() < 0.01,
2046            "popover x = {} (expected {trigger_x})",
2047            panel_rect.x,
2048        );
2049        assert!(
2050            (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
2051            "popover y = {} (expected {})",
2052            panel_rect.y,
2053            trigger_y + trigger_h + 4.0,
2054        );
2055    }
2056
2057    #[test]
2058    fn layout_override_rect_of_key_returns_none_for_missing_key() {
2059        let mut root = column([crate::widgets::text::text("inner")
2060            .width(Size::Fixed(40.0))
2061            .height(Size::Fixed(20.0))])
2062        .width(Size::Fixed(200.0))
2063        .height(Size::Fixed(200.0))
2064        .layout(|ctx| {
2065            assert!((ctx.rect_of_key)("nope").is_none());
2066            vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2067        });
2068        let mut state = UiState::new();
2069        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2070    }
2071
2072    #[test]
2073    fn layout_override_rect_of_key_returns_none_for_later_sibling() {
2074        // First-frame contract: a custom layout running before its
2075        // target's sibling has been laid out should see `None`, not a
2076        // zero rect or a panic. This is what makes the popover pattern
2077        // (trigger first, popover layer second in source order) the
2078        // supported shape — the reverse direction simply gets `None`.
2079        use crate::tree::stack;
2080        let mut root = stack([
2081            stack([crate::widgets::text::text("panel")
2082                .width(Size::Fixed(40.0))
2083                .height(Size::Fixed(20.0))])
2084            .width(Size::Fill(1.0))
2085            .height(Size::Fill(1.0))
2086            .layout(|ctx| {
2087                assert!(
2088                    (ctx.rect_of_key)("later").is_none(),
2089                    "later sibling's rect must not be available yet"
2090                );
2091                vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2092            }),
2093            crate::widgets::button::button("after").key("later"),
2094        ]);
2095        let mut state = UiState::new();
2096        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2097    }
2098
2099    #[test]
2100    fn layout_override_measure_returns_intrinsic() {
2101        // The custom layout reads `measure` to size each child.
2102        let mut root = column([crate::widgets::text::text("hi")
2103            .width(Size::Fixed(40.0))
2104            .height(Size::Fixed(20.0))])
2105        .width(Size::Fixed(200.0))
2106        .height(Size::Fixed(200.0))
2107        .layout(|ctx| {
2108            let (w, h) = (ctx.measure)(&ctx.children[0]);
2109            assert!((w - 40.0).abs() < 0.01, "measured width {w}");
2110            assert!((h - 20.0).abs() < 0.01, "measured height {h}");
2111            vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
2112        });
2113        let mut state = UiState::new();
2114        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2115        let r = state.rect(&root.children[0].computed_id);
2116        assert_eq!((r.w, r.h), (40.0, 20.0));
2117    }
2118
2119    #[test]
2120    #[should_panic(expected = "returned 1 rects for 2 children")]
2121    fn layout_override_length_mismatch_panics() {
2122        let mut root = column([
2123            crate::widgets::text::text("a")
2124                .width(Size::Fixed(10.0))
2125                .height(Size::Fixed(10.0)),
2126            crate::widgets::text::text("b")
2127                .width(Size::Fixed(10.0))
2128                .height(Size::Fixed(10.0)),
2129        ])
2130        .width(Size::Fixed(200.0))
2131        .height(Size::Fixed(200.0))
2132        .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
2133        let mut state = UiState::new();
2134        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2135    }
2136
2137    #[test]
2138    #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
2139    fn layout_override_hug_panics() {
2140        // Hug check fires when the parent's layout pass measures the
2141        // custom-layout child for sizing — i.e. when a layout_override
2142        // node is a child of a column/row, not when it's the root.
2143        let mut root = column([column([crate::widgets::text::text("c")])
2144            .width(Size::Hug)
2145            .height(Size::Fixed(200.0))
2146            .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
2147        .width(Size::Fixed(200.0))
2148        .height(Size::Fixed(200.0));
2149        let mut state = UiState::new();
2150        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2151    }
2152
2153    #[test]
2154    fn virtual_list_realizes_only_visible_rows() {
2155        // 100 rows × 50px each in a 200px viewport, offset = 120.
2156        // Visible range: rows whose y in [-50, 200) → start = floor(120/50) = 2,
2157        // end = ceil((120+200)/50) = ceil(6.4) = 7. Five rows realized.
2158        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
2159            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2160        });
2161        let mut state = UiState::new();
2162        assign_ids(&mut root);
2163        state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
2164        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2165
2166        assert_eq!(
2167            root.children.len(),
2168            5,
2169            "expected 5 realized rows, got {}",
2170            root.children.len()
2171        );
2172        // Identity check: the first realized row should be the row keyed "row-2".
2173        assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
2174        assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
2175        // Position check: first realized row's y = inner.y + 2*50 - 120 = -20.
2176        let r0 = state.rect(&root.children[0].computed_id);
2177        assert!(
2178            (r0.y - (-20.0)).abs() < 0.5,
2179            "row 2 expected y≈-20, got {}",
2180            r0.y
2181        );
2182    }
2183
2184    #[test]
2185    fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
2186        let make_root = || {
2187            crate::tree::virtual_list(50, 50.0, |i| {
2188                crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2189            })
2190        };
2191
2192        let mut state = UiState::new();
2193        let mut root_a = make_root();
2194        assign_ids(&mut root_a);
2195        // Scroll so row 5 is visible.
2196        state
2197            .scroll
2198            .offsets
2199            .insert(root_a.computed_id.clone(), 250.0);
2200        layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2201        let id_at_offset_a = root_a
2202            .children
2203            .iter()
2204            .find(|c| c.key.as_deref() == Some("row-5"))
2205            .unwrap()
2206            .computed_id
2207            .clone();
2208
2209        // Re-layout with a different offset — row 5 is still visible.
2210        let mut root_b = make_root();
2211        assign_ids(&mut root_b);
2212        state
2213            .scroll
2214            .offsets
2215            .insert(root_b.computed_id.clone(), 200.0);
2216        layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2217        let id_at_offset_b = root_b
2218            .children
2219            .iter()
2220            .find(|c| c.key.as_deref() == Some("row-5"))
2221            .unwrap()
2222            .computed_id
2223            .clone();
2224
2225        assert_eq!(
2226            id_at_offset_a, id_at_offset_b,
2227            "row-5's computed_id changed when scroll offset moved"
2228        );
2229    }
2230
2231    #[test]
2232    fn virtual_list_clamps_overshoot_offset() {
2233        // 10 rows × 50 = 500 content height; viewport 200; max offset = 300.
2234        let mut root =
2235            crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2236        let mut state = UiState::new();
2237        assign_ids(&mut root);
2238        state
2239            .scroll
2240            .offsets
2241            .insert(root.computed_id.clone(), 9999.0);
2242        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2243        let stored = state
2244            .scroll
2245            .offsets
2246            .get(&root.computed_id)
2247            .copied()
2248            .unwrap_or(0.0);
2249        assert!(
2250            (stored - 300.0).abs() < 0.01,
2251            "expected clamp to 300, got {stored}"
2252        );
2253    }
2254
2255    #[test]
2256    fn virtual_list_empty_count_realizes_no_children() {
2257        let mut root =
2258            crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2259        let mut state = UiState::new();
2260        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2261        assert_eq!(root.children.len(), 0);
2262    }
2263
2264    #[test]
2265    #[should_panic(expected = "row_height > 0.0")]
2266    fn virtual_list_zero_row_height_panics() {
2267        let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
2268    }
2269
2270    #[test]
2271    #[should_panic(expected = "Size::Hug would defeat virtualization")]
2272    fn virtual_list_hug_panics() {
2273        let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
2274            crate::widgets::text::text(format!("r{i}"))
2275        })
2276        .height(Size::Hug)])
2277        .width(Size::Fixed(300.0))
2278        .height(Size::Fixed(200.0));
2279        let mut state = UiState::new();
2280        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2281    }
2282
2283    #[test]
2284    fn virtual_list_dyn_respects_per_row_fixed_heights() {
2285        // Alternating 40px / 80px rows. With a 200px viewport and offset 0,
2286        // accumulated y goes 0, 40, 120, 160, 240 — the fifth row starts
2287        // past the viewport, so four rows are realized.
2288        let mut root = crate::tree::virtual_list_dyn(20, 50.0, |i| {
2289            let h = if i % 2 == 0 { 40.0 } else { 80.0 };
2290            crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2291                .key(format!("row-{i}"))
2292                .height(Size::Fixed(h))
2293        });
2294        let mut state = UiState::new();
2295        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2296
2297        assert_eq!(
2298            root.children.len(),
2299            4,
2300            "expected 4 realized rows, got {}",
2301            root.children.len()
2302        );
2303        // y positions: row 0 → 0, row 1 → 40, row 2 → 120, row 3 → 160.
2304        let ys: Vec<f32> = root
2305            .children
2306            .iter()
2307            .map(|c| state.rect(&c.computed_id).y)
2308            .collect();
2309        assert!(
2310            (ys[0] - 0.0).abs() < 0.5,
2311            "row 0 expected y≈0, got {}",
2312            ys[0]
2313        );
2314        assert!(
2315            (ys[1] - 40.0).abs() < 0.5,
2316            "row 1 expected y≈40, got {}",
2317            ys[1]
2318        );
2319        assert!(
2320            (ys[2] - 120.0).abs() < 0.5,
2321            "row 2 expected y≈120, got {}",
2322            ys[2]
2323        );
2324        assert!(
2325            (ys[3] - 160.0).abs() < 0.5,
2326            "row 3 expected y≈160, got {}",
2327            ys[3]
2328        );
2329    }
2330
2331    #[test]
2332    fn virtual_list_dyn_caches_measured_heights() {
2333        // Build a list where the first frame realizes rows 0..k, measuring
2334        // each. After layout the cache should hold those measurements and
2335        // the next frame should read them.
2336        let mut root = crate::tree::virtual_list_dyn(50, 50.0, |i| {
2337            crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2338                .key(format!("row-{i}"))
2339                .height(Size::Fixed(30.0))
2340        });
2341        let mut state = UiState::new();
2342        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2343
2344        let measured = state
2345            .scroll
2346            .measured_row_heights
2347            .get(&root.computed_id)
2348            .expect("dynamic virtual list should populate the height cache");
2349        // At least the realized rows (≈ ceil(200/30) = 7) should be cached.
2350        assert!(
2351            measured.len() >= 7,
2352            "expected ≥ 7 cached row heights, got {}",
2353            measured.len()
2354        );
2355        for (_, h) in measured.iter() {
2356            assert!(
2357                (h - 30.0).abs() < 0.5,
2358                "expected cached height ≈ 30, got {h}"
2359            );
2360        }
2361    }
2362
2363    #[test]
2364    fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
2365        // 20 rows of fixed 30px in a 200px viewport. First frame realizes
2366        // 7 rows (200/30 = 6.66, ceil = 7). Cache holds 7 × 30 = 210;
2367        // remaining 13 × estimate 50 = 650; content_h = 860; max_offset =
2368        // 660. A second frame with offset 9999 must clamp to that 660.
2369        let make_root = || {
2370            crate::tree::virtual_list_dyn(20, 50.0, |i| {
2371                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2372                    .key(format!("row-{i}"))
2373                    .height(Size::Fixed(30.0))
2374            })
2375        };
2376        let mut state = UiState::new();
2377        let mut root = make_root();
2378        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2379
2380        let measured_count = state
2381            .scroll
2382            .measured_row_heights
2383            .get(&root.computed_id)
2384            .map(|m| m.len())
2385            .unwrap_or(0);
2386        let expected_total = measured_count as f32 * 30.0 + (20 - measured_count) as f32 * 50.0;
2387        let expected_max_offset = expected_total - 200.0;
2388
2389        state
2390            .scroll
2391            .offsets
2392            .insert(root.computed_id.clone(), 9999.0);
2393        let mut root2 = make_root();
2394        layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2395        let stored = state
2396            .scroll
2397            .offsets
2398            .get(&root2.computed_id)
2399            .copied()
2400            .unwrap_or(0.0);
2401        assert!(
2402            (stored - expected_max_offset).abs() < 0.5,
2403            "expected offset clamped to {expected_max_offset}, got {stored}"
2404        );
2405    }
2406
2407    #[test]
2408    fn virtual_list_dyn_empty_count_realizes_no_children() {
2409        let mut root =
2410            crate::tree::virtual_list_dyn(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2411        let mut state = UiState::new();
2412        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2413        assert_eq!(root.children.len(), 0);
2414    }
2415
2416    #[test]
2417    #[should_panic(expected = "estimated_row_height > 0.0")]
2418    fn virtual_list_dyn_zero_estimate_panics() {
2419        let _ =
2420            crate::tree::virtual_list_dyn(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
2421    }
2422
2423    #[test]
2424    fn text_runs_constructor_shape_smoke() {
2425        let el = crate::tree::text_runs([
2426            crate::widgets::text::text("Hello, "),
2427            crate::widgets::text::text("world").bold(),
2428            crate::tree::hard_break(),
2429            crate::widgets::text::text("of text").italic(),
2430        ]);
2431        assert_eq!(el.kind, Kind::Inlines);
2432        assert_eq!(el.children.len(), 4);
2433        assert!(matches!(
2434            el.children[1].font_weight,
2435            FontWeight::Bold | FontWeight::Semibold
2436        ));
2437        assert_eq!(el.children[2].kind, Kind::HardBreak);
2438        assert!(el.children[3].text_italic);
2439    }
2440
2441    #[test]
2442    fn wrapped_text_hugs_multiline_height_from_available_width() {
2443        let mut root = column([crate::paragraph(
2444            "A longer sentence should wrap into multiple measured lines.",
2445        )])
2446        .width(Size::Fill(1.0))
2447        .height(Size::Hug);
2448
2449        let mut state = UiState::new();
2450        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
2451
2452        let child_rect = state.rect(&root.children[0].computed_id);
2453        assert_eq!(child_rect.w, 180.0);
2454        assert!(
2455            child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
2456            "expected multiline paragraph height, got {}",
2457            child_rect.h
2458        );
2459    }
2460
2461    #[test]
2462    fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
2463        // Regression: overlay_rect used to call `intrinsic(c)` with no
2464        // width hint, so a Fixed-width modal containing a wrappable
2465        // paragraph measured the paragraph as a single line — leaving
2466        // the modal's Hug height short by the wrapped lines and
2467        // crowding the buttons against the bottom edge of the panel
2468        // (rumble cert-pending modal showed this).
2469        //
2470        // The fix: pass the child's resolved width as the available
2471        // width for intrinsic measurement, mirroring what column/row
2472        // already do.
2473        const PANEL_W: f32 = 240.0;
2474        const PADDING: f32 = 18.0;
2475        const GAP: f32 = 12.0;
2476
2477        let panel = column([
2478            crate::paragraph(
2479                "A long enough warning paragraph that it has to wrap onto a second line \
2480                 inside this narrow panel.",
2481            ),
2482            crate::widgets::button::button("OK").key("ok"),
2483        ])
2484        .width(Size::Fixed(PANEL_W))
2485        .height(Size::Hug)
2486        .padding(Sides::all(PADDING))
2487        .gap(GAP)
2488        .align(Align::Stretch);
2489
2490        let mut root = crate::stack([panel])
2491            .width(Size::Fill(1.0))
2492            .height(Size::Fill(1.0));
2493        let mut state = UiState::new();
2494        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
2495
2496        let panel_rect = state.rect(&root.children[0].computed_id);
2497        assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
2498
2499        let para_rect = state.rect(&root.children[0].children[0].computed_id);
2500        let button_rect = state.rect(&root.children[0].children[1].computed_id);
2501
2502        // Paragraph wrapped to ≥ 2 lines (exact line count depends on
2503        // glyph metrics; just guard against the single-line bug).
2504        assert!(
2505            para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
2506            "paragraph should wrap to multiple lines inside the Fixed-width panel; \
2507             got h={}",
2508            para_rect.h
2509        );
2510
2511        // Panel height must accommodate top padding + paragraph +
2512        // gap + button + bottom padding. The bug was that the panel
2513        // came out exactly `padding + gap + 1-line-paragraph + button`
2514        // — short by the second wrap line — and the button overshot
2515        // the inner area, leaving zero pixels of bottom padding.
2516        let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
2517        assert!(
2518            (bottom_padding - PADDING).abs() < 0.5,
2519            "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
2520        );
2521    }
2522
2523    #[test]
2524    fn row_with_fill_paragraph_propagates_height_to_parent_column() {
2525        // Regression: the Row branch of `intrinsic_constrained` called
2526        // `intrinsic(ch)` unconstrained, so a wrappable Fill child
2527        // (paragraph) measured as a single unwrapped line. Two such rows
2528        // in a column then got one-line-tall allocations and the second
2529        // row's gutter rect overlapped the first row's wrapped text
2530        // (chat-port event-log recipe in aetna-core/README.md hit this).
2531        //
2532        // The fix mirrors `layout_axis`: the Row intrinsic distributes
2533        // its available width across Fill children before measuring,
2534        // so wrappable Fill children see the width they will actually
2535        // be laid out at.
2536        const COL_W: f32 = 600.0;
2537        const GUTTER_W: f32 = 3.0;
2538
2539        let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
2540                    sed do eiusmod tempor incididunt ut labore et dolore magna \
2541                    aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
2542                    ullamco laboris nisi ut aliquip ex ea commodo consequat.";
2543
2544        let make_row = || {
2545            let gutter = El::new(Kind::Custom("gutter"))
2546                .width(Size::Fixed(GUTTER_W))
2547                .height(Size::Fill(1.0));
2548            let body = crate::paragraph(long).width(Size::Fill(1.0));
2549            crate::row([gutter, body]).width(Size::Fill(1.0))
2550        };
2551
2552        let mut root = column([make_row(), make_row()])
2553            .width(Size::Fixed(COL_W))
2554            .height(Size::Hug)
2555            .align(Align::Stretch);
2556        let mut state = UiState::new();
2557        layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
2558
2559        let row0_rect = state.rect(&root.children[0].computed_id);
2560        let row1_rect = state.rect(&root.children[1].computed_id);
2561        let para0_rect = state.rect(&root.children[0].children[1].computed_id);
2562
2563        // Both the paragraph rect and the row rect must reflect the
2564        // wrapped (multi-line) height. The bug pinned them to a single
2565        // line (~`TEXT_SM.line_height` = 20px), so the wrapped text
2566        // painted outside the row's allocated rect.
2567        let line_height = crate::tokens::TEXT_SM.line_height;
2568        assert!(
2569            para0_rect.h > line_height * 1.5,
2570            "paragraph should wrap to multiple lines at ~597px wide; \
2571             got h={} (line_height={})",
2572            para0_rect.h,
2573            line_height,
2574        );
2575        assert!(
2576            row0_rect.h > line_height * 1.5,
2577            "row 0 should accommodate the wrapped paragraph height; \
2578             got h={} (line_height={})",
2579            row0_rect.h,
2580            line_height,
2581        );
2582
2583        // Sanity: row 1 sits below row 0's allocated rect, not above it.
2584        assert!(
2585            row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
2586            "row 1 starts at y={} but row 0 occupies y={}..{}",
2587            row1_rect.y,
2588            row0_rect.y,
2589            row0_rect.y + row0_rect.h,
2590        );
2591    }
2592}