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