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::cell::RefCell;
35use std::sync::Arc;
36
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::scroll::{ScrollAlignment, ScrollRequest};
40use crate::state::{UiState, VirtualAnchor};
41use crate::text::metrics as text_metrics;
42use crate::tree::*;
43
44/// Second escape hatch: author-supplied layout function.
45///
46/// When set on a node via [`El::layout`], the layout pass calls this
47/// function instead of running the column/row/overlay distribution for
48/// that node's direct children. The function returns one [`Rect`] per
49/// child (in source order), positioned anywhere inside the container.
50/// The library still recurses into each child (so descendants lay out
51/// normally) and still drives hit-test, focus, animation, scroll —
52/// those all read from `UiState`'s computed-rect side map, which receives the
53/// rects this function produces.
54///
55/// Authors typically write a free `fn(LayoutCtx) -> Vec<Rect>` and
56/// pass it directly: `column(children).layout(my_layout)`.
57///
58/// ## What you get
59///
60/// - [`LayoutCtx::container`] — the rect available for placement
61///   (parent rect minus this node's padding).
62/// - [`LayoutCtx::children`] — read-only slice of the node's children;
63///   index here matches the index in your returned `Vec<Rect>`.
64/// - [`LayoutCtx::measure`] — call to get a child's intrinsic
65///   `(width, height)` if you need it for sizing decisions.
66///
67/// ## Scope limits (will panic)
68///
69/// - The custom-layout node itself must size with [`Size::Fixed`] or
70///   [`Size::Fill`] on both axes. `Size::Hug` would require a separate
71///   intrinsic callback and is not yet supported.
72/// - The returned `Vec<Rect>` length must equal `children.len()`.
73#[derive(Clone)]
74pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
75
76impl LayoutFn {
77    pub fn new<F>(f: F) -> Self
78    where
79        F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
80    {
81        LayoutFn(Arc::new(f))
82    }
83}
84
85impl std::fmt::Debug for LayoutFn {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str("LayoutFn(<fn>)")
88    }
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct LayoutIntrinsicCacheStats {
93    pub hits: u64,
94    pub misses: u64,
95}
96
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub struct LayoutPruneStats {
99    pub subtrees: u64,
100    pub nodes: u64,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Hash)]
104struct IntrinsicCacheKey {
105    computed_id: String,
106    available_width_bits: Option<u32>,
107}
108
109#[derive(Default)]
110struct IntrinsicCache {
111    measurements: FxHashMap<IntrinsicCacheKey, (f32, f32)>,
112    stats: LayoutIntrinsicCacheStats,
113    prune: LayoutPruneStats,
114}
115
116thread_local! {
117    static INTRINSIC_CACHE: RefCell<Option<IntrinsicCache>> = const { RefCell::new(None) };
118    static LAST_INTRINSIC_CACHE_STATS: RefCell<LayoutIntrinsicCacheStats> =
119        const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
120    static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
121        const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
122}
123
124struct IntrinsicCacheGuard {
125    previous: Option<IntrinsicCache>,
126}
127
128impl Drop for IntrinsicCacheGuard {
129    fn drop(&mut self) {
130        INTRINSIC_CACHE.with(|cell| {
131            cell.replace(self.previous.take());
132        });
133    }
134}
135
136fn with_intrinsic_cache(f: impl FnOnce()) {
137    let previous = INTRINSIC_CACHE.with(|cell| cell.replace(Some(IntrinsicCache::default())));
138    let mut guard = IntrinsicCacheGuard { previous };
139    f();
140    let finished = INTRINSIC_CACHE.with(|cell| cell.replace(guard.previous.take()));
141    if let Some(cache) = finished {
142        LAST_INTRINSIC_CACHE_STATS.with(|stats| {
143            *stats.borrow_mut() = cache.stats;
144        });
145        LAST_PRUNE_STATS.with(|stats| {
146            *stats.borrow_mut() = cache.prune;
147        });
148    }
149    std::mem::forget(guard);
150}
151
152pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
153    LAST_INTRINSIC_CACHE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
154}
155
156pub fn take_prune_stats() -> LayoutPruneStats {
157    LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
158}
159
160/// Virtualized list state attached to a [`Kind::VirtualList`] node.
161/// Holds the row count, the row-height policy, and the closure that
162/// realizes a row by global index. Set via [`crate::virtual_list`] or
163/// [`crate::virtual_list_dyn`]; the layout pass calls `build_row(i)`
164/// only for indices whose rect intersects the viewport.
165///
166/// ## Row-height policies
167///
168/// - [`VirtualMode::Fixed`] — every row is the same logical-pixel
169///   height. Scroll → visible-range is O(1).
170/// - [`VirtualMode::Dynamic`] — rows vary in height. The library uses
171///   `estimated_row_height` as a placeholder for unmeasured rows,
172///   measures visible rows at the current layout width, and preserves a
173///   row anchor on screen while estimates become measurements.
174///
175/// ## Other current scope
176///
177/// - **Vertical only** — feed/chat-log-shaped lists are the target.
178///   A horizontal variant can come later.
179/// - **No row pooling** — visible rows are rebuilt from scratch each
180///   layout pass. Fine for thousands of items; if it bottlenecks we
181///   add a pool keyed by stable row keys.
182#[derive(Clone, Debug)]
183pub enum VirtualMode {
184    /// Every row is exactly `row_height` logical pixels tall.
185    Fixed { row_height: f32 },
186    /// Rows have variable heights. `estimated_row_height` seeds the
187    /// content-height total and the visible-range walk for rows that
188    /// haven't been measured yet.
189    Dynamic { estimated_row_height: f32 },
190}
191
192/// Policy used to pick the next dynamic virtual-list anchor after each
193/// layout pass. The previous anchor solves the current frame; this
194/// policy rebases the next frame onto a coherent in-viewport row point.
195#[derive(Clone, Copy, Debug, PartialEq)]
196pub enum VirtualAnchorPolicy {
197    /// Pick the row point nearest `y_fraction` through the viewport.
198    /// `0.0` is the top, `1.0` is the bottom. Good default for feeds.
199    ViewportFraction { y_fraction: f32 },
200    /// Prefer the first fully visible row; fall back to the first
201    /// partially visible row.
202    FirstVisible,
203    /// Prefer the last fully visible row; fall back to the last
204    /// partially visible row.
205    LastVisible,
206}
207
208impl Default for VirtualAnchorPolicy {
209    fn default() -> Self {
210        Self::ViewportFraction { y_fraction: 0.25 }
211    }
212}
213
214#[derive(Clone)]
215#[non_exhaustive]
216pub struct VirtualItems {
217    pub count: usize,
218    pub mode: VirtualMode,
219    pub anchor_policy: VirtualAnchorPolicy,
220    pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
221    pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
222}
223
224impl VirtualItems {
225    pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
226    where
227        F: Fn(usize) -> El + Send + Sync + 'static,
228    {
229        assert!(
230            row_height > 0.0,
231            "VirtualItems::new requires row_height > 0.0 (got {row_height})"
232        );
233        VirtualItems {
234            count,
235            mode: VirtualMode::Fixed { row_height },
236            anchor_policy: VirtualAnchorPolicy::default(),
237            row_key: Arc::new(|i| i.to_string()),
238            build_row: Arc::new(build_row),
239        }
240    }
241
242    pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
243    where
244        K: Fn(usize) -> String + Send + Sync + 'static,
245        F: Fn(usize) -> El + Send + Sync + 'static,
246    {
247        assert!(
248            estimated_row_height > 0.0,
249            "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
250        );
251        VirtualItems {
252            count,
253            mode: VirtualMode::Dynamic {
254                estimated_row_height,
255            },
256            anchor_policy: VirtualAnchorPolicy::default(),
257            row_key: Arc::new(row_key),
258            build_row: Arc::new(build_row),
259        }
260    }
261
262    pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
263        self.anchor_policy = policy;
264        self
265    }
266}
267
268impl std::fmt::Debug for VirtualItems {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("VirtualItems")
271            .field("count", &self.count)
272            .field("mode", &self.mode)
273            .field("anchor_policy", &self.anchor_policy)
274            .field("row_key", &"<fn>")
275            .field("build_row", &"<fn>")
276            .finish()
277    }
278}
279
280/// Context handed to a [`LayoutFn`]. Marked `#[non_exhaustive]` so
281/// future fields (intrinsic-at-width, scroll context, …) can be added
282/// without breaking author code that currently reads `container` /
283/// `children` / `measure`.
284#[non_exhaustive]
285pub struct LayoutCtx<'a> {
286    /// Inner rect of the parent (after padding) — the area available
287    /// for child placement. Children may be positioned anywhere; the
288    /// library does not clamp returned rects to this region.
289    pub container: Rect,
290    /// Direct children of the node, in source order. Read-only — return
291    /// positions through your `Vec<Rect>`.
292    pub children: &'a [El],
293    /// Intrinsic `(width, height)` for any child. Wrapped text returns
294    /// its unwrapped width here; if you need width-dependent wrapping
295    /// you'll need to size the child with `Fixed` / `Fill` instead.
296    pub measure: &'a dyn Fn(&El) -> (f32, f32),
297    /// Look up any keyed node's laid-out rect. Returns `None` when the
298    /// key is absent from the tree, when the node hasn't been laid out
299    /// yet (siblings later in source order), or when the key was used
300    /// on a node without a recorded rect. Used by widgets like
301    /// [`crate::widgets::popover::popover`] to position children
302    /// relative to elements outside their own subtree.
303    pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
304    /// Look up a node's laid-out rect by its `computed_id`. Same
305    /// semantics as [`Self::rect_of_key`] but skips the `key →
306    /// computed_id` translation — useful for runtime-synthesized
307    /// layers (tooltips, focus rings) that anchor to a node the
308    /// library already knows by id.
309    pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
310}
311
312/// Lay out the whole tree into the given viewport rect. Assigns
313/// `computed_id`s, rebuilds the key index, and runs the layout walk.
314///
315/// Hosts that drive their own pipeline (the Aetna runtime does this in
316/// [`crate::runtime::RunnerCore::prepare_layout`]) typically call
317/// [`assign_ids`] before synthesizing floating layers (tooltips,
318/// toasts), then route the laid-out call through
319/// [`layout_post_assign`] so the id walk doesn't run twice per frame.
320pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
321    {
322        crate::profile_span!("layout::assign_ids");
323        assign_id(root, "root");
324    }
325    layout_post_assign(root, ui_state, viewport);
326}
327
328/// Like [`layout`], but skips the recursive `assign_id` walk. Callers
329/// are responsible for ensuring every node's `computed_id` is already
330/// set — typically by invoking [`assign_ids`] earlier in the pipeline,
331/// then having any per-frame floating-layer synthesis pass call
332/// [`assign_id_appended`] on its newly pushed layer.
333pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
334    with_intrinsic_cache(|| {
335        {
336            crate::profile_span!("layout::root_setup");
337            ui_state
338                .layout
339                .computed_rects
340                .insert(root.computed_id.clone(), viewport);
341            rebuild_key_index(root, ui_state);
342            // Per-scrollable scratch is rebuilt every layout — entries for
343            // scrollables that disappeared mid-frame must not leave stale
344            // thumb rects behind for hit-test or paint to find.
345            ui_state.scroll.metrics.clear();
346            ui_state.scroll.thumb_rects.clear();
347            ui_state.scroll.thumb_tracks.clear();
348        }
349        crate::profile_span!("layout::children");
350        layout_children(root, viewport, ui_state);
351    });
352}
353
354/// Assign `computed_id`s to a child that was just appended to an
355/// already-id-assigned `parent`. Companion to [`layout_post_assign`]:
356/// floating-layer synthesis (tooltip, toast) pushes one new child onto
357/// the root and uses this to give the new subtree the same path-style
358/// ids the recursive `assign_id` would have, without re-walking the
359/// rest of the tree.
360pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
361    let role = role_token(&child.kind);
362    let suffix = match (&child.key, role) {
363        (Some(k), r) => format!("{r}[{k}]"),
364        (None, r) => format!("{r}.{child_index}"),
365    };
366    assign_id(child, &format!("{parent_id}.{suffix}"));
367}
368
369/// Walk the tree once and refresh `ui_state.layout.key_index` so
370/// `LayoutCtx::rect_of_key` can resolve `key → computed_id` without
371/// re-scanning the tree per lookup. First key wins — duplicate keys
372/// are an author bug, but we don't want to crash layout over it.
373fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
374    ui_state.layout.key_index.clear();
375    fn visit(node: &El, index: &mut rustc_hash::FxHashMap<String, String>) {
376        if let Some(key) = &node.key {
377            index
378                .entry(key.clone())
379                .or_insert_with(|| node.computed_id.clone());
380        }
381        for c in &node.children {
382            visit(c, index);
383        }
384    }
385    visit(root, &mut ui_state.layout.key_index);
386}
387
388/// Assign every node's `computed_id` without positioning anything else.
389/// Useful when callers need to read or seed side-map entries (e.g.,
390/// scroll offsets) before `layout` runs.
391pub fn assign_ids(root: &mut El) {
392    assign_id(root, "root");
393}
394
395fn assign_id(node: &mut El, path: &str) {
396    node.computed_id = path.to_string();
397    for (i, c) in node.children.iter_mut().enumerate() {
398        let role = role_token(&c.kind);
399        let suffix = match (&c.key, role) {
400            (Some(k), r) => format!("{r}[{k}]"),
401            (None, r) => format!("{r}.{i}"),
402        };
403        let child_path = format!("{path}.{suffix}");
404        assign_id(c, &child_path);
405    }
406}
407
408fn role_token(k: &Kind) -> &'static str {
409    match k {
410        Kind::Group => "group",
411        Kind::Card => "card",
412        Kind::Button => "button",
413        Kind::Badge => "badge",
414        Kind::Text => "text",
415        Kind::Heading => "heading",
416        Kind::Spacer => "spacer",
417        Kind::Divider => "divider",
418        Kind::Overlay => "overlay",
419        Kind::Scrim => "scrim",
420        Kind::Modal => "modal",
421        Kind::Scroll => "scroll",
422        Kind::VirtualList => "virtual_list",
423        Kind::Inlines => "inlines",
424        Kind::HardBreak => "hard_break",
425        Kind::Math => "math",
426        Kind::Image => "image",
427        Kind::Surface => "surface",
428        Kind::Vector => "vector",
429        Kind::Custom(name) => name,
430    }
431}
432
433fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
434    if matches!(node.kind, Kind::Inlines) {
435        // The paragraph paints as a single AttributedText DrawOp;
436        // child Text/HardBreak nodes are aggregated by draw_ops::
437        // push_node and don't paint independently. Give each child a
438        // zero-size rect so the rest of the engine (hit-test, focus,
439        // animation, lint) treats them as non-paint pseudo-nodes. The
440        // paragraph's hit-test target is the Inlines node itself,
441        // sized by node_rect.
442        for c in &mut node.children {
443            ui_state.layout.computed_rects.insert(
444                c.computed_id.clone(),
445                Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
446            );
447            // Recurse so descendants of Text/HardBreak nodes (rare —
448            // these are leaves in practice — but keeping the invariant
449            // simple) still get their rects assigned.
450            layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
451        }
452        return;
453    }
454    if let Some(items) = node.virtual_items.clone() {
455        layout_virtual(node, node_rect, items, ui_state);
456        return;
457    }
458    if let Some(layout_fn) = node.layout_override.clone() {
459        layout_custom(node, node_rect, layout_fn, ui_state);
460        if node.scrollable {
461            apply_scroll_offset(node, node_rect, ui_state);
462        }
463        return;
464    }
465    match node.axis {
466        Axis::Overlay => {
467            let inner = node_rect.inset(node.padding);
468            for c in &mut node.children {
469                let c_rect = overlay_rect(c, inner, node.align, node.justify);
470                ui_state
471                    .layout
472                    .computed_rects
473                    .insert(c.computed_id.clone(), c_rect);
474                layout_children(c, c_rect, ui_state);
475            }
476        }
477        Axis::Column => layout_axis(node, node_rect, true, ui_state),
478        Axis::Row => layout_axis(node, node_rect, false, ui_state),
479    }
480    if node.scrollable {
481        apply_scroll_offset(node, node_rect, ui_state);
482    }
483}
484
485fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
486    let inner = node_rect.inset(node.padding);
487    let measure = |c: &El| intrinsic(c);
488    // Split-borrow `ui_state` so the `rect_of_key` closure reads the
489    // key index + computed rects while the surrounding function still
490    // holds the mutable borrow needed to insert this node's children
491    // back into `computed_rects` afterwards.
492    let key_index = &ui_state.layout.key_index;
493    let computed_rects = &ui_state.layout.computed_rects;
494    let rect_of_key = |key: &str| -> Option<Rect> {
495        let id = key_index.get(key)?;
496        computed_rects.get(id).copied()
497    };
498    let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
499    let rects = (layout_fn.0)(LayoutCtx {
500        container: inner,
501        children: &node.children,
502        measure: &measure,
503        rect_of_key: &rect_of_key,
504        rect_of_id: &rect_of_id,
505    });
506    assert_eq!(
507        rects.len(),
508        node.children.len(),
509        "LayoutFn for {:?} returned {} rects for {} children",
510        node.computed_id,
511        rects.len(),
512        node.children.len(),
513    );
514    for (c, c_rect) in node.children.iter_mut().zip(rects) {
515        ui_state
516            .layout
517            .computed_rects
518            .insert(c.computed_id.clone(), c_rect);
519        layout_children(c, c_rect, ui_state);
520    }
521}
522
523/// Virtualized list realization. Dispatches by [`VirtualMode`] —
524/// `Fixed` uses an O(1) division to find the visible range; `Dynamic`
525/// walks measured-or-estimated heights, measures each visible row's
526/// natural intrinsic height, and writes the result back to the height
527/// cache on `UiState` so subsequent frames have it available.
528fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
529    let inner = node_rect.inset(node.padding);
530    match items.mode {
531        VirtualMode::Fixed { row_height } => layout_virtual_fixed(
532            node,
533            inner,
534            items.count,
535            row_height,
536            items.build_row,
537            ui_state,
538        ),
539        VirtualMode::Dynamic {
540            estimated_row_height,
541        } => layout_virtual_dynamic(
542            node,
543            inner,
544            items.count,
545            estimated_row_height,
546            DynamicVirtualFns {
547                anchor_policy: items.anchor_policy,
548                row_key: items.row_key,
549                build_row: items.build_row,
550            },
551            ui_state,
552        ),
553    }
554}
555
556/// Consume any pending [`ScrollRequest`]s targeting this list's `key`,
557/// resolving each into a target offset using the live viewport rect and
558/// the caller-supplied row-extent function. Writes the resolved offset
559/// directly into `scroll.offsets`; the immediately-following
560/// `write_virtual_scroll_state` call clamps it to `[0, max_offset]`.
561///
562/// Requests for other lists are left in the queue for sibling lists in
563/// the same layout pass. Anything still queued after layout completes is
564/// dropped by the runtime (see `prepare_layout`).
565fn resolve_scroll_requests<F, K>(
566    node: &El,
567    inner: Rect,
568    count: usize,
569    row_extent: F,
570    row_for_key: K,
571    ui_state: &mut UiState,
572) -> bool
573where
574    F: Fn(usize) -> (f32, f32),
575    K: Fn(&str) -> Option<usize>,
576{
577    if ui_state.scroll.pending_requests.is_empty() {
578        return false;
579    }
580    let Some(key) = node.key.as_deref() else {
581        return false;
582    };
583    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
584    let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
585        pending.into_iter().partition(|req| match req {
586            ScrollRequest::ToRow { list_key, .. } => list_key == key,
587            ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
588            // EnsureVisible isn't a virtual-list-row request; let the
589            // non-virtual scroll resolver pick it up downstream.
590            ScrollRequest::EnsureVisible { .. } => false,
591        });
592    ui_state.scroll.pending_requests = remaining;
593
594    let mut wrote = false;
595    for req in matched {
596        let (row, align) = match req {
597            ScrollRequest::ToRow { row, align, .. } => (row, align),
598            ScrollRequest::ToRowKey { row_key, align, .. } => {
599                let Some(row) = row_for_key(&row_key) else {
600                    continue;
601                };
602                (row, align)
603            }
604            ScrollRequest::EnsureVisible { .. } => continue,
605        };
606        if row >= count {
607            continue;
608        }
609        let (row_top, row_h) = row_extent(row);
610        let row_bottom = row_top + row_h;
611        let viewport_h = inner.h;
612        let current = ui_state
613            .scroll
614            .offsets
615            .get(&node.computed_id)
616            .copied()
617            .unwrap_or(0.0);
618        let new_offset = match align {
619            ScrollAlignment::Start => row_top,
620            ScrollAlignment::End => row_bottom - viewport_h,
621            ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
622            ScrollAlignment::Visible => {
623                if row_top < current {
624                    row_top
625                } else if row_bottom > current + viewport_h {
626                    row_bottom - viewport_h
627                } else {
628                    continue;
629                }
630            }
631        };
632        ui_state
633            .scroll
634            .offsets
635            .insert(node.computed_id.clone(), new_offset);
636        wrote = true;
637    }
638    wrote
639}
640
641/// Clamp the stored scroll offset, write the metrics + thumb rect, and
642/// return the clamped offset. Shared scaffold for both virtual modes.
643fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
644    let max_offset = (total_h - inner.h).max(0.0);
645    let stored = ui_state
646        .scroll
647        .offsets
648        .get(&node.computed_id)
649        .copied()
650        .unwrap_or(0.0);
651    let stored = resolve_pin_end(node, stored, max_offset, ui_state);
652    let offset = stored.clamp(0.0, max_offset);
653    ui_state
654        .scroll
655        .offsets
656        .insert(node.computed_id.clone(), offset);
657    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
658    offset
659}
660
661fn write_virtual_scroll_metrics(
662    node: &El,
663    inner: Rect,
664    total_h: f32,
665    max_offset: f32,
666    offset: f32,
667    ui_state: &mut UiState,
668) {
669    ui_state.scroll.metrics.insert(
670        node.computed_id.clone(),
671        crate::state::ScrollMetrics {
672            viewport_h: inner.h,
673            content_h: total_h,
674            max_offset,
675        },
676    );
677    write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
678}
679
680/// Assign the realized row a path-style `computed_id` matching the
681/// regular tree's role/key/index convention so hit-test, focus, and
682/// state lookups remain stable across scrolls.
683fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
684    let role = role_token(&child.kind);
685    let suffix = match (&child.key, role) {
686        (Some(k), r) => format!("{r}[{k}]"),
687        (None, r) => format!("{r}.{global_i}"),
688    };
689    assign_id(child, &format!("{parent_id}.{suffix}"));
690}
691
692fn layout_virtual_fixed(
693    node: &mut El,
694    inner: Rect,
695    count: usize,
696    row_height: f32,
697    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
698    ui_state: &mut UiState,
699) {
700    let gap = node.gap.max(0.0);
701    let pitch = row_height + gap;
702    let total_h = virtual_total_height(count, count as f32 * row_height, gap);
703    resolve_scroll_requests(
704        node,
705        inner,
706        count,
707        |i| (i as f32 * pitch, row_height),
708        |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
709        ui_state,
710    );
711    let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
712
713    if count == 0 {
714        node.children.clear();
715        return;
716    }
717
718    // Visible index range — `start` floors, `end` ceils, both clamped.
719    // Include one extra candidate because a large gap can make the
720    // pitch-based ceil land on the gap before the next visible row.
721    let start = (offset / pitch).floor() as usize;
722    let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
723
724    let mut realized: Vec<El> = Vec::new();
725    for global_i in start..end {
726        let row_top = global_i as f32 * pitch;
727        if row_top >= offset + inner.h || row_top + row_height <= offset {
728            continue;
729        }
730        let mut child = (build_row)(global_i);
731        assign_virtual_row_id(&mut child, &node.computed_id, global_i);
732
733        let row_y = inner.y + row_top - offset;
734        let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
735        ui_state
736            .layout
737            .computed_rects
738            .insert(child.computed_id.clone(), c_rect);
739        layout_children(&mut child, c_rect, ui_state);
740        realized.push(child);
741    }
742    node.children = realized;
743}
744
745fn layout_virtual_dynamic(
746    node: &mut El,
747    inner: Rect,
748    count: usize,
749    estimated_row_height: f32,
750    fns: DynamicVirtualFns,
751    ui_state: &mut UiState,
752) {
753    let gap = node.gap.max(0.0);
754    let width_bucket = virtual_width_bucket(inner.w);
755    let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
756    prune_dynamic_measurements(node, &row_keys, ui_state);
757
758    if count == 0 {
759        ui_state.scroll.virtual_anchors.remove(&node.computed_id);
760        let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
761        debug_assert_eq!(offset, 0.0);
762        node.children.clear();
763        return;
764    }
765
766    let mut row_heights = dynamic_row_heights(
767        node,
768        &row_keys,
769        width_bucket,
770        estimated_row_height,
771        ui_state,
772    );
773
774    // Skip the cache snapshot entirely when nothing in the queue
775    // targets this list — a hot path on dynamic lists with warm
776    // caches (potentially thousands of entries) that would otherwise
777    // pay a per-frame HashMap clone for an operation that fires
778    // maybe once a minute.
779    let has_request = node.key.as_deref().is_some_and(|k| {
780        ui_state.scroll.pending_requests.iter().any(|r| match r {
781            ScrollRequest::ToRow { list_key, .. } => list_key == k,
782            ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
783            ScrollRequest::EnsureVisible { .. } => false,
784        })
785    });
786    let mut request_wrote = false;
787    if has_request {
788        request_wrote = resolve_scroll_requests(
789            node,
790            inner,
791            count,
792            |target| {
793                (
794                    dynamic_row_top(&row_heights, gap, target),
795                    row_heights[target],
796                )
797            },
798            |row_key| row_keys.iter().position(|key| key == row_key),
799            ui_state,
800        );
801    }
802
803    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
804    let max_offset = (total_h - inner.h).max(0.0);
805    let stored = ui_state
806        .scroll
807        .offsets
808        .get(&node.computed_id)
809        .copied()
810        .unwrap_or(0.0);
811    let pin_active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
812    let provisional_offset = if pin_active {
813        max_offset
814    } else if request_wrote {
815        stored
816    } else {
817        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
818            .unwrap_or(stored)
819    }
820    .clamp(0.0, max_offset);
821
822    let (measure_start, _, measure_end) =
823        dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
824    measure_dynamic_range(
825        node,
826        DynamicRangeCtx {
827            inner,
828            row_keys: &row_keys,
829            width_bucket,
830            build_row: &fns.build_row,
831        },
832        measure_start,
833        measure_end,
834        ui_state,
835    );
836
837    row_heights = dynamic_row_heights(
838        node,
839        &row_keys,
840        width_bucket,
841        estimated_row_height,
842        ui_state,
843    );
844    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
845    let max_offset = (total_h - inner.h).max(0.0);
846    let stored = ui_state
847        .scroll
848        .offsets
849        .get(&node.computed_id)
850        .copied()
851        .unwrap_or(0.0);
852    let pin_resolved = resolve_pin_end(node, stored, max_offset, ui_state);
853    let pin_active = node.pin_end
854        && ui_state
855            .scroll
856            .pin_active
857            .get(&node.computed_id)
858            .copied()
859            .unwrap_or(false);
860    let mut offset = if pin_active {
861        pin_resolved
862    } else if request_wrote {
863        stored
864    } else {
865        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
866            .unwrap_or(stored)
867    }
868    .clamp(0.0, max_offset);
869
870    ui_state
871        .scroll
872        .offsets
873        .insert(node.computed_id.clone(), offset);
874
875    let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
876    let mut realized_rows = layout_dynamic_range(
877        node,
878        DynamicRangeCtx {
879            inner,
880            row_keys: &row_keys,
881            width_bucket,
882            build_row: &fns.build_row,
883        },
884        offset,
885        start,
886        start_y,
887        end,
888        ui_state,
889    );
890
891    row_heights = dynamic_row_heights(
892        node,
893        &row_keys,
894        width_bucket,
895        estimated_row_height,
896        ui_state,
897    );
898    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
899    let max_offset = (total_h - inner.h).max(0.0);
900    let corrected_offset = if pin_active {
901        max_offset
902    } else if request_wrote {
903        offset
904    } else {
905        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
906            .unwrap_or(offset)
907    }
908    .clamp(0.0, max_offset);
909    if (corrected_offset - offset).abs() > 0.01 {
910        let dy = offset - corrected_offset;
911        for child in &node.children {
912            shift_subtree_y(child, dy, ui_state);
913        }
914        for row in &mut realized_rows {
915            row.rect.y += dy;
916        }
917        offset = corrected_offset;
918        ui_state
919            .scroll
920            .offsets
921            .insert(node.computed_id.clone(), offset);
922    }
923    if node.pin_end {
924        ui_state
925            .scroll
926            .pin_prev_max
927            .insert(node.computed_id.clone(), max_offset);
928    }
929    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
930
931    if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
932        ui_state
933            .scroll
934            .virtual_anchors
935            .insert(node.computed_id.clone(), anchor);
936    } else {
937        ui_state.scroll.virtual_anchors.remove(&node.computed_id);
938    }
939}
940
941struct DynamicVirtualFns {
942    anchor_policy: VirtualAnchorPolicy,
943    row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
944    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
945}
946
947#[derive(Clone, Copy)]
948struct DynamicRangeCtx<'a> {
949    inner: Rect,
950    row_keys: &'a [String],
951    width_bucket: u32,
952    build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
953}
954
955fn virtual_width_bucket(width: f32) -> u32 {
956    width.max(0.0).round().min(u32::MAX as f32) as u32
957}
958
959fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
960    let Some(measurements) = ui_state
961        .scroll
962        .measured_row_heights
963        .get_mut(&node.computed_id)
964    else {
965        return;
966    };
967    let live_keys = row_keys
968        .iter()
969        .map(String::as_str)
970        .collect::<FxHashSet<_>>();
971    measurements.retain(|key, widths| {
972        let live = live_keys.contains(key.as_str());
973        if live {
974            widths.retain(|_, h| h.is_finite() && *h >= 0.0);
975        }
976        live && !widths.is_empty()
977    });
978    if measurements.is_empty() {
979        ui_state
980            .scroll
981            .measured_row_heights
982            .remove(&node.computed_id);
983    }
984}
985
986fn dynamic_row_heights(
987    node: &El,
988    row_keys: &[String],
989    width_bucket: u32,
990    estimated_row_height: f32,
991    ui_state: &UiState,
992) -> Vec<f32> {
993    let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
994    row_keys
995        .iter()
996        .map(|key| {
997            measurements
998                .and_then(|m| m.get(key))
999                .and_then(|by_width| by_width.get(&width_bucket))
1000                .copied()
1001                .unwrap_or(estimated_row_height)
1002        })
1003        .collect()
1004}
1005
1006fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1007    row_heights
1008        .iter()
1009        .take(target)
1010        .fold(0.0, |y, h| y + *h + gap)
1011}
1012
1013fn dynamic_visible_range(
1014    row_heights: &[f32],
1015    gap: f32,
1016    offset: f32,
1017    viewport_h: f32,
1018) -> (usize, f32, usize) {
1019    let count = row_heights.len();
1020    let mut start = 0;
1021    let mut y = 0.0_f32;
1022    while start < count {
1023        let h = row_heights[start];
1024        if y + h > offset {
1025            break;
1026        }
1027        y += h + gap;
1028        start += 1;
1029    }
1030
1031    let mut end = start;
1032    let mut cursor = y;
1033    let viewport_bottom = offset + viewport_h;
1034    while end < count && cursor < viewport_bottom {
1035        let h = row_heights[end];
1036        end += 1;
1037        cursor += h + gap;
1038    }
1039    (start, y, end)
1040}
1041
1042fn dynamic_anchor_offset(
1043    node: &El,
1044    row_keys: &[String],
1045    row_heights: &[f32],
1046    gap: f32,
1047    stored: f32,
1048    ui_state: &UiState,
1049) -> Option<f32> {
1050    let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1051    let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1052        anchor.row_index
1053    } else {
1054        row_keys.iter().position(|key| key == &anchor.row_key)?
1055    };
1056    let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1057    let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1058    let scroll_delta = stored - anchor.resolved_offset;
1059    let viewport_y = anchor.viewport_y - scroll_delta;
1060    Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1061}
1062
1063fn measure_dynamic_range(
1064    node: &El,
1065    ctx: DynamicRangeCtx<'_>,
1066    start: usize,
1067    end: usize,
1068    ui_state: &mut UiState,
1069) {
1070    if start >= end {
1071        return;
1072    }
1073    let mut new_measurements = Vec::new();
1074    for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1075        let child = (ctx.build_row)(idx);
1076        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1077        new_measurements.push((key.clone(), actual_h));
1078    }
1079    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1080}
1081
1082fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1083    match child.height {
1084        Size::Fixed(v) => v.max(0.0),
1085        Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1086        Size::Fill(_) => panic!(
1087            "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
1088             Size::Fill would absorb the viewport's height and break virtualization",
1089            node.computed_id,
1090        ),
1091    }
1092}
1093
1094fn store_dynamic_measurements(
1095    node: &El,
1096    width_bucket: u32,
1097    measurements: Vec<(String, f32)>,
1098    ui_state: &mut UiState,
1099) {
1100    if measurements.is_empty() {
1101        return;
1102    }
1103    let entry = ui_state
1104        .scroll
1105        .measured_row_heights
1106        .entry(node.computed_id.clone())
1107        .or_default();
1108    for (row_key, h) in measurements {
1109        entry.entry(row_key).or_default().insert(width_bucket, h);
1110    }
1111}
1112
1113#[derive(Clone, Debug)]
1114struct DynamicRealizedRow {
1115    index: usize,
1116    key: String,
1117    rect: Rect,
1118}
1119
1120fn layout_dynamic_range(
1121    node: &mut El,
1122    ctx: DynamicRangeCtx<'_>,
1123    offset: f32,
1124    start: usize,
1125    start_y: f32,
1126    end: usize,
1127    ui_state: &mut UiState,
1128) -> Vec<DynamicRealizedRow> {
1129    let gap = node.gap.max(0.0);
1130    let mut cursor_y = start_y;
1131    let mut realized = Vec::new();
1132    let mut realized_rows = Vec::new();
1133    let mut new_measurements = Vec::new();
1134
1135    for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1136        let mut child = (ctx.build_row)(idx);
1137        assign_virtual_row_id(&mut child, &node.computed_id, idx);
1138        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1139        new_measurements.push((key.clone(), actual_h));
1140
1141        let row_y = ctx.inner.y + cursor_y - offset;
1142        let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1143        ui_state
1144            .layout
1145            .computed_rects
1146            .insert(child.computed_id.clone(), c_rect);
1147        layout_children(&mut child, c_rect, ui_state);
1148
1149        realized_rows.push(DynamicRealizedRow {
1150            index: idx,
1151            key: key.clone(),
1152            rect: c_rect,
1153        });
1154        realized.push(child);
1155        cursor_y += actual_h + gap;
1156    }
1157
1158    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1159    node.children = realized;
1160    realized_rows
1161}
1162
1163fn choose_dynamic_anchor(
1164    policy: VirtualAnchorPolicy,
1165    inner: Rect,
1166    offset: f32,
1167    rows: &[DynamicRealizedRow],
1168) -> Option<VirtualAnchor> {
1169    let visible = rows
1170        .iter()
1171        .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1172        .collect::<Vec<_>>();
1173    if visible.is_empty() {
1174        return None;
1175    }
1176
1177    let chosen = match policy {
1178        VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1179            let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1180            visible
1181                .iter()
1182                .min_by(|a, b| {
1183                    let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1184                    let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1185                    ad.total_cmp(&bd)
1186                })
1187                .copied()
1188                .map(|row| {
1189                    let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1190                    (row.clone(), anchor_y)
1191                })
1192        }
1193        VirtualAnchorPolicy::FirstVisible => {
1194            let row = visible
1195                .iter()
1196                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1197                .or_else(|| visible.first())
1198                .copied()?;
1199            let anchor_y = row.rect.y.max(inner.y);
1200            Some((row.clone(), anchor_y))
1201        }
1202        VirtualAnchorPolicy::LastVisible => {
1203            let row = visible
1204                .iter()
1205                .rev()
1206                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1207                .or_else(|| visible.last())
1208                .copied()?;
1209            let anchor_y = row.rect.bottom().min(inner.bottom());
1210            Some((row.clone(), anchor_y))
1211        }
1212    }?;
1213
1214    let (row, anchor_y) = chosen;
1215    let row_h = row.rect.h.max(0.0);
1216    let row_fraction = if row_h > 0.0 {
1217        ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1218    } else {
1219        0.0
1220    };
1221    Some(VirtualAnchor {
1222        row_key: row.key.clone(),
1223        row_index: row.index,
1224        row_fraction,
1225        viewport_y: anchor_y - inner.y,
1226        resolved_offset: offset,
1227    })
1228}
1229
1230fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1231    if y < top {
1232        top - y
1233    } else if y > bottom {
1234        y - bottom
1235    } else {
1236        0.0
1237    }
1238}
1239
1240fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1241    if count == 0 {
1242        0.0
1243    } else {
1244        row_sum + gap * count.saturating_sub(1) as f32
1245    }
1246}
1247
1248/// Scrollable post-pass: measure content height from the laid-out
1249/// children's stored rects, clamp the scroll offset to the available
1250/// range, and shift every descendant rect by `-offset`.
1251///
1252/// Children should size with `Hug` or `Fixed` on the main axis —
1253/// `Fill` children would absorb the viewport's height and there would
1254/// be nothing to scroll.
1255fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1256    let inner = node_rect.inset(node.padding);
1257    if node.children.is_empty() {
1258        ui_state
1259            .scroll
1260            .offsets
1261            .insert(node.computed_id.clone(), 0.0);
1262        ui_state.scroll.metrics.insert(
1263            node.computed_id.clone(),
1264            crate::state::ScrollMetrics {
1265                viewport_h: inner.h,
1266                content_h: 0.0,
1267                max_offset: 0.0,
1268            },
1269        );
1270        return;
1271    }
1272    let content_bottom = node
1273        .children
1274        .iter()
1275        .map(|c| ui_state.rect(&c.computed_id).bottom())
1276        .fold(f32::NEG_INFINITY, f32::max);
1277    let content_h = (content_bottom - inner.y).max(0.0);
1278    let max_offset = (content_h - inner.h).max(0.0);
1279
1280    // Resolve any matching `ScrollRequest::EnsureVisible` against
1281    // this scroll BEFORE reading the stored offset, so the request's
1282    // chosen offset wins (and gets clamped below, just like
1283    // wheel-driven offsets do). A request matches when the node
1284    // keyed `container_key` is an ancestor of this scroll —
1285    // `key_index` resolves the key to a computed_id and a
1286    // prefix-match on `node.computed_id` tells us we're inside.
1287    resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1288
1289    let stored = ui_state
1290        .scroll
1291        .offsets
1292        .get(&node.computed_id)
1293        .copied()
1294        .unwrap_or(0.0);
1295    let stored = resolve_pin_end(node, stored, max_offset, ui_state);
1296    let clamped = stored.clamp(0.0, max_offset);
1297    if clamped > 0.0 {
1298        for c in &node.children {
1299            shift_subtree_y(c, -clamped, ui_state);
1300        }
1301    }
1302    ui_state
1303        .scroll
1304        .offsets
1305        .insert(node.computed_id.clone(), clamped);
1306    ui_state.scroll.metrics.insert(
1307        node.computed_id.clone(),
1308        crate::state::ScrollMetrics {
1309            viewport_h: inner.h,
1310            content_h,
1311            max_offset,
1312        },
1313    );
1314
1315    write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1316}
1317
1318/// Stored offset within this much of `max_offset` counts as "at the
1319/// tail" for [`El::pin_end`]. Wheel deltas are integer pixels, so a
1320/// half-pixel slack absorbs floating-point rounding without admitting
1321/// any deliberate user scroll.
1322const PIN_END_EPSILON: f32 = 0.5;
1323
1324fn pin_end_would_be_active(
1325    node: &El,
1326    stored: f32,
1327    _max_offset: f32,
1328    ui_state: &UiState,
1329) -> Option<bool> {
1330    if !node.pin_end {
1331        return None;
1332    }
1333    let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1334    let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1335    Some(match prev_active {
1336        None => true,
1337        Some(prev) => {
1338            let prev_max = prev_max.unwrap_or(0.0);
1339            if prev && stored < prev_max - PIN_END_EPSILON {
1340                false
1341            } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
1342                true
1343            } else {
1344                prev
1345            }
1346        }
1347    })
1348}
1349
1350/// Apply [`El::pin_end`] semantics to `stored`. Reads the previous
1351/// frame's `max_offset` from `scroll.metrics` to decide whether the
1352/// stored offset has moved off the tail since last frame (user wheel /
1353/// drag / programmatic write), and updates `scroll.pin_active`
1354/// accordingly. Returns the offset that should be clamped + written
1355/// downstream — `max_offset` when the pin is engaged, the input
1356/// `stored` otherwise.
1357///
1358/// First frame for an opted-in container starts pinned, so a freshly
1359/// mounted `scroll([...]).pin_end()` paints with its tail visible.
1360fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1361    if !node.pin_end {
1362        ui_state.scroll.pin_active.remove(&node.computed_id);
1363        ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1364        return stored;
1365    }
1366    let active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1367    ui_state
1368        .scroll
1369        .pin_active
1370        .insert(node.computed_id.clone(), active);
1371    ui_state
1372        .scroll
1373        .pin_prev_max
1374        .insert(node.computed_id.clone(), max_offset);
1375    if active { max_offset } else { stored }
1376}
1377
1378/// Walk pending `ScrollRequest::EnsureVisible` requests and pop any
1379/// whose `container_key` resolves to an ancestor of `node`. For each
1380/// match, write a stored offset that brings the request's content-
1381/// space `y..y+h` range into the viewport using minimal-displacement
1382/// semantics (top edge if above, bottom edge if below, leave alone if
1383/// already inside). The clamp + shift downstream of this call ensures
1384/// the resulting offset stays inside `[0, max_offset]`.
1385///
1386/// Matching is by computed-id prefix on the keyed ancestor — a
1387/// scroll is "inside" the keyed widget when its id starts with the
1388/// ancestor's id followed by `.`, the same rule used by
1389/// [`crate::state::query::target_in_subtree`].
1390fn resolve_ensure_visible_for_scroll(
1391    node: &El,
1392    inner: Rect,
1393    content_h: f32,
1394    ui_state: &mut UiState,
1395) {
1396    if ui_state.scroll.pending_requests.is_empty() {
1397        return;
1398    }
1399    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1400    let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1401    for req in pending {
1402        let ScrollRequest::EnsureVisible {
1403            container_key,
1404            y,
1405            h,
1406        } = &req
1407        else {
1408            remaining.push(req);
1409            continue;
1410        };
1411        let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1412            // Container hasn't been laid out yet (or its key isn't
1413            // in this tree). Keep the request for a future frame —
1414            // dropped at end-of-frame like row requests for
1415            // missing lists.
1416            remaining.push(req);
1417            continue;
1418        };
1419        // Match this scroll only if it sits inside the keyed widget.
1420        // Same prefix rule as `target_in_subtree`.
1421        let inside = node.computed_id == *ancestor_id
1422            || node
1423                .computed_id
1424                .strip_prefix(ancestor_id.as_str())
1425                .is_some_and(|rest| rest.starts_with('.'));
1426        if !inside {
1427            remaining.push(req);
1428            continue;
1429        }
1430        let current = ui_state
1431            .scroll
1432            .offsets
1433            .get(&node.computed_id)
1434            .copied()
1435            .unwrap_or(0.0);
1436        let target_top = *y;
1437        let target_bottom = *y + *h;
1438        let viewport_h = inner.h;
1439        // Minimal-displacement: if the range is fully visible, no
1440        // change. If it's above the viewport top, scroll up to it.
1441        // If it's below the viewport bottom, scroll just enough to
1442        // expose the bottom edge — but never less than 0 or more
1443        // than `content_h - viewport_h` (the clamp downstream will
1444        // do that anyway).
1445        let new_offset = if target_top < current {
1446            target_top
1447        } else if target_bottom > current + viewport_h {
1448            target_bottom - viewport_h
1449        } else {
1450            // Already visible: don't override an in-progress
1451            // manual scroll just because the caret happens to be
1452            // mid-viewport. Skip this request without disturbing
1453            // the offset.
1454            continue;
1455        };
1456        // Clamp against the live content extent so we don't write
1457        // a wildly-out-of-range offset when the request races a
1458        // layout pass that hasn't yet measured all rows.
1459        let max = (content_h - viewport_h).max(0.0);
1460        let new_offset = new_offset.clamp(0.0, max);
1461        ui_state
1462            .scroll
1463            .offsets
1464            .insert(node.computed_id.clone(), new_offset);
1465    }
1466    ui_state.scroll.pending_requests = remaining;
1467}
1468
1469/// Compute and store the scrollbar thumb + track rects for `node`
1470/// when the author opted into a visible scrollbar AND content
1471/// overflows. Both rects are anchored to the right edge of `inner`.
1472/// The visible thumb is `SCROLLBAR_THUMB_WIDTH` wide and tracks the
1473/// scroll offset; the track is `SCROLLBAR_HITBOX_WIDTH` wide and
1474/// covers the full inner height so a press above/below the thumb
1475/// can page-scroll.
1476fn write_thumb_rect(
1477    node: &El,
1478    inner: Rect,
1479    content_h: f32,
1480    max_offset: f32,
1481    offset: f32,
1482    ui_state: &mut UiState,
1483) {
1484    if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1485        return;
1486    }
1487    let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1488    let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1489    let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1490    let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1491    let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1492    let track_remaining = (inner.h - thumb_h).max(0.0);
1493    let thumb_y = inner.y + track_remaining * (offset / max_offset);
1494    let thumb_x = inner.right() - thumb_w - track_inset;
1495    let track_x = inner.right() - track_w - track_inset;
1496    ui_state.scroll.thumb_rects.insert(
1497        node.computed_id.clone(),
1498        Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1499    );
1500    ui_state.scroll.thumb_tracks.insert(
1501        node.computed_id.clone(),
1502        Rect::new(track_x, inner.y, track_w, inner.h),
1503    );
1504}
1505
1506fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1507    if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1508        rect.y += dy;
1509    }
1510    for c in &node.children {
1511        shift_subtree_y(c, dy, ui_state);
1512    }
1513}
1514
1515fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1516    let inner = node_rect.inset(node.padding);
1517    let n = node.children.len();
1518    if n == 0 {
1519        return;
1520    }
1521
1522    let total_gap = node.gap * n.saturating_sub(1) as f32;
1523    let main_extent = if vertical { inner.h } else { inner.w };
1524    let cross_extent = if vertical { inner.w } else { inner.h };
1525
1526    let intrinsics: Vec<(f32, f32)> = {
1527        crate::profile_span!("layout::axis::intrinsics");
1528        node.children
1529            .iter()
1530            .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1531            .collect()
1532    };
1533
1534    let mut consumed = 0.0;
1535    let mut fill_weight_total = 0.0;
1536    for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1537        match main_size_of(c, *iw, *ih, vertical) {
1538            MainSize::Resolved(v) => consumed += v,
1539            MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1540        }
1541    }
1542    let remaining = (main_extent - consumed - total_gap).max(0.0);
1543
1544    // Free space after children + gaps. When there are Fill children they
1545    // claim it all, so justify is moot; otherwise this is what center/end
1546    // distribute around.
1547    let free_after_used = if fill_weight_total == 0.0 {
1548        remaining
1549    } else {
1550        0.0
1551    };
1552    let mut cursor = match node.justify {
1553        Justify::Start => 0.0,
1554        Justify::Center => free_after_used * 0.5,
1555        Justify::End => free_after_used,
1556        Justify::SpaceBetween => 0.0,
1557    };
1558    let between_extra =
1559        if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1560            remaining / (n - 1) as f32
1561        } else {
1562            0.0
1563        };
1564    let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1565
1566    crate::profile_span!("layout::axis::place");
1567    for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1568        let main_size = match main_size_of(c, iw, ih, vertical) {
1569            MainSize::Resolved(v) => v,
1570            MainSize::Fill(w) => remaining * w.max(0.001) / fill_weight_total.max(0.001),
1571        };
1572
1573        let cross_intent = if vertical { c.width } else { c.height };
1574        let cross_intrinsic = if vertical { iw } else { ih };
1575        // CSS-flex parity for cross-axis sizing: `Size::Fixed` is an
1576        // explicit author override and always wins. Otherwise the
1577        // parent's `Align` decides — `Stretch` (the column default)
1578        // stretches non-fixed children to the container, `Center` /
1579        // `Start` / `End` shrink to intrinsic so the alignment can
1580        // actually position them. This collapses Hug and Fill on the
1581        // cross axis (both are "follow align-items"), the same way
1582        // CSS flex doesn't distinguish between them on the cross axis.
1583        let cross_size = match cross_intent {
1584            Size::Fixed(v) => v,
1585            Size::Hug | Size::Fill(_) => match node.align {
1586                Align::Stretch => cross_extent,
1587                Align::Start | Align::Center | Align::End => cross_intrinsic,
1588            },
1589        };
1590
1591        let cross_off = match node.align {
1592            Align::Start | Align::Stretch => 0.0,
1593            Align::Center => (cross_extent - cross_size) * 0.5,
1594            Align::End => cross_extent - cross_size,
1595        };
1596
1597        let c_rect = if vertical {
1598            Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1599        } else {
1600            Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1601        };
1602        ui_state
1603            .layout
1604            .computed_rects
1605            .insert(c.computed_id.clone(), c_rect);
1606        if can_prune_scroll_child(c, c_rect, scroll_visible) {
1607            let nodes = zero_descendant_rects(c, c_rect, ui_state);
1608            record_pruned_subtree(nodes);
1609        } else {
1610            layout_children(c, c_rect, ui_state);
1611        }
1612
1613        cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1614    }
1615}
1616
1617const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1618
1619fn scroll_visible_content_rect(
1620    node: &El,
1621    inner: Rect,
1622    vertical: bool,
1623    ui_state: &UiState,
1624) -> Option<Rect> {
1625    if !vertical || !node.scrollable || node.pin_end {
1626        return None;
1627    }
1628    let offset = ui_state
1629        .scroll
1630        .offsets
1631        .get(&node.computed_id)
1632        .copied()
1633        .unwrap_or(0.0)
1634        .max(0.0);
1635    Some(Rect::new(
1636        inner.x,
1637        inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1638        inner.w,
1639        inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1640    ))
1641}
1642
1643fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1644    let Some(visible) = visible else {
1645        return false;
1646    };
1647    child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1648}
1649
1650fn subtree_is_layout_confined(node: &El) -> bool {
1651    if node.translate != (0.0, 0.0)
1652        || node.scale != 1.0
1653        || node.shadow > 0.0
1654        || node.paint_overflow != Sides::zero()
1655        || node.hit_overflow != Sides::zero()
1656        || node.layout_override.is_some()
1657        || node.virtual_items.is_some()
1658    {
1659        return false;
1660    }
1661    node.children.iter().all(subtree_is_layout_confined)
1662}
1663
1664fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1665    let mut count = 0;
1666    let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1667    for child in &node.children {
1668        ui_state
1669            .layout
1670            .computed_rects
1671            .insert(child.computed_id.clone(), zero);
1672        count += 1 + zero_descendant_rects(child, zero, ui_state);
1673    }
1674    count
1675}
1676
1677fn record_pruned_subtree(nodes: u64) {
1678    INTRINSIC_CACHE.with(|cell| {
1679        if let Some(cache) = cell.borrow_mut().as_mut() {
1680            cache.prune.subtrees += 1;
1681            cache.prune.nodes += nodes;
1682        }
1683    });
1684}
1685
1686enum MainSize {
1687    Resolved(f32),
1688    Fill(f32),
1689}
1690
1691fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1692    let s = if vertical { c.height } else { c.width };
1693    let intr = if vertical { ih } else { iw };
1694    match s {
1695        Size::Fixed(v) => MainSize::Resolved(v),
1696        Size::Hug => MainSize::Resolved(intr),
1697        Size::Fill(w) => MainSize::Fill(w),
1698    }
1699}
1700
1701fn child_intrinsic(
1702    c: &El,
1703    vertical: bool,
1704    parent_cross_extent: f32,
1705    parent_align: Align,
1706) -> (f32, f32) {
1707    if !vertical {
1708        return intrinsic(c);
1709    }
1710    let available_width = match c.width {
1711        Size::Fixed(v) => Some(v),
1712        Size::Fill(_) => Some(parent_cross_extent),
1713        Size::Hug => match parent_align {
1714            Align::Stretch => Some(parent_cross_extent),
1715            Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1716        },
1717    };
1718    intrinsic_constrained(c, available_width)
1719}
1720
1721fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1722    // Wrap-text height depends on width, so constrain the intrinsic
1723    // measurement to the width the child will actually be laid out at
1724    // — same shape as `child_intrinsic` does for column/row children.
1725    // Without this, a Fixed-width modal with a wrappable paragraph
1726    // measures as a single-line block and the modal's Hug height ends
1727    // up shorter than the actual content needs, eating bottom padding.
1728    let constrained_width = match c.width {
1729        Size::Fixed(v) => Some(v),
1730        Size::Fill(_) | Size::Hug => Some(parent.w),
1731    };
1732    let (iw, ih) = intrinsic_constrained(c, constrained_width);
1733    let w = match c.width {
1734        Size::Fixed(v) => v,
1735        Size::Hug => iw.min(parent.w),
1736        Size::Fill(_) => parent.w,
1737    };
1738    let h = match c.height {
1739        Size::Fixed(v) => v,
1740        Size::Hug => ih.min(parent.h),
1741        Size::Fill(_) => parent.h,
1742    };
1743    let x = match align {
1744        Align::Start | Align::Stretch => parent.x,
1745        Align::Center => parent.x + (parent.w - w) * 0.5,
1746        Align::End => parent.right() - w,
1747    };
1748    let y = match justify {
1749        Justify::Start | Justify::SpaceBetween => parent.y,
1750        Justify::Center => parent.y + (parent.h - h) * 0.5,
1751        Justify::End => parent.bottom() - h,
1752    };
1753    Rect::new(x, y, w, h)
1754}
1755
1756/// Intrinsic (width, height) for hugging layouts.
1757pub fn intrinsic(c: &El) -> (f32, f32) {
1758    intrinsic_constrained(c, None)
1759}
1760
1761fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1762    let key = intrinsic_cache_key(c, available_width);
1763    if let Some(key) = &key
1764        && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
1765            let mut slot = cell.borrow_mut();
1766            let cache = slot.as_mut()?;
1767            let cached = cache.measurements.get(key).copied();
1768            if cached.is_some() {
1769                cache.stats.hits += 1;
1770            }
1771            cached
1772        })
1773    {
1774        return cached;
1775    }
1776
1777    if key.is_some() {
1778        INTRINSIC_CACHE.with(|cell| {
1779            if let Some(cache) = cell.borrow_mut().as_mut() {
1780                cache.stats.misses += 1;
1781            }
1782        });
1783    }
1784
1785    let measured = intrinsic_constrained_uncached(c, available_width);
1786
1787    if let Some(key) = key {
1788        INTRINSIC_CACHE.with(|cell| {
1789            if let Some(cache) = cell.borrow_mut().as_mut() {
1790                cache.measurements.insert(key, measured);
1791            }
1792        });
1793    }
1794
1795    measured
1796}
1797
1798fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
1799    if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
1800        return None;
1801    }
1802    if c.computed_id.is_empty() {
1803        return None;
1804    }
1805    Some(IntrinsicCacheKey {
1806        computed_id: c.computed_id.clone(),
1807        available_width_bits: available_width.map(f32::to_bits),
1808    })
1809}
1810
1811fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
1812    if c.layout_override.is_some() {
1813        // Custom-layout nodes don't define an intrinsic. Authors must
1814        // size them with `Fixed` or `Fill` on both axes; the returned
1815        // (0.0, 0.0) is replaced by `apply_min` for `Fixed` and is
1816        // unread for `Fill` (parent's distribution decides).
1817        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1818            panic!(
1819                "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1820                 Size::Hug is not supported for custom layouts",
1821                c.computed_id,
1822            );
1823        }
1824        return apply_min(c, 0.0, 0.0);
1825    }
1826    if c.virtual_items.is_some() {
1827        // VirtualList sizes the whole viewport (the parent decides) and
1828        // realizes only on-screen rows. Hug-sizing it would mean
1829        // "shrink to fit all rows", defeating virtualization. Same
1830        // shape as the layout_override guard.
1831        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1832            panic!(
1833                "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1834                 Size::Hug would defeat virtualization",
1835                c.computed_id,
1836            );
1837        }
1838        return apply_min(c, 0.0, 0.0);
1839    }
1840    if matches!(c.kind, Kind::Inlines) {
1841        return inline_paragraph_intrinsic(c, available_width);
1842    }
1843    if matches!(c.kind, Kind::HardBreak) {
1844        // HardBreak is meaningful only inside Inlines (where draw_ops
1845        // encodes it as `\n` in the attributed text). Outside Inlines
1846        // it's a no-op layout-wise.
1847        return apply_min(c, 0.0, 0.0);
1848    }
1849    if matches!(c.kind, Kind::Math) {
1850        if let Some(expr) = &c.math {
1851            let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
1852            return apply_min(
1853                c,
1854                layout.width + c.padding.left + c.padding.right,
1855                layout.height() + c.padding.top + c.padding.bottom,
1856            );
1857        }
1858        return apply_min(c, 0.0, 0.0);
1859    }
1860    if c.icon.is_some() {
1861        return apply_min(
1862            c,
1863            c.font_size + c.padding.left + c.padding.right,
1864            c.font_size + c.padding.top + c.padding.bottom,
1865        );
1866    }
1867    if let Some(img) = &c.image {
1868        // Natural pixel size as a logical-pixel intrinsic. Authors who
1869        // want a different sized box set `.width()` / `.height()`;
1870        // the projection inside that box is decided by `image_fit`.
1871        let w = img.width() as f32 + c.padding.left + c.padding.right;
1872        let h = img.height() as f32 + c.padding.top + c.padding.bottom;
1873        return apply_min(c, w, h);
1874    }
1875    if let Some(text) = &c.text {
1876        let content_available = match c.text_wrap {
1877            TextWrap::NoWrap => None,
1878            TextWrap::Wrap => available_width
1879                .or(match c.width {
1880                    Size::Fixed(v) => Some(v),
1881                    Size::Fill(_) | Size::Hug => None,
1882                })
1883                .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1884        };
1885        let display = display_text_for_measure(c, text, content_available);
1886        let layout = text_metrics::layout_text_with_line_height_and_family(
1887            &display,
1888            c.font_size,
1889            c.line_height,
1890            c.font_family,
1891            c.font_weight,
1892            c.font_mono,
1893            c.text_wrap,
1894            content_available,
1895        );
1896        let w = match (content_available, c.width) {
1897            (Some(available), Size::Hug) => {
1898                let unwrapped = text_metrics::layout_text_with_family(
1899                    text,
1900                    c.font_size,
1901                    c.font_family,
1902                    c.font_weight,
1903                    c.font_mono,
1904                    TextWrap::NoWrap,
1905                    None,
1906                );
1907                unwrapped.width.min(available) + c.padding.left + c.padding.right
1908            }
1909            (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
1910                available + c.padding.left + c.padding.right
1911            }
1912            (None, _) => layout.width + c.padding.left + c.padding.right,
1913        };
1914        let h = layout.height + c.padding.top + c.padding.bottom;
1915        return apply_min(c, w, h);
1916    }
1917    match c.axis {
1918        Axis::Overlay => {
1919            let mut w: f32 = 0.0;
1920            let mut h: f32 = 0.0;
1921            for ch in &c.children {
1922                let child_available =
1923                    available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1924                let (cw, chh) = intrinsic_constrained(ch, child_available);
1925                w = w.max(cw);
1926                h = h.max(chh);
1927            }
1928            apply_min(
1929                c,
1930                w + c.padding.left + c.padding.right,
1931                h + c.padding.top + c.padding.bottom,
1932            )
1933        }
1934        Axis::Column => {
1935            let mut w: f32 = 0.0;
1936            let mut h: f32 = c.padding.top + c.padding.bottom;
1937            let n = c.children.len();
1938            let child_available =
1939                available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1940            for (i, ch) in c.children.iter().enumerate() {
1941                let (cw, chh) = intrinsic_constrained(ch, child_available);
1942                w = w.max(cw);
1943                h += chh;
1944                if i + 1 < n {
1945                    h += c.gap;
1946                }
1947            }
1948            apply_min(c, w + c.padding.left + c.padding.right, h)
1949        }
1950        Axis::Row => {
1951            // Two-pass measurement so that wrappable Fill children see
1952            // the width they will actually be laid out at. Without
1953            // this, a `Size::Fill` paragraph inside a row falls through
1954            // `inline_paragraph_intrinsic`'s `available_width` fallback
1955            // with `None` and reports its unwrapped single-line height
1956            // — the row then under-reserves vertical space and the
1957            // wrapped text overflows downward into the next row. This
1958            // mirrors how `layout_axis` (the runtime pass) already
1959            // splits Resolved vs. Fill main-axis sizing.
1960            let n = c.children.len();
1961            let total_gap = c.gap * n.saturating_sub(1) as f32;
1962            let inner_available = available_width
1963                .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
1964
1965            // First pass: Fixed and Hug children measure unconstrained.
1966            // Fixed-width wrappable children self-resolve their wrap
1967            // width via `inline_paragraph_intrinsic`'s own Fixed
1968            // fallback; Hug children take their natural width. We only
1969            // need to feed an explicit available width to Fill.
1970            let mut consumed: f32 = 0.0;
1971            let mut fill_weight_total: f32 = 0.0;
1972            let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1973            for ch in &c.children {
1974                match ch.width {
1975                    Size::Fill(w) => {
1976                        fill_weight_total += w.max(0.001);
1977                        sizes.push(None);
1978                    }
1979                    _ => {
1980                        let (cw, chh) = intrinsic(ch);
1981                        consumed += cw;
1982                        sizes.push(Some((cw, chh)));
1983                    }
1984                }
1985            }
1986
1987            // Second pass: distribute the leftover among Fill children
1988            // by weight and remeasure each with its share. Without an
1989            // available_width hint (row inside a Hug ancestor with no
1990            // outer constraint) we fall back to unconstrained
1991            // measurement — same lossy shape as the prior code, but
1992            // limited to the case where there's genuinely no width to
1993            // distribute.
1994            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
1995            let mut w_total: f32 = c.padding.left + c.padding.right;
1996            let mut h_max: f32 = 0.0;
1997            for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
1998                let (cw, chh) = match slot {
1999                    Some(rc) => rc,
2000                    None => match (fill_remaining, fill_weight_total > 0.0) {
2001                        (Some(av), true) => {
2002                            let weight = match ch.width {
2003                                Size::Fill(w) => w.max(0.001),
2004                                _ => 1.0,
2005                            };
2006                            intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2007                        }
2008                        _ => intrinsic(ch),
2009                    },
2010                };
2011                w_total += cw;
2012                if i + 1 < n {
2013                    w_total += c.gap;
2014                }
2015                h_max = h_max.max(chh);
2016            }
2017            apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2018        }
2019    }
2020}
2021
2022pub(crate) fn text_layout(
2023    c: &El,
2024    available_width: Option<f32>,
2025) -> Option<text_metrics::TextLayout> {
2026    let text = c.text.as_ref()?;
2027    let content_available = match c.text_wrap {
2028        TextWrap::NoWrap => None,
2029        TextWrap::Wrap => available_width
2030            .or(match c.width {
2031                Size::Fixed(v) => Some(v),
2032                Size::Fill(_) | Size::Hug => None,
2033            })
2034            .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2035    };
2036    let display = display_text_for_measure(c, text, content_available);
2037    Some(text_metrics::layout_text_with_line_height_and_family(
2038        &display,
2039        c.font_size,
2040        c.line_height,
2041        c.font_family,
2042        c.font_weight,
2043        c.font_mono,
2044        c.text_wrap,
2045        content_available,
2046    ))
2047}
2048
2049fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2050    if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2051        (c.text_wrap, c.text_max_lines, available_width)
2052    {
2053        text_metrics::clamp_text_to_lines_with_family(
2054            text,
2055            c.font_size,
2056            c.font_family,
2057            c.font_weight,
2058            c.font_mono,
2059            width,
2060            max_lines,
2061        )
2062    } else {
2063        text.to_string()
2064    }
2065}
2066
2067fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2068    if let Size::Fixed(v) = c.width {
2069        w = v;
2070    }
2071    if let Size::Fixed(v) = c.height {
2072        h = v;
2073    }
2074    (w, h)
2075}
2076
2077/// Approximate intrinsic measurement for `Kind::Inlines` paragraphs.
2078///
2079/// The paragraph paints through cosmic-text's rich-text shaping (which
2080/// resolves bold/italic/mono runs against fontdb), but layout needs a
2081/// width and height *before* we get to the renderer. We concatenate
2082/// the runs' text into one string and call `text_metrics::layout_text`
2083/// at the dominant font size — same approximation the lint pass uses
2084/// for single-style text. Bold/italic widths are slightly different
2085/// from regular; for body-text paragraphs that difference is well
2086/// under one wrap-line and we accept it. If a fixture wraps within
2087/// 1-2 characters of a boundary the rendered glyphs may straddle the
2088/// laid-out rect by a fraction of a glyph.
2089fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2090    if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2091        return inline_mixed_intrinsic(node, available_width);
2092    }
2093    let concat = concat_inline_text(&node.children);
2094    let size = inline_paragraph_size(node);
2095    let line_height = inline_paragraph_line_height(node);
2096    let content_available = match node.text_wrap {
2097        TextWrap::NoWrap => None,
2098        TextWrap::Wrap => available_width
2099            .or(match node.width {
2100                Size::Fixed(v) => Some(v),
2101                Size::Fill(_) | Size::Hug => None,
2102            })
2103            .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2104    };
2105    let layout = text_metrics::layout_text_with_line_height_and_family(
2106        &concat,
2107        size,
2108        line_height,
2109        node.font_family,
2110        FontWeight::Regular,
2111        false,
2112        node.text_wrap,
2113        content_available,
2114    );
2115    let w = match (content_available, node.width) {
2116        (Some(available), Size::Hug) => {
2117            let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2118                &concat,
2119                size,
2120                line_height,
2121                node.font_family,
2122                FontWeight::Regular,
2123                false,
2124                TextWrap::NoWrap,
2125                None,
2126            );
2127            unwrapped.width.min(available) + node.padding.left + node.padding.right
2128        }
2129        (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2130            available + node.padding.left + node.padding.right
2131        }
2132        (None, _) => layout.width + node.padding.left + node.padding.right,
2133    };
2134    let h = layout.height + node.padding.top + node.padding.bottom;
2135    apply_min(node, w, h)
2136}
2137
2138fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2139    let wrap_width = match node.text_wrap {
2140        TextWrap::Wrap => available_width.or(match node.width {
2141            Size::Fixed(v) => Some(v),
2142            Size::Fill(_) | Size::Hug => None,
2143        }),
2144        TextWrap::NoWrap => None,
2145    }
2146    .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2147
2148    let mut breaker = crate::inline_mixed::MixedInlineBreaker::new(
2149        node.text_wrap,
2150        wrap_width,
2151        node.font_size * 0.82,
2152        node.font_size * 0.22,
2153        node.line_height,
2154    );
2155
2156    for child in &node.children {
2157        match child.kind {
2158            Kind::HardBreak => {
2159                breaker.finish_line();
2160                continue;
2161            }
2162            Kind::Text => {
2163                let text = child.text.as_deref().unwrap_or("");
2164                for chunk in inline_text_chunks(text) {
2165                    let is_space = chunk.chars().all(char::is_whitespace);
2166                    if breaker.skips_leading_space(is_space) {
2167                        continue;
2168                    }
2169                    let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2170                    if breaker.wraps_before(is_space, w) {
2171                        breaker.finish_line();
2172                    }
2173                    if breaker.skips_overflowing_space(is_space, w) {
2174                        continue;
2175                    }
2176                    breaker.push(w, ascent, descent);
2177                }
2178                continue;
2179            }
2180            _ => {}
2181        }
2182        let (w, ascent, descent) = inline_child_metrics(child);
2183        if breaker.wraps_before(false, w) {
2184            breaker.finish_line();
2185        }
2186        breaker.push(w, ascent, descent);
2187    }
2188    let measurement = breaker.finish();
2189    let w = measurement.width + node.padding.left + node.padding.right;
2190    let h = measurement.height + node.padding.top + node.padding.bottom;
2191    apply_min(node, w, h)
2192}
2193
2194fn inline_text_chunks(text: &str) -> Vec<&str> {
2195    let mut chunks = Vec::new();
2196    let mut start = 0;
2197    let mut last_space = None;
2198    for (i, ch) in text.char_indices() {
2199        let is_space = ch.is_whitespace();
2200        match last_space {
2201            None => last_space = Some(is_space),
2202            Some(prev) if prev != is_space => {
2203                chunks.push(&text[start..i]);
2204                start = i;
2205                last_space = Some(is_space);
2206            }
2207            _ => {}
2208        }
2209    }
2210    if start < text.len() {
2211        chunks.push(&text[start..]);
2212    }
2213    chunks
2214}
2215
2216fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2217    let layout = text_metrics::layout_text_with_line_height_and_family(
2218        text,
2219        child.font_size,
2220        child.line_height,
2221        child.font_family,
2222        child.font_weight,
2223        child.font_mono,
2224        TextWrap::NoWrap,
2225        None,
2226    );
2227    (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2228}
2229
2230fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2231    match child.kind {
2232        Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2233        Kind::Math => {
2234            if let Some(expr) = &child.math {
2235                let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2236                (layout.width, layout.ascent, layout.descent)
2237            } else {
2238                (0.0, 0.0, 0.0)
2239            }
2240        }
2241        _ => (0.0, 0.0, 0.0),
2242    }
2243}
2244
2245/// Walk an Inlines paragraph's children and produce the source-order
2246/// concatenation that draw_ops will hand to the atlas. `Kind::Text`
2247/// contributes its `text` field; `Kind::HardBreak` contributes a
2248/// newline; anything else contributes nothing (an unsupported child
2249/// kind inside Inlines is a programmer error elsewhere — measurement
2250/// silently ignores it).
2251fn concat_inline_text(children: &[El]) -> String {
2252    let mut s = String::new();
2253    for c in children {
2254        match c.kind {
2255            Kind::Text => {
2256                if let Some(t) = &c.text {
2257                    s.push_str(t);
2258                }
2259            }
2260            Kind::HardBreak => s.push('\n'),
2261            _ => {}
2262        }
2263    }
2264    s
2265}
2266
2267/// Pick the font size that drives the paragraph's measurement. We use
2268/// the maximum across text children rather than the parent's own
2269/// `font_size`, because builders set sizes on the leaf text nodes.
2270fn inline_paragraph_size(node: &El) -> f32 {
2271    let mut size: f32 = node.font_size;
2272    for c in &node.children {
2273        if matches!(c.kind, Kind::Text) {
2274            size = size.max(c.font_size);
2275        }
2276    }
2277    size
2278}
2279
2280fn inline_paragraph_line_height(node: &El) -> f32 {
2281    let mut line_height: f32 = node.line_height;
2282    let mut max_size: f32 = node.font_size;
2283    for c in &node.children {
2284        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2285            max_size = c.font_size;
2286            line_height = c.line_height;
2287        }
2288    }
2289    line_height
2290}
2291
2292#[cfg(test)]
2293mod tests {
2294    use super::*;
2295    use crate::state::UiState;
2296
2297    /// CSS-flex parity: a `Size::Fill` child of a column with
2298    /// `align(Center)` should shrink to its intrinsic cross-axis size
2299    /// and be horizontally centered, matching `align-items: center`
2300    /// in CSS flex (which causes flex items to lose their stretch).
2301    #[test]
2302    fn align_center_shrinks_fill_child_to_intrinsic() {
2303        // Column with align(Center). Inner row has the default
2304        // El::new width = Fill(1.0); without Proposal B it would
2305        // claim the full 200px and align would be a no-op.
2306        let mut root = column([crate::row([crate::widgets::text::text("hi")
2307            .width(Size::Fixed(40.0))
2308            .height(Size::Fixed(20.0))])])
2309        .align(Align::Center)
2310        .width(Size::Fixed(200.0))
2311        .height(Size::Fixed(100.0));
2312        let mut state = UiState::new();
2313        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2314        let row_rect = state.rect(&root.children[0].computed_id);
2315        // Row's intrinsic width = 40 (single fixed child). 200 - 40 = 160
2316        // leftover; centered → row starts at x=80.
2317        assert!(
2318            (row_rect.x - 80.0).abs() < 0.5,
2319            "expected x≈80 (centered), got {}",
2320            row_rect.x
2321        );
2322        assert!(
2323            (row_rect.w - 40.0).abs() < 0.5,
2324            "expected w≈40 (shrunk to intrinsic), got {}",
2325            row_rect.w
2326        );
2327    }
2328
2329    /// `align(Stretch)` (the default) preserves Fill stretching: a
2330    /// Fill-width child still claims the full cross axis.
2331    #[test]
2332    fn align_stretch_preserves_fill_stretch() {
2333        let mut root = column([crate::row([crate::widgets::text::text("hi")
2334            .width(Size::Fixed(40.0))
2335            .height(Size::Fixed(20.0))])])
2336        .align(Align::Stretch)
2337        .width(Size::Fixed(200.0))
2338        .height(Size::Fixed(100.0));
2339        let mut state = UiState::new();
2340        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2341        let row_rect = state.rect(&root.children[0].computed_id);
2342        assert!(
2343            (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2344            "expected stretched (x=0, w=200), got x={} w={}",
2345            row_rect.x,
2346            row_rect.w
2347        );
2348    }
2349
2350    /// When all children are Hug-sized, `Justify::Center` should split
2351    /// the leftover space symmetrically across the main axis.
2352    #[test]
2353    fn justify_center_centers_hug_children() {
2354        let mut root = column([crate::widgets::text::text("hi")
2355            .width(Size::Fixed(40.0))
2356            .height(Size::Fixed(20.0))])
2357        .justify(Justify::Center)
2358        .height(Size::Fill(1.0));
2359        let mut state = UiState::new();
2360        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2361        let child_rect = state.rect(&root.children[0].computed_id);
2362        // Expected: 100 - 20 = 80 leftover; centered → starts at y=40.
2363        assert!(
2364            (child_rect.y - 40.0).abs() < 0.5,
2365            "expected y≈40, got {}",
2366            child_rect.y
2367        );
2368    }
2369
2370    #[test]
2371    fn justify_end_pushes_to_bottom() {
2372        let mut root = column([crate::widgets::text::text("hi")
2373            .width(Size::Fixed(40.0))
2374            .height(Size::Fixed(20.0))])
2375        .justify(Justify::End)
2376        .height(Size::Fill(1.0));
2377        let mut state = UiState::new();
2378        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2379        let child_rect = state.rect(&root.children[0].computed_id);
2380        assert!(
2381            (child_rect.y - 80.0).abs() < 0.5,
2382            "expected y≈80, got {}",
2383            child_rect.y
2384        );
2385    }
2386
2387    /// CSS `justify-content: space-between`: when no main-axis Fill
2388    /// children claim the slack, the leftover space is distributed
2389    /// evenly *between* (not around) the children — outer edges flush.
2390    #[test]
2391    fn justify_space_between_distributes_evenly() {
2392        let row_child = || {
2393            crate::widgets::text::text("x")
2394                .width(Size::Fixed(20.0))
2395                .height(Size::Fixed(20.0))
2396        };
2397        let mut root = column([row_child(), row_child(), row_child()])
2398            .justify(Justify::SpaceBetween)
2399            .height(Size::Fixed(200.0));
2400        let mut state = UiState::new();
2401        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2402        // Used main = 3 * 20 = 60. Leftover = 140 over (n-1) = 2 gaps
2403        // → 70 between. Positions: 0, 90, 180.
2404        let y0 = state.rect(&root.children[0].computed_id).y;
2405        let y1 = state.rect(&root.children[1].computed_id).y;
2406        let y2 = state.rect(&root.children[2].computed_id).y;
2407        assert!(
2408            y0.abs() < 0.5,
2409            "first child should be flush at y=0, got {y0}"
2410        );
2411        assert!(
2412            (y1 - 90.0).abs() < 0.5,
2413            "middle child should be at y≈90, got {y1}"
2414        );
2415        assert!(
2416            (y2 - 180.0).abs() < 0.5,
2417            "last child should be flush at y≈180, got {y2}"
2418        );
2419    }
2420
2421    /// CSS `flex: <weight>`: when multiple `Size::Fill` children share
2422    /// a container, the available space is distributed in proportion
2423    /// to their weights.
2424    #[test]
2425    fn fill_weight_distributes_proportionally() {
2426        let big = crate::widgets::text::text("big")
2427            .width(Size::Fixed(40.0))
2428            .height(Size::Fill(2.0));
2429        let small = crate::widgets::text::text("small")
2430            .width(Size::Fixed(40.0))
2431            .height(Size::Fill(1.0));
2432        let mut root = column([big, small]).height(Size::Fixed(300.0));
2433        let mut state = UiState::new();
2434        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2435        // Total weight = 3, available = 300. Big = 200, small = 100.
2436        let big_h = state.rect(&root.children[0].computed_id).h;
2437        let small_h = state.rect(&root.children[1].computed_id).h;
2438        assert!(
2439            (big_h - 200.0).abs() < 0.5,
2440            "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2441        );
2442        assert!(
2443            (small_h - 100.0).abs() < 0.5,
2444            "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2445        );
2446    }
2447
2448    /// `padding` on a `Hug`-sized container is included in the
2449    /// container's intrinsic — matching CSS `box-sizing: content-box`
2450    /// where padding adds to the rendered size.
2451    #[test]
2452    fn padding_on_hug_includes_in_intrinsic() {
2453        let root = column([crate::widgets::text::text("x")
2454            .width(Size::Fixed(40.0))
2455            .height(Size::Fixed(40.0))])
2456        .padding(Sides::all(20.0));
2457        let (w, h) = intrinsic(&root);
2458        // 40 content + 2*20 padding on each axis = 80.
2459        assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2460        assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2461    }
2462
2463    /// Cross-axis `Align::End` on a row pins children to the bottom
2464    /// edge — CSS `align-items: flex-end`. Mirror of `justify_end`
2465    /// but on the cross axis instead of the main axis.
2466    #[test]
2467    fn align_end_pins_to_cross_axis_far_edge() {
2468        let mut root = crate::row([crate::widgets::text::text("hi")
2469            .width(Size::Fixed(40.0))
2470            .height(Size::Fixed(20.0))])
2471        .align(Align::End)
2472        .width(Size::Fixed(200.0))
2473        .height(Size::Fixed(100.0));
2474        let mut state = UiState::new();
2475        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2476        let child_rect = state.rect(&root.children[0].computed_id);
2477        // Row cross axis = height. End → child y = 100 - 20 = 80.
2478        assert!(
2479            (child_rect.y - 80.0).abs() < 0.5,
2480            "expected y≈80 (pinned to bottom), got {}",
2481            child_rect.y
2482        );
2483    }
2484
2485    #[test]
2486    fn overlay_can_center_hug_child() {
2487        let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2488            .width(Size::Fixed(200.0))
2489            .height(Size::Hug)])
2490        .align(Align::Center)
2491        .justify(Justify::Center);
2492        let mut state = UiState::new();
2493        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2494        let child_rect = state.rect(&root.children[0].computed_id);
2495        assert!(
2496            (child_rect.x - 200.0).abs() < 0.5,
2497            "expected x≈200, got {}",
2498            child_rect.x
2499        );
2500        assert!(
2501            child_rect.y > 100.0 && child_rect.y < 200.0,
2502            "expected centered y, got {}",
2503            child_rect.y
2504        );
2505    }
2506
2507    #[test]
2508    fn scroll_offset_translates_children_and_clamps_to_content() {
2509        // Six 50px-tall rows in a 200px-tall scroll viewport.
2510        // Content height = 6 * 50 + 5 * 12 (gap) = 360 px. Visible
2511        // viewport (no padding) = 200 px → max_offset = 160.
2512        let mut root = scroll(
2513            (0..6)
2514                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2515        )
2516        .key("list")
2517        .gap(12.0)
2518        .height(Size::Fixed(200.0));
2519        let mut state = UiState::new();
2520        assign_ids(&mut root);
2521        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2522
2523        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2524
2525        // Offset is in range, applied verbatim.
2526        let stored = state
2527            .scroll
2528            .offsets
2529            .get(&root.computed_id)
2530            .copied()
2531            .unwrap_or(0.0);
2532        assert!(
2533            (stored - 80.0).abs() < 0.01,
2534            "offset clamped unexpectedly: {stored}"
2535        );
2536        // First child shifted up by 80.
2537        let c0 = state.rect(&root.children[0].computed_id);
2538        assert!(
2539            (c0.y - (-80.0)).abs() < 0.01,
2540            "child 0 y = {} (expected -80)",
2541            c0.y
2542        );
2543        // Now overshoot — should clamp to max_offset=160.
2544        state
2545            .scroll
2546            .offsets
2547            .insert(root.computed_id.clone(), 9999.0);
2548        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2549        let stored = state
2550            .scroll
2551            .offsets
2552            .get(&root.computed_id)
2553            .copied()
2554            .unwrap_or(0.0);
2555        assert!(
2556            (stored - 160.0).abs() < 0.01,
2557            "overshoot clamped to {stored}"
2558        );
2559        // Content fits → offset clamps to 0.
2560        let mut tiny =
2561            scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
2562                .height(Size::Fixed(200.0));
2563        let mut tiny_state = UiState::new();
2564        assign_ids(&mut tiny);
2565        tiny_state
2566            .scroll
2567            .offsets
2568            .insert(tiny.computed_id.clone(), 50.0);
2569        layout(
2570            &mut tiny,
2571            &mut tiny_state,
2572            Rect::new(0.0, 0.0, 300.0, 200.0),
2573        );
2574        assert_eq!(
2575            tiny_state
2576                .scroll
2577                .offsets
2578                .get(&tiny.computed_id)
2579                .copied()
2580                .unwrap_or(0.0),
2581            0.0
2582        );
2583    }
2584
2585    #[test]
2586    fn scroll_layout_prunes_far_offscreen_descendants() {
2587        let far = column([crate::widgets::text::text("far row body").key("far-text")])
2588            .height(Size::Fixed(40.0));
2589        let mut root = scroll([
2590            column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
2591            crate::tree::spacer().height(Size::Fixed(400.0)),
2592            far,
2593        ])
2594        .key("list")
2595        .height(Size::Fixed(80.0));
2596        let mut state = UiState::new();
2597        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
2598        let stats = take_prune_stats();
2599
2600        assert!(
2601            stats.subtrees >= 1,
2602            "expected at least one far scroll child to be pruned, got {stats:?}"
2603        );
2604        assert!(
2605            stats.nodes >= 1,
2606            "expected pruned descendants to be zeroed, got {stats:?}"
2607        );
2608        let far_text = state
2609            .rect_of_key(&root, "far-text")
2610            .expect("far text keeps a zero rect while pruned");
2611        assert_eq!(far_text.w, 0.0);
2612        assert_eq!(far_text.h, 0.0);
2613    }
2614
2615    #[test]
2616    fn scrollbar_thumb_size_and_position_track_overflow() {
2617        // 6 rows x 50px + 5 gaps x 12 = 360 content; 200 viewport.
2618        // viewport/content = 200/360 ≈ 0.555 → thumb_h ≈ 111.1.
2619        let mut root = scroll(
2620            (0..6)
2621                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2622        )
2623        .gap(12.0)
2624        .height(Size::Fixed(200.0));
2625        let mut state = UiState::new();
2626        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2627
2628        let metrics = state
2629            .scroll
2630            .metrics
2631            .get(&root.computed_id)
2632            .copied()
2633            .expect("scrollable should have metrics");
2634        assert!((metrics.viewport_h - 200.0).abs() < 0.01);
2635        assert!((metrics.content_h - 360.0).abs() < 0.01);
2636        assert!((metrics.max_offset - 160.0).abs() < 0.01);
2637
2638        let thumb = state
2639            .scroll
2640            .thumb_rects
2641            .get(&root.computed_id)
2642            .copied()
2643            .expect("scrollable with scrollbar() and overflow gets a thumb");
2644        // viewport^2 / content_h = 200^2 / 360 = 111.11..
2645        assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
2646        assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
2647        // At offset 0, thumb sits at the top of the inner rect.
2648        assert!(thumb.y.abs() < 0.01);
2649        // Right-anchored: thumb_x + thumb_w + track_inset == viewport_right.
2650        assert!(
2651            (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
2652            "thumb anchored at {} (expected {})",
2653            thumb.x,
2654            300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
2655        );
2656
2657        // Slide to half — thumb should be at half the track_remaining.
2658        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2659        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2660        let thumb = state
2661            .scroll
2662            .thumb_rects
2663            .get(&root.computed_id)
2664            .copied()
2665            .unwrap();
2666        let track_remaining = 200.0 - thumb.h;
2667        let expected_y = track_remaining * (80.0 / 160.0);
2668        assert!(
2669            (thumb.y - expected_y).abs() < 0.5,
2670            "thumb at half-scroll y = {} (expected {expected_y})",
2671            thumb.y,
2672        );
2673    }
2674
2675    #[test]
2676    fn scrollbar_track_is_wider_than_thumb_and_full_height() {
2677        // The track is the click hitbox: wider than the visible
2678        // thumb (Fitts's law) and tall enough to detect track
2679        // clicks above and below the thumb for paging.
2680        let mut root = scroll(
2681            (0..6)
2682                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2683        )
2684        .gap(12.0)
2685        .height(Size::Fixed(200.0));
2686        let mut state = UiState::new();
2687        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2688
2689        let thumb = state
2690            .scroll
2691            .thumb_rects
2692            .get(&root.computed_id)
2693            .copied()
2694            .unwrap();
2695        let track = state
2696            .scroll
2697            .thumb_tracks
2698            .get(&root.computed_id)
2699            .copied()
2700            .unwrap();
2701        // Track wider than thumb on the same right edge.
2702        assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
2703        assert!(
2704            (track.right() - thumb.right()).abs() < 0.01,
2705            "track and thumb must share the right edge",
2706        );
2707        // Track spans the full inner viewport (so above/below thumb
2708        // are both inside it for click-to-page).
2709        assert!(
2710            (track.h - 200.0).abs() < 0.01,
2711            "track height = {} (expected 200)",
2712            track.h,
2713        );
2714    }
2715
2716    #[test]
2717    fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
2718        // Same scrollable, but author opted out — no thumb_rect.
2719        let mut suppressed = scroll(
2720            (0..6)
2721                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2722        )
2723        .no_scrollbar()
2724        .height(Size::Fixed(200.0));
2725        let mut state = UiState::new();
2726        layout(
2727            &mut suppressed,
2728            &mut state,
2729            Rect::new(0.0, 0.0, 300.0, 200.0),
2730        );
2731        assert!(
2732            !state
2733                .scroll
2734                .thumb_rects
2735                .contains_key(&suppressed.computed_id)
2736        );
2737
2738        // Same scrollable, content fits → no thumb either.
2739        let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
2740            .height(Size::Fixed(200.0));
2741        let mut tiny_state = UiState::new();
2742        layout(
2743            &mut tiny,
2744            &mut tiny_state,
2745            Rect::new(0.0, 0.0, 300.0, 200.0),
2746        );
2747        assert!(
2748            !tiny_state
2749                .scroll
2750                .thumb_rects
2751                .contains_key(&tiny.computed_id)
2752        );
2753    }
2754
2755    #[test]
2756    fn layout_override_places_children_at_returned_rects() {
2757        // A custom layout that just stacks children diagonally inside the container.
2758        let mut root = column((0..3).map(|i| {
2759            crate::widgets::text::text(format!("dot {i}"))
2760                .width(Size::Fixed(20.0))
2761                .height(Size::Fixed(20.0))
2762        }))
2763        .width(Size::Fixed(200.0))
2764        .height(Size::Fixed(200.0))
2765        .layout(|ctx| {
2766            ctx.children
2767                .iter()
2768                .enumerate()
2769                .map(|(i, _)| {
2770                    let off = i as f32 * 30.0;
2771                    Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
2772                })
2773                .collect()
2774        });
2775        let mut state = UiState::new();
2776        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2777        let r0 = state.rect(&root.children[0].computed_id);
2778        let r1 = state.rect(&root.children[1].computed_id);
2779        let r2 = state.rect(&root.children[2].computed_id);
2780        assert_eq!((r0.x, r0.y), (0.0, 0.0));
2781        assert_eq!((r1.x, r1.y), (30.0, 30.0));
2782        assert_eq!((r2.x, r2.y), (60.0, 60.0));
2783    }
2784
2785    #[test]
2786    fn layout_override_rect_of_key_resolves_earlier_sibling() {
2787        // The popover-anchor pattern: a custom-laid-out node positions
2788        // its child by reading another keyed node's rect via the new
2789        // LayoutCtx::rect_of_key callback. The trigger lives in an
2790        // earlier sibling so its rect is already in `computed_rects`
2791        // by the time the popover layer's layout_override runs.
2792        use crate::tree::stack;
2793        let trigger_x = 40.0;
2794        let trigger_y = 20.0;
2795        let trigger_w = 60.0;
2796        let trigger_h = 30.0;
2797        let mut root = stack([
2798            // Earlier sibling: the trigger.
2799            crate::widgets::button::button("Open")
2800                .key("trig")
2801                .width(Size::Fixed(trigger_w))
2802                .height(Size::Fixed(trigger_h)),
2803            // Later sibling: a custom-laid-out container that reads
2804            // the trigger's rect to position its single child.
2805            stack([crate::widgets::text::text("popover")
2806                .width(Size::Fixed(80.0))
2807                .height(Size::Fixed(20.0))])
2808            .width(Size::Fill(1.0))
2809            .height(Size::Fill(1.0))
2810            .layout(|ctx| {
2811                let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
2812                vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
2813            }),
2814        ])
2815        .padding(Sides::xy(trigger_x, trigger_y));
2816        let mut state = UiState::new();
2817        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2818
2819        let popover_layer = &root.children[1];
2820        let panel_id = &popover_layer.children[0].computed_id;
2821        let panel_rect = state.rect(panel_id);
2822        // Anchored to (trigger.x, trigger.bottom() + 4.0). With padding
2823        // (40, 20) and trigger height 30 → expect (40, 54).
2824        assert!(
2825            (panel_rect.x - trigger_x).abs() < 0.01,
2826            "popover x = {} (expected {trigger_x})",
2827            panel_rect.x,
2828        );
2829        assert!(
2830            (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
2831            "popover y = {} (expected {})",
2832            panel_rect.y,
2833            trigger_y + trigger_h + 4.0,
2834        );
2835    }
2836
2837    #[test]
2838    fn layout_override_rect_of_key_returns_none_for_missing_key() {
2839        let mut root = column([crate::widgets::text::text("inner")
2840            .width(Size::Fixed(40.0))
2841            .height(Size::Fixed(20.0))])
2842        .width(Size::Fixed(200.0))
2843        .height(Size::Fixed(200.0))
2844        .layout(|ctx| {
2845            assert!((ctx.rect_of_key)("nope").is_none());
2846            vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2847        });
2848        let mut state = UiState::new();
2849        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2850    }
2851
2852    #[test]
2853    fn layout_override_rect_of_key_returns_none_for_later_sibling() {
2854        // First-frame contract: a custom layout running before its
2855        // target's sibling has been laid out should see `None`, not a
2856        // zero rect or a panic. This is what makes the popover pattern
2857        // (trigger first, popover layer second in source order) the
2858        // supported shape — the reverse direction simply gets `None`.
2859        use crate::tree::stack;
2860        let mut root = stack([
2861            stack([crate::widgets::text::text("panel")
2862                .width(Size::Fixed(40.0))
2863                .height(Size::Fixed(20.0))])
2864            .width(Size::Fill(1.0))
2865            .height(Size::Fill(1.0))
2866            .layout(|ctx| {
2867                assert!(
2868                    (ctx.rect_of_key)("later").is_none(),
2869                    "later sibling's rect must not be available yet"
2870                );
2871                vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2872            }),
2873            crate::widgets::button::button("after").key("later"),
2874        ]);
2875        let mut state = UiState::new();
2876        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2877    }
2878
2879    #[test]
2880    fn layout_override_measure_returns_intrinsic() {
2881        // The custom layout reads `measure` to size each child.
2882        let mut root = column([crate::widgets::text::text("hi")
2883            .width(Size::Fixed(40.0))
2884            .height(Size::Fixed(20.0))])
2885        .width(Size::Fixed(200.0))
2886        .height(Size::Fixed(200.0))
2887        .layout(|ctx| {
2888            let (w, h) = (ctx.measure)(&ctx.children[0]);
2889            assert!((w - 40.0).abs() < 0.01, "measured width {w}");
2890            assert!((h - 20.0).abs() < 0.01, "measured height {h}");
2891            vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
2892        });
2893        let mut state = UiState::new();
2894        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2895        let r = state.rect(&root.children[0].computed_id);
2896        assert_eq!((r.w, r.h), (40.0, 20.0));
2897    }
2898
2899    #[test]
2900    #[should_panic(expected = "returned 1 rects for 2 children")]
2901    fn layout_override_length_mismatch_panics() {
2902        let mut root = column([
2903            crate::widgets::text::text("a")
2904                .width(Size::Fixed(10.0))
2905                .height(Size::Fixed(10.0)),
2906            crate::widgets::text::text("b")
2907                .width(Size::Fixed(10.0))
2908                .height(Size::Fixed(10.0)),
2909        ])
2910        .width(Size::Fixed(200.0))
2911        .height(Size::Fixed(200.0))
2912        .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
2913        let mut state = UiState::new();
2914        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2915    }
2916
2917    #[test]
2918    #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
2919    fn layout_override_hug_panics() {
2920        // Hug check fires when the parent's layout pass measures the
2921        // custom-layout child for sizing — i.e. when a layout_override
2922        // node is a child of a column/row, not when it's the root.
2923        let mut root = column([column([crate::widgets::text::text("c")])
2924            .width(Size::Hug)
2925            .height(Size::Fixed(200.0))
2926            .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
2927        .width(Size::Fixed(200.0))
2928        .height(Size::Fixed(200.0));
2929        let mut state = UiState::new();
2930        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2931    }
2932
2933    #[test]
2934    fn virtual_list_realizes_only_visible_rows() {
2935        // 100 rows × 50px each in a 200px viewport, offset = 120.
2936        // Visible range: rows whose y in [-50, 200) → start = floor(120/50) = 2,
2937        // end = ceil((120+200)/50) = ceil(6.4) = 7. Five rows realized.
2938        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
2939            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2940        });
2941        let mut state = UiState::new();
2942        assign_ids(&mut root);
2943        state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
2944        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2945
2946        assert_eq!(
2947            root.children.len(),
2948            5,
2949            "expected 5 realized rows, got {}",
2950            root.children.len()
2951        );
2952        // Identity check: the first realized row should be the row keyed "row-2".
2953        assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
2954        assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
2955        // Position check: first realized row's y = inner.y + 2*50 - 120 = -20.
2956        let r0 = state.rect(&root.children[0].computed_id);
2957        assert!(
2958            (r0.y - (-20.0)).abs() < 0.5,
2959            "row 2 expected y≈-20, got {}",
2960            r0.y
2961        );
2962    }
2963
2964    #[test]
2965    fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
2966        let mut root = crate::tree::virtual_list(10, 40.0, |i| {
2967            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2968        })
2969        .gap(10.0);
2970        let mut state = UiState::new();
2971        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2972
2973        assert_eq!(
2974            root.children.len(),
2975            3,
2976            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
2977        );
2978        let row_1 = root
2979            .children
2980            .iter()
2981            .find(|c| c.key.as_deref() == Some("row-1"))
2982            .expect("row 1 should be realized");
2983        assert!(
2984            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
2985            "gap should place row 1 at y=50"
2986        );
2987        let metrics = state
2988            .scroll
2989            .metrics
2990            .get(&root.computed_id)
2991            .expect("virtual list writes scroll metrics");
2992        assert!(
2993            (metrics.content_h - 490.0).abs() < 0.5,
2994            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
2995            metrics.content_h
2996        );
2997    }
2998
2999    #[test]
3000    fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3001        let make_root = || {
3002            crate::tree::virtual_list(50, 50.0, |i| {
3003                crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3004            })
3005        };
3006
3007        let mut state = UiState::new();
3008        let mut root_a = make_root();
3009        assign_ids(&mut root_a);
3010        // Scroll so row 5 is visible.
3011        state
3012            .scroll
3013            .offsets
3014            .insert(root_a.computed_id.clone(), 250.0);
3015        layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3016        let id_at_offset_a = root_a
3017            .children
3018            .iter()
3019            .find(|c| c.key.as_deref() == Some("row-5"))
3020            .unwrap()
3021            .computed_id
3022            .clone();
3023
3024        // Re-layout with a different offset — row 5 is still visible.
3025        let mut root_b = make_root();
3026        assign_ids(&mut root_b);
3027        state
3028            .scroll
3029            .offsets
3030            .insert(root_b.computed_id.clone(), 200.0);
3031        layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3032        let id_at_offset_b = root_b
3033            .children
3034            .iter()
3035            .find(|c| c.key.as_deref() == Some("row-5"))
3036            .unwrap()
3037            .computed_id
3038            .clone();
3039
3040        assert_eq!(
3041            id_at_offset_a, id_at_offset_b,
3042            "row-5's computed_id changed when scroll offset moved"
3043        );
3044    }
3045
3046    #[test]
3047    fn virtual_list_clamps_overshoot_offset() {
3048        // 10 rows × 50 = 500 content height; viewport 200; max offset = 300.
3049        let mut root =
3050            crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3051        let mut state = UiState::new();
3052        assign_ids(&mut root);
3053        state
3054            .scroll
3055            .offsets
3056            .insert(root.computed_id.clone(), 9999.0);
3057        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3058        let stored = state
3059            .scroll
3060            .offsets
3061            .get(&root.computed_id)
3062            .copied()
3063            .unwrap_or(0.0);
3064        assert!(
3065            (stored - 300.0).abs() < 0.01,
3066            "expected clamp to 300, got {stored}"
3067        );
3068    }
3069
3070    #[test]
3071    fn virtual_list_empty_count_realizes_no_children() {
3072        let mut root =
3073            crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3074        let mut state = UiState::new();
3075        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3076        assert_eq!(root.children.len(), 0);
3077    }
3078
3079    #[test]
3080    #[should_panic(expected = "row_height > 0.0")]
3081    fn virtual_list_zero_row_height_panics() {
3082        let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3083    }
3084
3085    #[test]
3086    #[should_panic(expected = "Size::Hug would defeat virtualization")]
3087    fn virtual_list_hug_panics() {
3088        let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3089            crate::widgets::text::text(format!("r{i}"))
3090        })
3091        .height(Size::Hug)])
3092        .width(Size::Fixed(300.0))
3093        .height(Size::Fixed(200.0));
3094        let mut state = UiState::new();
3095        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3096    }
3097
3098    #[test]
3099    fn virtual_list_dyn_respects_per_row_fixed_heights() {
3100        // Alternating 40px / 80px rows. With a 200px viewport and offset 0,
3101        // accumulated y goes 0, 40, 120, 160, 240 — the fifth row starts
3102        // past the viewport, so four rows are realized.
3103        let mut root = crate::tree::virtual_list_dyn(
3104            20,
3105            50.0,
3106            |i| format!("row-{i}"),
3107            |i| {
3108                let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3109                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3110                    .key(format!("row-{i}"))
3111                    .height(Size::Fixed(h))
3112            },
3113        );
3114        let mut state = UiState::new();
3115        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3116
3117        assert_eq!(
3118            root.children.len(),
3119            4,
3120            "expected 4 realized rows, got {}",
3121            root.children.len()
3122        );
3123        // y positions: row 0 → 0, row 1 → 40, row 2 → 120, row 3 → 160.
3124        let ys: Vec<f32> = root
3125            .children
3126            .iter()
3127            .map(|c| state.rect(&c.computed_id).y)
3128            .collect();
3129        assert!(
3130            (ys[0] - 0.0).abs() < 0.5,
3131            "row 0 expected y≈0, got {}",
3132            ys[0]
3133        );
3134        assert!(
3135            (ys[1] - 40.0).abs() < 0.5,
3136            "row 1 expected y≈40, got {}",
3137            ys[1]
3138        );
3139        assert!(
3140            (ys[2] - 120.0).abs() < 0.5,
3141            "row 2 expected y≈120, got {}",
3142            ys[2]
3143        );
3144        assert!(
3145            (ys[3] - 160.0).abs() < 0.5,
3146            "row 3 expected y≈160, got {}",
3147            ys[3]
3148        );
3149    }
3150
3151    #[test]
3152    fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3153        let mut root = crate::tree::virtual_list_dyn(
3154            10,
3155            40.0,
3156            |i| format!("row-{i}"),
3157            |i| {
3158                crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3159                    .key(format!("row-{i}"))
3160                    .height(Size::Fixed(40.0))
3161            },
3162        )
3163        .gap(10.0);
3164        let mut state = UiState::new();
3165        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3166
3167        assert_eq!(
3168            root.children.len(),
3169            3,
3170            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3171        );
3172        let row_1 = root
3173            .children
3174            .iter()
3175            .find(|c| c.key.as_deref() == Some("row-1"))
3176            .expect("row 1 should be realized");
3177        assert!(
3178            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3179            "gap should place row 1 at y=50"
3180        );
3181        let metrics = state
3182            .scroll
3183            .metrics
3184            .get(&root.computed_id)
3185            .expect("virtual list writes scroll metrics");
3186        assert!(
3187            (metrics.content_h - 490.0).abs() < 0.5,
3188            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3189            metrics.content_h
3190        );
3191    }
3192
3193    #[test]
3194    fn virtual_list_dyn_caches_measured_heights() {
3195        // Build a list where the first frame realizes rows 0..k, measuring
3196        // each. After layout the cache should hold those measurements and
3197        // the next frame should read them.
3198        let mut root = crate::tree::virtual_list_dyn(
3199            50,
3200            50.0,
3201            |i| format!("row-{i}"),
3202            |i| {
3203                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3204                    .key(format!("row-{i}"))
3205                    .height(Size::Fixed(30.0))
3206            },
3207        );
3208        let mut state = UiState::new();
3209        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3210
3211        let measured = state
3212            .scroll
3213            .measured_row_heights
3214            .get(&root.computed_id)
3215            .expect("dynamic virtual list should populate the height cache");
3216        // The first pass measures the estimate-derived window, then
3217        // the anchored final pass can extend it with newly revealed
3218        // rows. At least six rows are visible/cached here.
3219        assert!(
3220            measured.len() >= 6,
3221            "expected ≥ 6 cached row heights, got {}",
3222            measured.len()
3223        );
3224        for by_width in measured.values() {
3225            let h = by_width
3226                .get(&300)
3227                .copied()
3228                .expect("measurement should be keyed at the 300px width bucket");
3229            assert!(
3230                (h - 30.0).abs() < 0.5,
3231                "expected cached height ≈ 30, got {h}"
3232            );
3233        }
3234    }
3235
3236    #[test]
3237    fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3238        let make_root = || {
3239            crate::tree::virtual_list_dyn(
3240                100,
3241                40.0,
3242                |i| format!("row-{i}"),
3243                |i| {
3244                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3245                        .key(format!("row-{i}"))
3246                        .height(Size::Fixed(40.0))
3247                },
3248            )
3249        };
3250        let mut root = make_root();
3251        let mut state = UiState::new();
3252        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3253
3254        state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3255        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3256
3257        let anchor = state
3258            .scroll
3259            .virtual_anchors
3260            .get(&root.computed_id)
3261            .cloned()
3262            .expect("dynamic list should store a visible anchor");
3263        let before_y = root
3264            .children
3265            .iter()
3266            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3267            .map(|child| state.rect(&child.computed_id).y)
3268            .expect("anchor row should be realized");
3269        let before_offset = state.scroll_offset(&root.computed_id);
3270
3271        state
3272            .scroll
3273            .measured_row_heights
3274            .entry(root.computed_id.clone())
3275            .or_default()
3276            .entry("row-0".to_string())
3277            .or_default()
3278            .insert(300, 120.0);
3279
3280        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3281        let after_y = root
3282            .children
3283            .iter()
3284            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3285            .map(|child| state.rect(&child.computed_id).y)
3286            .expect("anchor row should remain realized");
3287        let after_offset = state.scroll_offset(&root.computed_id);
3288
3289        assert!(
3290            (after_y - before_y).abs() < 0.5,
3291            "anchor row should stay at y={before_y}, got {after_y}"
3292        );
3293        assert!(
3294            (after_offset - (before_offset + 80.0)).abs() < 0.5,
3295            "offset should absorb the 80px measurement delta above anchor"
3296        );
3297    }
3298
3299    #[test]
3300    fn virtual_list_dyn_height_cache_is_width_bucketed() {
3301        let mut root = crate::tree::virtual_list_dyn(
3302            20,
3303            50.0,
3304            |i| format!("row-{i}"),
3305            |i| {
3306                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3307                    .key(format!("row-{i}"))
3308                    .height(Size::Fixed(30.0))
3309            },
3310        );
3311        let mut state = UiState::new();
3312        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3313        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3314
3315        let row_0 = state
3316            .scroll
3317            .measured_row_heights
3318            .get(&root.computed_id)
3319            .and_then(|m| m.get("row-0"))
3320            .expect("row 0 should be measured");
3321        assert!(
3322            row_0.contains_key(&300) && row_0.contains_key(&240),
3323            "expected width buckets 300 and 240, got {:?}",
3324            row_0.keys().collect::<Vec<_>>()
3325        );
3326    }
3327
3328    #[test]
3329    fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3330        // Measured rows use their cached fixed 30px height; rows that
3331        // have not been seen at this width still use the 50px estimate.
3332        // An overshoot offset must clamp to the mixed measured/estimated
3333        // content height after the final visible measurements land.
3334        let make_root = || {
3335            crate::tree::virtual_list_dyn(
3336                20,
3337                50.0,
3338                |i| format!("row-{i}"),
3339                |i| {
3340                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3341                        .key(format!("row-{i}"))
3342                        .height(Size::Fixed(30.0))
3343                },
3344            )
3345        };
3346        let mut state = UiState::new();
3347        let mut root = make_root();
3348        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3349
3350        state
3351            .scroll
3352            .offsets
3353            .insert(root.computed_id.clone(), 9999.0);
3354        let mut root2 = make_root();
3355        layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3356
3357        let measured = state
3358            .scroll
3359            .measured_row_heights
3360            .get(&root2.computed_id)
3361            .expect("dynamic virtual list should populate the height cache");
3362        let measured_sum = measured
3363            .values()
3364            .filter_map(|by_width| by_width.get(&300))
3365            .sum::<f32>();
3366        let measured_count = measured
3367            .values()
3368            .filter(|by_width| by_width.contains_key(&300))
3369            .count();
3370        let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3371        let expected_max_offset = expected_total - 200.0;
3372
3373        let stored = state
3374            .scroll
3375            .offsets
3376            .get(&root2.computed_id)
3377            .copied()
3378            .unwrap_or(0.0);
3379        assert!(
3380            (stored - expected_max_offset).abs() < 0.5,
3381            "expected offset clamped to {expected_max_offset}, got {stored}"
3382        );
3383    }
3384
3385    #[test]
3386    fn virtual_list_dyn_empty_count_realizes_no_children() {
3387        let mut root = crate::tree::virtual_list_dyn(
3388            0,
3389            50.0,
3390            |i| format!("row-{i}"),
3391            |i| crate::widgets::text::text(format!("r{i}")),
3392        );
3393        let mut state = UiState::new();
3394        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3395        assert_eq!(root.children.len(), 0);
3396    }
3397
3398    #[test]
3399    #[should_panic(expected = "estimated_row_height > 0.0")]
3400    fn virtual_list_dyn_zero_estimate_panics() {
3401        let _ = crate::tree::virtual_list_dyn(
3402            10,
3403            0.0,
3404            |i| format!("row-{i}"),
3405            |i| crate::widgets::text::text(format!("r{i}")),
3406        );
3407    }
3408
3409    #[test]
3410    fn text_runs_constructor_shape_smoke() {
3411        let el = crate::tree::text_runs([
3412            crate::widgets::text::text("Hello, "),
3413            crate::widgets::text::text("world").bold(),
3414            crate::tree::hard_break(),
3415            crate::widgets::text::text("of text").italic(),
3416        ]);
3417        assert_eq!(el.kind, Kind::Inlines);
3418        assert_eq!(el.children.len(), 4);
3419        assert!(matches!(
3420            el.children[1].font_weight,
3421            FontWeight::Bold | FontWeight::Semibold
3422        ));
3423        assert_eq!(el.children[2].kind, Kind::HardBreak);
3424        assert!(el.children[3].text_italic);
3425    }
3426
3427    #[test]
3428    fn wrapped_text_hugs_multiline_height_from_available_width() {
3429        let mut root = column([crate::paragraph(
3430            "A longer sentence should wrap into multiple measured lines.",
3431        )])
3432        .width(Size::Fill(1.0))
3433        .height(Size::Hug);
3434
3435        let mut state = UiState::new();
3436        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
3437
3438        let child_rect = state.rect(&root.children[0].computed_id);
3439        assert_eq!(child_rect.w, 180.0);
3440        assert!(
3441            child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3442            "expected multiline paragraph height, got {}",
3443            child_rect.h
3444        );
3445    }
3446
3447    #[test]
3448    fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
3449        // Regression: overlay_rect used to call `intrinsic(c)` with no
3450        // width hint, so a Fixed-width modal containing a wrappable
3451        // paragraph measured the paragraph as a single line — leaving
3452        // the modal's Hug height short by the wrapped lines and
3453        // crowding the buttons against the bottom edge of the panel
3454        // (rumble cert-pending modal showed this).
3455        //
3456        // The fix: pass the child's resolved width as the available
3457        // width for intrinsic measurement, mirroring what column/row
3458        // already do.
3459        const PANEL_W: f32 = 240.0;
3460        const PADDING: f32 = 18.0;
3461        const GAP: f32 = 12.0;
3462
3463        let panel = column([
3464            crate::paragraph(
3465                "A long enough warning paragraph that it has to wrap onto a second line \
3466                 inside this narrow panel.",
3467            ),
3468            crate::widgets::button::button("OK").key("ok"),
3469        ])
3470        .width(Size::Fixed(PANEL_W))
3471        .height(Size::Hug)
3472        .padding(Sides::all(PADDING))
3473        .gap(GAP)
3474        .align(Align::Stretch);
3475
3476        let mut root = crate::stack([panel])
3477            .width(Size::Fill(1.0))
3478            .height(Size::Fill(1.0));
3479        let mut state = UiState::new();
3480        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3481
3482        let panel_rect = state.rect(&root.children[0].computed_id);
3483        assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
3484
3485        let para_rect = state.rect(&root.children[0].children[0].computed_id);
3486        let button_rect = state.rect(&root.children[0].children[1].computed_id);
3487
3488        // Paragraph wrapped to ≥ 2 lines (exact line count depends on
3489        // glyph metrics; just guard against the single-line bug).
3490        assert!(
3491            para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3492            "paragraph should wrap to multiple lines inside the Fixed-width panel; \
3493             got h={}",
3494            para_rect.h
3495        );
3496
3497        // Panel height must accommodate top padding + paragraph +
3498        // gap + button + bottom padding. The bug was that the panel
3499        // came out exactly `padding + gap + 1-line-paragraph + button`
3500        // — short by the second wrap line — and the button overshot
3501        // the inner area, leaving zero pixels of bottom padding.
3502        let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
3503        assert!(
3504            (bottom_padding - PADDING).abs() < 0.5,
3505            "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
3506        );
3507    }
3508
3509    #[test]
3510    fn row_with_fill_paragraph_propagates_height_to_parent_column() {
3511        // Regression: the Row branch of `intrinsic_constrained` called
3512        // `intrinsic(ch)` unconstrained, so a wrappable Fill child
3513        // (paragraph) measured as a single unwrapped line. Two such rows
3514        // in a column then got one-line-tall allocations and the second
3515        // row's gutter rect overlapped the first row's wrapped text
3516        // (chat-port event-log recipe in aetna-core/README.md hit this).
3517        //
3518        // The fix mirrors `layout_axis`: the Row intrinsic distributes
3519        // its available width across Fill children before measuring,
3520        // so wrappable Fill children see the width they will actually
3521        // be laid out at.
3522        const COL_W: f32 = 600.0;
3523        const GUTTER_W: f32 = 3.0;
3524
3525        let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
3526                    sed do eiusmod tempor incididunt ut labore et dolore magna \
3527                    aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
3528                    ullamco laboris nisi ut aliquip ex ea commodo consequat.";
3529
3530        let make_row = || {
3531            let gutter = El::new(Kind::Custom("gutter"))
3532                .width(Size::Fixed(GUTTER_W))
3533                .height(Size::Fill(1.0));
3534            let body = crate::paragraph(long).width(Size::Fill(1.0));
3535            crate::row([gutter, body]).width(Size::Fill(1.0))
3536        };
3537
3538        let mut root = column([make_row(), make_row()])
3539            .width(Size::Fixed(COL_W))
3540            .height(Size::Hug)
3541            .align(Align::Stretch);
3542        let mut state = UiState::new();
3543        layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
3544
3545        let row0_rect = state.rect(&root.children[0].computed_id);
3546        let row1_rect = state.rect(&root.children[1].computed_id);
3547        let para0_rect = state.rect(&root.children[0].children[1].computed_id);
3548
3549        // Both the paragraph rect and the row rect must reflect the
3550        // wrapped (multi-line) height. The bug pinned them to a single
3551        // line (~`TEXT_SM.line_height` = 20px), so the wrapped text
3552        // painted outside the row's allocated rect.
3553        let line_height = crate::tokens::TEXT_SM.line_height;
3554        assert!(
3555            para0_rect.h > line_height * 1.5,
3556            "paragraph should wrap to multiple lines at ~597px wide; \
3557             got h={} (line_height={})",
3558            para0_rect.h,
3559            line_height,
3560        );
3561        assert!(
3562            row0_rect.h > line_height * 1.5,
3563            "row 0 should accommodate the wrapped paragraph height; \
3564             got h={} (line_height={})",
3565            row0_rect.h,
3566            line_height,
3567        );
3568
3569        // Sanity: row 1 sits below row 0's allocated rect, not above it.
3570        assert!(
3571            row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
3572            "row 1 starts at y={} but row 0 occupies y={}..{}",
3573            row1_rect.y,
3574            row0_rect.y,
3575            row0_rect.y + row0_rect.h,
3576        );
3577    }
3578}