Skip to main content

damascene_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::{ScrollAnchor, 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 Damascene 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::Scene3D => "scene3d",
430        Kind::Custom(name) => name,
431    }
432}
433
434fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
435    if matches!(node.kind, Kind::Inlines) {
436        // The paragraph paints as a single AttributedText DrawOp;
437        // child Text/HardBreak nodes are aggregated by draw_ops::
438        // push_node and don't paint independently. Give each child a
439        // zero-size rect so the rest of the engine (hit-test, focus,
440        // animation, lint) treats them as non-paint pseudo-nodes. The
441        // paragraph's hit-test target is the Inlines node itself,
442        // sized by node_rect.
443        for c in &mut node.children {
444            ui_state.layout.computed_rects.insert(
445                c.computed_id.clone(),
446                Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
447            );
448            // Recurse so descendants of Text/HardBreak nodes (rare —
449            // these are leaves in practice — but keeping the invariant
450            // simple) still get their rects assigned.
451            layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
452        }
453        return;
454    }
455    if let Some(items) = node.virtual_items.clone() {
456        layout_virtual(node, node_rect, items, ui_state);
457        return;
458    }
459    if let Some(layout_fn) = node.layout_override.clone() {
460        layout_custom(node, node_rect, layout_fn, ui_state);
461        if node.scrollable {
462            apply_scroll_offset(node, node_rect, ui_state);
463        }
464        return;
465    }
466    match node.axis {
467        Axis::Overlay => {
468            let inner = node_rect.inset(node.padding);
469            for c in &mut node.children {
470                let c_rect = overlay_rect(c, inner, node.align, node.justify);
471                ui_state
472                    .layout
473                    .computed_rects
474                    .insert(c.computed_id.clone(), c_rect);
475                layout_children(c, c_rect, ui_state);
476            }
477        }
478        Axis::Column => layout_axis(node, node_rect, true, ui_state),
479        Axis::Row => layout_axis(node, node_rect, false, ui_state),
480    }
481    if node.scrollable {
482        apply_scroll_offset(node, node_rect, ui_state);
483    }
484}
485
486fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
487    let inner = node_rect.inset(node.padding);
488    let measure = |c: &El| intrinsic(c);
489    // Split-borrow `ui_state` so the `rect_of_key` closure reads the
490    // key index + computed rects while the surrounding function still
491    // holds the mutable borrow needed to insert this node's children
492    // back into `computed_rects` afterwards.
493    let key_index = &ui_state.layout.key_index;
494    let computed_rects = &ui_state.layout.computed_rects;
495    let rect_of_key = |key: &str| -> Option<Rect> {
496        let id = key_index.get(key)?;
497        computed_rects.get(id).copied()
498    };
499    let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
500    let rects = (layout_fn.0)(LayoutCtx {
501        container: inner,
502        children: &node.children,
503        measure: &measure,
504        rect_of_key: &rect_of_key,
505        rect_of_id: &rect_of_id,
506    });
507    assert_eq!(
508        rects.len(),
509        node.children.len(),
510        "LayoutFn for {:?} returned {} rects for {} children",
511        node.computed_id,
512        rects.len(),
513        node.children.len(),
514    );
515    for (c, c_rect) in node.children.iter_mut().zip(rects) {
516        ui_state
517            .layout
518            .computed_rects
519            .insert(c.computed_id.clone(), c_rect);
520        layout_children(c, c_rect, ui_state);
521    }
522}
523
524/// Virtualized list realization. Dispatches by [`VirtualMode`] —
525/// `Fixed` uses an O(1) division to find the visible range; `Dynamic`
526/// walks measured-or-estimated heights, measures each visible row's
527/// natural intrinsic height, and writes the result back to the height
528/// cache on `UiState` so subsequent frames have it available.
529fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
530    let inner = node_rect.inset(node.padding);
531    match items.mode {
532        VirtualMode::Fixed { row_height } => layout_virtual_fixed(
533            node,
534            inner,
535            items.count,
536            row_height,
537            items.build_row,
538            ui_state,
539        ),
540        VirtualMode::Dynamic {
541            estimated_row_height,
542        } => layout_virtual_dynamic(
543            node,
544            inner,
545            items.count,
546            estimated_row_height,
547            DynamicVirtualFns {
548                anchor_policy: items.anchor_policy,
549                row_key: items.row_key,
550                build_row: items.build_row,
551            },
552            ui_state,
553        ),
554    }
555}
556
557/// Consume any pending [`ScrollRequest`]s targeting this list's `key`,
558/// resolving each into a target offset using the live viewport rect and
559/// the caller-supplied row-extent function. Writes the resolved offset
560/// directly into `scroll.offsets`; the immediately-following
561/// `write_virtual_scroll_state` call clamps it to `[0, max_offset]`.
562///
563/// Requests for other lists are left in the queue for sibling lists in
564/// the same layout pass. Anything still queued after layout completes is
565/// dropped by the runtime (see `prepare_layout`).
566fn resolve_scroll_requests<F, K>(
567    node: &El,
568    inner: Rect,
569    count: usize,
570    row_extent: F,
571    row_for_key: K,
572    ui_state: &mut UiState,
573) -> bool
574where
575    F: Fn(usize) -> (f32, f32),
576    K: Fn(&str) -> Option<usize>,
577{
578    if ui_state.scroll.pending_requests.is_empty() {
579        return false;
580    }
581    let Some(key) = node.key.as_deref() else {
582        return false;
583    };
584    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
585    let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
586        pending.into_iter().partition(|req| match req {
587            ScrollRequest::ToRow { list_key, .. } => list_key == key,
588            ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
589            // EnsureVisible isn't a virtual-list-row request; let the
590            // non-virtual scroll resolver pick it up downstream.
591            ScrollRequest::EnsureVisible { .. } => false,
592        });
593    ui_state.scroll.pending_requests = remaining;
594
595    let mut wrote = false;
596    for req in matched {
597        let (row, align) = match req {
598            ScrollRequest::ToRow { row, align, .. } => (row, align),
599            ScrollRequest::ToRowKey { row_key, align, .. } => {
600                let Some(row) = row_for_key(&row_key) else {
601                    continue;
602                };
603                (row, align)
604            }
605            ScrollRequest::EnsureVisible { .. } => continue,
606        };
607        if row >= count {
608            continue;
609        }
610        let (row_top, row_h) = row_extent(row);
611        let row_bottom = row_top + row_h;
612        let viewport_h = inner.h;
613        let current = ui_state
614            .scroll
615            .offsets
616            .get(&node.computed_id)
617            .copied()
618            .unwrap_or(0.0);
619        let new_offset = match align {
620            ScrollAlignment::Start => row_top,
621            ScrollAlignment::End => row_bottom - viewport_h,
622            ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
623            ScrollAlignment::Visible => {
624                if row_top < current {
625                    row_top
626                } else if row_bottom > current + viewport_h {
627                    row_bottom - viewport_h
628                } else {
629                    continue;
630                }
631            }
632        };
633        ui_state
634            .scroll
635            .offsets
636            .insert(node.computed_id.clone(), new_offset);
637        wrote = true;
638    }
639    wrote
640}
641
642/// Clamp the stored scroll offset, write the metrics + thumb rect, and
643/// return the clamped offset. Shared scaffold for both virtual modes.
644fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
645    let max_offset = (total_h - inner.h).max(0.0);
646    let stored = ui_state
647        .scroll
648        .offsets
649        .get(&node.computed_id)
650        .copied()
651        .unwrap_or(0.0);
652    let stored = resolve_pin(node, stored, max_offset, ui_state);
653    let offset = stored.clamp(0.0, max_offset);
654    ui_state
655        .scroll
656        .offsets
657        .insert(node.computed_id.clone(), offset);
658    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
659    offset
660}
661
662fn write_virtual_scroll_metrics(
663    node: &El,
664    inner: Rect,
665    total_h: f32,
666    max_offset: f32,
667    offset: f32,
668    ui_state: &mut UiState,
669) {
670    ui_state.scroll.metrics.insert(
671        node.computed_id.clone(),
672        crate::state::ScrollMetrics {
673            viewport_h: inner.h,
674            content_h: total_h,
675            max_offset,
676        },
677    );
678    write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
679}
680
681/// Assign the realized row a path-style `computed_id` matching the
682/// regular tree's role/key/index convention so hit-test, focus, and
683/// state lookups remain stable across scrolls.
684fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
685    let role = role_token(&child.kind);
686    let suffix = match (&child.key, role) {
687        (Some(k), r) => format!("{r}[{k}]"),
688        (None, r) => format!("{r}.{global_i}"),
689    };
690    assign_id(child, &format!("{parent_id}.{suffix}"));
691}
692
693fn layout_virtual_fixed(
694    node: &mut El,
695    inner: Rect,
696    count: usize,
697    row_height: f32,
698    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
699    ui_state: &mut UiState,
700) {
701    let gap = node.gap.max(0.0);
702    let pitch = row_height + gap;
703    let total_h = virtual_total_height(count, count as f32 * row_height, gap);
704    resolve_scroll_requests(
705        node,
706        inner,
707        count,
708        |i| (i as f32 * pitch, row_height),
709        |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
710        ui_state,
711    );
712    let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
713
714    if count == 0 {
715        node.children.clear();
716        return;
717    }
718
719    // Visible index range — `start` floors, `end` ceils, both clamped.
720    // Include one extra candidate because a large gap can make the
721    // pitch-based ceil land on the gap before the next visible row.
722    let start = (offset / pitch).floor() as usize;
723    let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
724
725    let mut realized: Vec<El> = Vec::new();
726    for global_i in start..end {
727        let row_top = global_i as f32 * pitch;
728        if row_top >= offset + inner.h || row_top + row_height <= offset {
729            continue;
730        }
731        let mut child = (build_row)(global_i);
732        assign_virtual_row_id(&mut child, &node.computed_id, global_i);
733
734        let row_y = inner.y + row_top - offset;
735        let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
736        ui_state
737            .layout
738            .computed_rects
739            .insert(child.computed_id.clone(), c_rect);
740        layout_children(&mut child, c_rect, ui_state);
741        realized.push(child);
742    }
743    node.children = realized;
744}
745
746fn layout_virtual_dynamic(
747    node: &mut El,
748    inner: Rect,
749    count: usize,
750    estimated_row_height: f32,
751    fns: DynamicVirtualFns,
752    ui_state: &mut UiState,
753) {
754    let gap = node.gap.max(0.0);
755    let width_bucket = virtual_width_bucket(inner.w);
756    let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
757    prune_dynamic_measurements(node, &row_keys, ui_state);
758
759    if count == 0 {
760        ui_state.scroll.virtual_anchors.remove(&node.computed_id);
761        let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
762        debug_assert_eq!(offset, 0.0);
763        node.children.clear();
764        return;
765    }
766
767    let mut row_heights = dynamic_row_heights(
768        node,
769        &row_keys,
770        width_bucket,
771        estimated_row_height,
772        ui_state,
773    );
774
775    // Skip the cache snapshot entirely when nothing in the queue
776    // targets this list — a hot path on dynamic lists with warm
777    // caches (potentially thousands of entries) that would otherwise
778    // pay a per-frame HashMap clone for an operation that fires
779    // maybe once a minute.
780    let has_request = node.key.as_deref().is_some_and(|k| {
781        ui_state.scroll.pending_requests.iter().any(|r| match r {
782            ScrollRequest::ToRow { list_key, .. } => list_key == k,
783            ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
784            ScrollRequest::EnsureVisible { .. } => false,
785        })
786    });
787    let mut request_wrote = false;
788    if has_request {
789        request_wrote = resolve_scroll_requests(
790            node,
791            inner,
792            count,
793            |target| {
794                (
795                    dynamic_row_top(&row_heights, gap, target),
796                    row_heights[target],
797                )
798            },
799            |row_key| row_keys.iter().position(|key| key == row_key),
800            ui_state,
801        );
802    }
803
804    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
805    let max_offset = (total_h - inner.h).max(0.0);
806    let stored = ui_state
807        .scroll
808        .offsets
809        .get(&node.computed_id)
810        .copied()
811        .unwrap_or(0.0);
812    let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
813    let provisional_offset = if pin_active {
814        match node.pin_policy {
815            crate::tree::PinPolicy::End => max_offset,
816            crate::tree::PinPolicy::Start => 0.0,
817            crate::tree::PinPolicy::None => unreachable!(),
818        }
819    } else if request_wrote {
820        stored
821    } else {
822        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
823            .unwrap_or(stored)
824    }
825    .clamp(0.0, max_offset);
826
827    let (measure_start, _, measure_end) =
828        dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
829    measure_dynamic_range(
830        node,
831        DynamicRangeCtx {
832            inner,
833            row_keys: &row_keys,
834            width_bucket,
835            build_row: &fns.build_row,
836        },
837        measure_start,
838        measure_end,
839        ui_state,
840    );
841
842    row_heights = dynamic_row_heights(
843        node,
844        &row_keys,
845        width_bucket,
846        estimated_row_height,
847        ui_state,
848    );
849    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
850    let max_offset = (total_h - inner.h).max(0.0);
851    let stored = ui_state
852        .scroll
853        .offsets
854        .get(&node.computed_id)
855        .copied()
856        .unwrap_or(0.0);
857    let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
858    let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
859        && ui_state
860            .scroll
861            .pin_active
862            .get(&node.computed_id)
863            .copied()
864            .unwrap_or(false);
865    let mut offset = if pin_active {
866        pin_resolved
867    } else if request_wrote {
868        stored
869    } else {
870        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
871            .unwrap_or(stored)
872    }
873    .clamp(0.0, max_offset);
874
875    ui_state
876        .scroll
877        .offsets
878        .insert(node.computed_id.clone(), offset);
879
880    let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
881    let mut realized_rows = layout_dynamic_range(
882        node,
883        DynamicRangeCtx {
884            inner,
885            row_keys: &row_keys,
886            width_bucket,
887            build_row: &fns.build_row,
888        },
889        offset,
890        start,
891        start_y,
892        end,
893        ui_state,
894    );
895
896    row_heights = dynamic_row_heights(
897        node,
898        &row_keys,
899        width_bucket,
900        estimated_row_height,
901        ui_state,
902    );
903    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
904    let max_offset = (total_h - inner.h).max(0.0);
905    let corrected_offset = if pin_active {
906        match node.pin_policy {
907            crate::tree::PinPolicy::End => max_offset,
908            crate::tree::PinPolicy::Start => 0.0,
909            crate::tree::PinPolicy::None => unreachable!(),
910        }
911    } else if request_wrote {
912        offset
913    } else {
914        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
915            .unwrap_or(offset)
916    }
917    .clamp(0.0, max_offset);
918    if (corrected_offset - offset).abs() > 0.01 {
919        let dy = offset - corrected_offset;
920        for child in &node.children {
921            shift_subtree_y(child, dy, ui_state);
922        }
923        for row in &mut realized_rows {
924            row.rect.y += dy;
925        }
926        offset = corrected_offset;
927        ui_state
928            .scroll
929            .offsets
930            .insert(node.computed_id.clone(), offset);
931    }
932    if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
933        ui_state
934            .scroll
935            .pin_prev_max
936            .insert(node.computed_id.clone(), max_offset);
937    }
938    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
939
940    if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
941        ui_state
942            .scroll
943            .virtual_anchors
944            .insert(node.computed_id.clone(), anchor);
945    } else {
946        ui_state.scroll.virtual_anchors.remove(&node.computed_id);
947    }
948}
949
950struct DynamicVirtualFns {
951    anchor_policy: VirtualAnchorPolicy,
952    row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
953    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
954}
955
956#[derive(Clone, Copy)]
957struct DynamicRangeCtx<'a> {
958    inner: Rect,
959    row_keys: &'a [String],
960    width_bucket: u32,
961    build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
962}
963
964fn virtual_width_bucket(width: f32) -> u32 {
965    width.max(0.0).round().min(u32::MAX as f32) as u32
966}
967
968fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
969    let Some(measurements) = ui_state
970        .scroll
971        .measured_row_heights
972        .get_mut(&node.computed_id)
973    else {
974        return;
975    };
976    let live_keys = row_keys
977        .iter()
978        .map(String::as_str)
979        .collect::<FxHashSet<_>>();
980    measurements.retain(|key, widths| {
981        let live = live_keys.contains(key.as_str());
982        if live {
983            widths.retain(|_, h| h.is_finite() && *h >= 0.0);
984        }
985        live && !widths.is_empty()
986    });
987    if measurements.is_empty() {
988        ui_state
989            .scroll
990            .measured_row_heights
991            .remove(&node.computed_id);
992    }
993}
994
995fn dynamic_row_heights(
996    node: &El,
997    row_keys: &[String],
998    width_bucket: u32,
999    estimated_row_height: f32,
1000    ui_state: &UiState,
1001) -> Vec<f32> {
1002    let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
1003    row_keys
1004        .iter()
1005        .map(|key| {
1006            measurements
1007                .and_then(|m| m.get(key))
1008                .and_then(|by_width| by_width.get(&width_bucket))
1009                .copied()
1010                .unwrap_or(estimated_row_height)
1011        })
1012        .collect()
1013}
1014
1015fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1016    row_heights
1017        .iter()
1018        .take(target)
1019        .fold(0.0, |y, h| y + *h + gap)
1020}
1021
1022fn dynamic_visible_range(
1023    row_heights: &[f32],
1024    gap: f32,
1025    offset: f32,
1026    viewport_h: f32,
1027) -> (usize, f32, usize) {
1028    let count = row_heights.len();
1029    let mut start = 0;
1030    let mut y = 0.0_f32;
1031    while start < count {
1032        let h = row_heights[start];
1033        if y + h > offset {
1034            break;
1035        }
1036        y += h + gap;
1037        start += 1;
1038    }
1039
1040    let mut end = start;
1041    let mut cursor = y;
1042    let viewport_bottom = offset + viewport_h;
1043    while end < count && cursor < viewport_bottom {
1044        let h = row_heights[end];
1045        end += 1;
1046        cursor += h + gap;
1047    }
1048    (start, y, end)
1049}
1050
1051fn dynamic_anchor_offset(
1052    node: &El,
1053    row_keys: &[String],
1054    row_heights: &[f32],
1055    gap: f32,
1056    stored: f32,
1057    ui_state: &UiState,
1058) -> Option<f32> {
1059    let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1060    let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1061        anchor.row_index
1062    } else {
1063        row_keys.iter().position(|key| key == &anchor.row_key)?
1064    };
1065    let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1066    let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1067    let scroll_delta = stored - anchor.resolved_offset;
1068    let viewport_y = anchor.viewport_y - scroll_delta;
1069    Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1070}
1071
1072fn measure_dynamic_range(
1073    node: &El,
1074    ctx: DynamicRangeCtx<'_>,
1075    start: usize,
1076    end: usize,
1077    ui_state: &mut UiState,
1078) {
1079    if start >= end {
1080        return;
1081    }
1082    let mut new_measurements = Vec::new();
1083    for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1084        let child = (ctx.build_row)(idx);
1085        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1086        new_measurements.push((key.clone(), actual_h));
1087    }
1088    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1089}
1090
1091fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1092    match child.height {
1093        Size::Fixed(v) => v.max(0.0),
1094        Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1095        Size::Aspect(r) => (width * r).max(0.0),
1096        Size::Fill(_) => panic!(
1097            "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1098             or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1099             virtualization",
1100            node.computed_id,
1101        ),
1102    }
1103}
1104
1105fn store_dynamic_measurements(
1106    node: &El,
1107    width_bucket: u32,
1108    measurements: Vec<(String, f32)>,
1109    ui_state: &mut UiState,
1110) {
1111    if measurements.is_empty() {
1112        return;
1113    }
1114    let entry = ui_state
1115        .scroll
1116        .measured_row_heights
1117        .entry(node.computed_id.clone())
1118        .or_default();
1119    for (row_key, h) in measurements {
1120        entry.entry(row_key).or_default().insert(width_bucket, h);
1121    }
1122}
1123
1124#[derive(Clone, Debug)]
1125struct DynamicRealizedRow {
1126    index: usize,
1127    key: String,
1128    rect: Rect,
1129}
1130
1131fn layout_dynamic_range(
1132    node: &mut El,
1133    ctx: DynamicRangeCtx<'_>,
1134    offset: f32,
1135    start: usize,
1136    start_y: f32,
1137    end: usize,
1138    ui_state: &mut UiState,
1139) -> Vec<DynamicRealizedRow> {
1140    let gap = node.gap.max(0.0);
1141    let mut cursor_y = start_y;
1142    let mut realized = Vec::new();
1143    let mut realized_rows = Vec::new();
1144    let mut new_measurements = Vec::new();
1145
1146    for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1147        let mut child = (ctx.build_row)(idx);
1148        assign_virtual_row_id(&mut child, &node.computed_id, idx);
1149        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1150        new_measurements.push((key.clone(), actual_h));
1151
1152        let row_y = ctx.inner.y + cursor_y - offset;
1153        let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1154        ui_state
1155            .layout
1156            .computed_rects
1157            .insert(child.computed_id.clone(), c_rect);
1158        layout_children(&mut child, c_rect, ui_state);
1159
1160        realized_rows.push(DynamicRealizedRow {
1161            index: idx,
1162            key: key.clone(),
1163            rect: c_rect,
1164        });
1165        realized.push(child);
1166        cursor_y += actual_h + gap;
1167    }
1168
1169    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1170    node.children = realized;
1171    realized_rows
1172}
1173
1174fn choose_dynamic_anchor(
1175    policy: VirtualAnchorPolicy,
1176    inner: Rect,
1177    offset: f32,
1178    rows: &[DynamicRealizedRow],
1179) -> Option<VirtualAnchor> {
1180    let visible = rows
1181        .iter()
1182        .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1183        .collect::<Vec<_>>();
1184    if visible.is_empty() {
1185        return None;
1186    }
1187
1188    let chosen = match policy {
1189        VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1190            let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1191            visible
1192                .iter()
1193                .min_by(|a, b| {
1194                    let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1195                    let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1196                    ad.total_cmp(&bd)
1197                })
1198                .copied()
1199                .map(|row| {
1200                    let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1201                    (row.clone(), anchor_y)
1202                })
1203        }
1204        VirtualAnchorPolicy::FirstVisible => {
1205            let row = visible
1206                .iter()
1207                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1208                .or_else(|| visible.first())
1209                .copied()?;
1210            let anchor_y = row.rect.y.max(inner.y);
1211            Some((row.clone(), anchor_y))
1212        }
1213        VirtualAnchorPolicy::LastVisible => {
1214            let row = visible
1215                .iter()
1216                .rev()
1217                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1218                .or_else(|| visible.last())
1219                .copied()?;
1220            let anchor_y = row.rect.bottom().min(inner.bottom());
1221            Some((row.clone(), anchor_y))
1222        }
1223    }?;
1224
1225    let (row, anchor_y) = chosen;
1226    let row_h = row.rect.h.max(0.0);
1227    let row_fraction = if row_h > 0.0 {
1228        ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1229    } else {
1230        0.0
1231    };
1232    Some(VirtualAnchor {
1233        row_key: row.key.clone(),
1234        row_index: row.index,
1235        row_fraction,
1236        viewport_y: anchor_y - inner.y,
1237        resolved_offset: offset,
1238    })
1239}
1240
1241fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1242    if y < top {
1243        top - y
1244    } else if y > bottom {
1245        y - bottom
1246    } else {
1247        0.0
1248    }
1249}
1250
1251fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1252    if count == 0 {
1253        0.0
1254    } else {
1255        row_sum + gap * count.saturating_sub(1) as f32
1256    }
1257}
1258
1259/// Scrollable post-pass: measure content height from the laid-out
1260/// children's stored rects, clamp the scroll offset to the available
1261/// range, and shift every descendant rect by `-offset`.
1262///
1263/// Children should size with `Hug` or `Fixed` on the main axis —
1264/// `Fill` children would absorb the viewport's height and there would
1265/// be nothing to scroll.
1266fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1267    let inner = node_rect.inset(node.padding);
1268    if node.children.is_empty() {
1269        ui_state
1270            .scroll
1271            .offsets
1272            .insert(node.computed_id.clone(), 0.0);
1273        ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1274        ui_state.scroll.metrics.insert(
1275            node.computed_id.clone(),
1276            crate::state::ScrollMetrics {
1277                viewport_h: inner.h,
1278                content_h: 0.0,
1279                max_offset: 0.0,
1280            },
1281        );
1282        return;
1283    }
1284    let content_bottom = node
1285        .children
1286        .iter()
1287        .map(|c| ui_state.rect(&c.computed_id).bottom())
1288        .fold(f32::NEG_INFINITY, f32::max);
1289    let content_h = (content_bottom - inner.y).max(0.0);
1290    let max_offset = (content_h - inner.h).max(0.0);
1291
1292    // Resolve any matching `ScrollRequest::EnsureVisible` against
1293    // this scroll BEFORE reading the stored offset, so the request's
1294    // chosen offset wins (and gets clamped below, just like
1295    // wheel-driven offsets do). A request matches when the node
1296    // keyed `container_key` is an ancestor of this scroll —
1297    // `key_index` resolves the key to a computed_id and a
1298    // prefix-match on `node.computed_id` tells us we're inside.
1299    let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1300
1301    let stored = ui_state
1302        .scroll
1303        .offsets
1304        .get(&node.computed_id)
1305        .copied()
1306        .unwrap_or(0.0);
1307    let stored = resolve_pin(node, stored, max_offset, ui_state);
1308    let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1309        && ui_state
1310            .scroll
1311            .pin_active
1312            .get(&node.computed_id)
1313            .copied()
1314            .unwrap_or(false);
1315    let stored = if pin_active || request_wrote {
1316        stored
1317    } else {
1318        scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
1319    };
1320    let clamped = stored.clamp(0.0, max_offset);
1321    if clamped > 0.0 {
1322        for c in &node.children {
1323            shift_subtree_y(c, -clamped, ui_state);
1324        }
1325    }
1326    ui_state
1327        .scroll
1328        .offsets
1329        .insert(node.computed_id.clone(), clamped);
1330    ui_state.scroll.metrics.insert(
1331        node.computed_id.clone(),
1332        crate::state::ScrollMetrics {
1333            viewport_h: inner.h,
1334            content_h,
1335            max_offset,
1336        },
1337    );
1338
1339    write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1340
1341    if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
1342        ui_state
1343            .scroll
1344            .scroll_anchors
1345            .insert(node.computed_id.clone(), anchor);
1346    } else {
1347        ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1348    }
1349}
1350
1351fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
1352    let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
1353    let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
1354    if rect.h <= 0.0 {
1355        return None;
1356    }
1357    let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
1358    let scroll_delta = stored - anchor.resolved_offset;
1359    let viewport_y = anchor.viewport_y - scroll_delta;
1360    Some(rect.y - inner.y + rect_point - viewport_y)
1361}
1362
1363fn choose_scroll_anchor(
1364    node: &El,
1365    inner: Rect,
1366    offset: f32,
1367    ui_state: &UiState,
1368) -> Option<ScrollAnchor> {
1369    if inner.h <= 0.0 {
1370        return None;
1371    }
1372    let target_y = inner.y + inner.h * 0.25;
1373    let mut best = None;
1374    for child in &node.children {
1375        choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
1376    }
1377    let candidate = best?;
1378    let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
1379    let rect_fraction = if candidate.rect.h > 0.0 {
1380        ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
1381    } else {
1382        0.0
1383    };
1384    Some(ScrollAnchor {
1385        node_id: candidate.node_id,
1386        rect_fraction,
1387        viewport_y: anchor_y - inner.y,
1388        resolved_offset: offset,
1389    })
1390}
1391
1392#[derive(Clone, Debug)]
1393struct ScrollAnchorCandidate {
1394    node_id: String,
1395    rect: Rect,
1396    distance: f32,
1397    depth: usize,
1398}
1399
1400fn choose_scroll_anchor_in_subtree(
1401    node: &El,
1402    inner: Rect,
1403    target_y: f32,
1404    depth: usize,
1405    ui_state: &UiState,
1406    best: &mut Option<ScrollAnchorCandidate>,
1407) {
1408    let Some(rect) = ui_state
1409        .layout
1410        .computed_rects
1411        .get(&node.computed_id)
1412        .copied()
1413    else {
1414        return;
1415    };
1416    if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
1417        let distance = distance_to_interval(target_y, rect.y, rect.bottom());
1418        let candidate = ScrollAnchorCandidate {
1419            node_id: node.computed_id.clone(),
1420            rect,
1421            distance,
1422            depth,
1423        };
1424        let replace = best.as_ref().is_none_or(|current| {
1425            candidate.distance < current.distance
1426                || (candidate.distance == current.distance && candidate.depth > current.depth)
1427                || (candidate.distance == current.distance
1428                    && candidate.depth == current.depth
1429                    && candidate.rect.h < current.rect.h)
1430        });
1431        if replace {
1432            *best = Some(candidate);
1433        }
1434    }
1435
1436    if node.scrollable {
1437        return;
1438    }
1439    for child in &node.children {
1440        choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
1441    }
1442}
1443
1444/// Stored offset within this much of the pinned edge counts as "at
1445/// the edge" for [`crate::tree::PinPolicy`] activation. Wheel deltas
1446/// are integer pixels, so a half-pixel slack absorbs floating-point
1447/// rounding without admitting any deliberate user scroll.
1448const PIN_EPSILON: f32 = 0.5;
1449
1450/// Decide whether the pin should be engaged this frame, given the
1451/// previous frame's active flag and reference offset.
1452///
1453/// `policy` selects the edge: `End` references the previous frame's
1454/// `max_offset` (stored in `scroll.pin_prev_max`) and engages when
1455/// `stored ≈ prev_max`; `Start` references `0` and engages when
1456/// `stored ≈ 0`. `None` returns `None` so callers can short-circuit.
1457fn pin_would_be_active(
1458    node: &El,
1459    stored: f32,
1460    _max_offset: f32,
1461    ui_state: &UiState,
1462) -> Option<bool> {
1463    let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1464    match node.pin_policy {
1465        crate::tree::PinPolicy::None => None,
1466        crate::tree::PinPolicy::End => {
1467            let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1468            Some(match prev_active {
1469                None => true,
1470                Some(prev) => {
1471                    let prev_max = prev_max.unwrap_or(0.0);
1472                    if prev && stored < prev_max - PIN_EPSILON {
1473                        false
1474                    } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
1475                        true
1476                    } else {
1477                        prev
1478                    }
1479                }
1480            })
1481        }
1482        crate::tree::PinPolicy::Start => Some(match prev_active {
1483            None => true,
1484            Some(prev) => {
1485                if prev && stored > PIN_EPSILON {
1486                    false
1487                } else if !prev && stored <= PIN_EPSILON {
1488                    true
1489                } else {
1490                    prev
1491                }
1492            }
1493        }),
1494    }
1495}
1496
1497/// Apply this node's [`crate::tree::PinPolicy`] to `stored`. Reads the
1498/// previous frame's bookkeeping from `scroll.pin_active` /
1499/// `scroll.pin_prev_max` to decide whether the stored offset has moved
1500/// off the pinned edge since last frame (user wheel / drag /
1501/// programmatic write), and updates those maps accordingly. Returns
1502/// the offset that should be clamped + written downstream — the
1503/// pinned edge (`0` for `Start`, `max_offset` for `End`) when engaged,
1504/// the input `stored` otherwise.
1505///
1506/// First frame for an opted-in container starts pinned, so a freshly
1507/// mounted `scroll([...]).pin_end()` paints with its tail visible and
1508/// `scroll([...]).pin_start()` paints (still) with its head visible.
1509fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1510    if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1511        ui_state.scroll.pin_active.remove(&node.computed_id);
1512        ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1513        return stored;
1514    }
1515    let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1516    ui_state
1517        .scroll
1518        .pin_active
1519        .insert(node.computed_id.clone(), active);
1520    match node.pin_policy {
1521        crate::tree::PinPolicy::End => {
1522            ui_state
1523                .scroll
1524                .pin_prev_max
1525                .insert(node.computed_id.clone(), max_offset);
1526            if active { max_offset } else { stored }
1527        }
1528        crate::tree::PinPolicy::Start => {
1529            // `pin_prev_max` is unused for Start — drop any stale entry
1530            // (e.g. if the policy was just flipped from End to Start
1531            // for the same computed_id).
1532            ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1533            if active { 0.0 } else { stored }
1534        }
1535        crate::tree::PinPolicy::None => unreachable!(),
1536    }
1537}
1538
1539/// Walk pending `ScrollRequest::EnsureVisible` requests and pop any
1540/// whose `container_key` resolves to an ancestor of `node`. For each
1541/// match, write a stored offset that brings the request's content-
1542/// space `y..y+h` range into the viewport using minimal-displacement
1543/// semantics (top edge if above, bottom edge if below, leave alone if
1544/// already inside). The clamp + shift downstream of this call ensures
1545/// the resulting offset stays inside `[0, max_offset]`.
1546///
1547/// Matching is by computed-id prefix on the keyed ancestor — a
1548/// scroll is "inside" the keyed widget when its id starts with the
1549/// ancestor's id followed by `.`, the same rule used by
1550/// [`crate::state::query::target_in_subtree`].
1551fn resolve_ensure_visible_for_scroll(
1552    node: &El,
1553    inner: Rect,
1554    content_h: f32,
1555    ui_state: &mut UiState,
1556) -> bool {
1557    if ui_state.scroll.pending_requests.is_empty() {
1558        return false;
1559    }
1560    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1561    let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1562    let mut wrote = false;
1563    for req in pending {
1564        let ScrollRequest::EnsureVisible {
1565            container_key,
1566            y,
1567            h,
1568        } = &req
1569        else {
1570            remaining.push(req);
1571            continue;
1572        };
1573        let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1574            // Container hasn't been laid out yet (or its key isn't
1575            // in this tree). Keep the request for a future frame —
1576            // dropped at end-of-frame like row requests for
1577            // missing lists.
1578            remaining.push(req);
1579            continue;
1580        };
1581        // Match this scroll only if it sits inside the keyed widget.
1582        // Same prefix rule as `target_in_subtree`.
1583        let inside = node.computed_id == *ancestor_id
1584            || node
1585                .computed_id
1586                .strip_prefix(ancestor_id.as_str())
1587                .is_some_and(|rest| rest.starts_with('.'));
1588        if !inside {
1589            remaining.push(req);
1590            continue;
1591        }
1592        let current = ui_state
1593            .scroll
1594            .offsets
1595            .get(&node.computed_id)
1596            .copied()
1597            .unwrap_or(0.0);
1598        let target_top = *y;
1599        let target_bottom = *y + *h;
1600        let viewport_h = inner.h;
1601        // Minimal-displacement: if the range is fully visible, no
1602        // change. If it's above the viewport top, scroll up to it.
1603        // If it's below the viewport bottom, scroll just enough to
1604        // expose the bottom edge — but never less than 0 or more
1605        // than `content_h - viewport_h` (the clamp downstream will
1606        // do that anyway).
1607        let new_offset = if target_top < current {
1608            target_top
1609        } else if target_bottom > current + viewport_h {
1610            target_bottom - viewport_h
1611        } else {
1612            // Already visible: don't override an in-progress
1613            // manual scroll just because the caret happens to be
1614            // mid-viewport. Skip this request without disturbing
1615            // the offset.
1616            continue;
1617        };
1618        // Clamp against the live content extent so we don't write
1619        // a wildly-out-of-range offset when the request races a
1620        // layout pass that hasn't yet measured all rows.
1621        let max = (content_h - viewport_h).max(0.0);
1622        let new_offset = new_offset.clamp(0.0, max);
1623        ui_state
1624            .scroll
1625            .offsets
1626            .insert(node.computed_id.clone(), new_offset);
1627        wrote = true;
1628    }
1629    ui_state.scroll.pending_requests = remaining;
1630    wrote
1631}
1632
1633/// Compute and store the scrollbar thumb + track rects for `node`
1634/// when the author opted into a visible scrollbar AND content
1635/// overflows. Both rects are anchored to the right edge of `inner`.
1636/// The visible thumb is `SCROLLBAR_THUMB_WIDTH` wide and tracks the
1637/// scroll offset; the track is `SCROLLBAR_HITBOX_WIDTH` wide and
1638/// covers the full inner height so a press above/below the thumb
1639/// can page-scroll.
1640fn write_thumb_rect(
1641    node: &El,
1642    inner: Rect,
1643    content_h: f32,
1644    max_offset: f32,
1645    offset: f32,
1646    ui_state: &mut UiState,
1647) {
1648    if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1649        return;
1650    }
1651    let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1652    let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1653    let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1654    let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1655    let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1656    let track_remaining = (inner.h - thumb_h).max(0.0);
1657    let thumb_y = inner.y + track_remaining * (offset / max_offset);
1658    let thumb_x = inner.right() - thumb_w - track_inset;
1659    let track_x = inner.right() - track_w - track_inset;
1660    ui_state.scroll.thumb_rects.insert(
1661        node.computed_id.clone(),
1662        Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1663    );
1664    ui_state.scroll.thumb_tracks.insert(
1665        node.computed_id.clone(),
1666        Rect::new(track_x, inner.y, track_w, inner.h),
1667    );
1668}
1669
1670fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1671    if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1672        rect.y += dy;
1673    }
1674    if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
1675        thumb.y += dy;
1676    }
1677    if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
1678        track.y += dy;
1679    }
1680    for c in &node.children {
1681        shift_subtree_y(c, dy, ui_state);
1682    }
1683}
1684
1685fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1686    let inner = node_rect.inset(node.padding);
1687    let n = node.children.len();
1688    if n == 0 {
1689        return;
1690    }
1691
1692    let total_gap = node.gap * n.saturating_sub(1) as f32;
1693    let main_extent = if vertical { inner.h } else { inner.w };
1694    let cross_extent = if vertical { inner.w } else { inner.h };
1695
1696    let intrinsics: Vec<(f32, f32)> = {
1697        crate::profile_span!("layout::axis::intrinsics");
1698        if vertical {
1699            // Column layout: each child measures at the parent's cross
1700            // (width) extent, so wrap-text children see the width they
1701            // will actually paint at. `child_intrinsic` already threads
1702            // this through.
1703            node.children
1704                .iter()
1705                .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1706                .collect()
1707        } else {
1708            // Row layout: mirror the two-pass measurement in
1709            // `intrinsic_constrained_uncached`'s Row branch so a `Fill`
1710            // child with `wrap_text` descendants reports the height it
1711            // will actually paint at — not its single-line unwrapped
1712            // intrinsic. Without this, e.g. a `row([column([paragraph,
1713            // paragraph]).fill_width(), fixed])` shape sizes the
1714            // column rect at the unwrapped height, and the wrapped
1715            // text inside overflows the column vertically (the
1716            // `Overflow B=N` shape that motivated this fix).
1717            row_child_intrinsics(node, main_extent)
1718        }
1719    };
1720
1721    // `main_size_of` resolves main-axis size directly from each child's
1722    // own sizing intent and its intrinsic. `Size::Aspect` on the main
1723    // axis is the one case that needs more context: it derives main
1724    // from the *resolved cross size*, which means we have to compute
1725    // cross first for that child only (an inversion of the normal
1726    // main-then-cross ordering). Cross resolution doesn't depend on
1727    // main except when cross is also Aspect — that's the degenerate
1728    // both-Aspect case and falls back to intrinsic via main_size_of.
1729    let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
1730        let main_intent = if vertical { c.height } else { c.width };
1731        if let Size::Aspect(r) = main_intent {
1732            let cross_intent = if vertical { c.width } else { c.height };
1733            if !matches!(cross_intent, Size::Aspect(_)) {
1734                let cross_intrinsic = if vertical { iw } else { ih };
1735                let cross_size = match cross_intent {
1736                    Size::Fixed(v) => v,
1737                    Size::Hug | Size::Fill(_) => match node.align {
1738                        Align::Stretch => cross_extent,
1739                        Align::Start | Align::Center | Align::End => cross_intrinsic,
1740                    },
1741                    Size::Aspect(_) => unreachable!(),
1742                };
1743                let cross_size = if vertical {
1744                    clamp_w(c, cross_size)
1745                } else {
1746                    clamp_h(c, cross_size)
1747                };
1748                let main = cross_size * r.max(0.0);
1749                let clamped = if vertical {
1750                    clamp_h(c, main)
1751                } else {
1752                    clamp_w(c, main)
1753                };
1754                return MainSize::Resolved(clamped);
1755            }
1756        }
1757        main_size_of(c, iw, ih, vertical)
1758    };
1759
1760    let mut consumed = 0.0;
1761    let mut fill_weight_total = 0.0;
1762    for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1763        match resolve_main(c, *iw, *ih) {
1764            MainSize::Resolved(v) => consumed += v,
1765            MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1766        }
1767    }
1768    let remaining = (main_extent - consumed - total_gap).max(0.0);
1769
1770    // Free space after children + gaps. When there are Fill children they
1771    // claim it all, so justify is moot; otherwise this is what center/end
1772    // distribute around.
1773    let free_after_used = if fill_weight_total == 0.0 {
1774        remaining
1775    } else {
1776        0.0
1777    };
1778    let mut cursor = match node.justify {
1779        Justify::Start => 0.0,
1780        Justify::Center => free_after_used * 0.5,
1781        Justify::End => free_after_used,
1782        Justify::SpaceBetween => 0.0,
1783    };
1784    let between_extra =
1785        if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1786            remaining / (n - 1) as f32
1787        } else {
1788            0.0
1789        };
1790    let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1791
1792    crate::profile_span!("layout::axis::place");
1793    for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1794        let main_size = match resolve_main(c, iw, ih) {
1795            MainSize::Resolved(v) => v,
1796            MainSize::Fill(w) => {
1797                let raw = remaining * w.max(0.001) / fill_weight_total.max(0.001);
1798                if vertical {
1799                    clamp_h(c, raw)
1800                } else {
1801                    clamp_w(c, raw)
1802                }
1803            }
1804        };
1805
1806        let cross_intent = if vertical { c.width } else { c.height };
1807        let cross_intrinsic = if vertical { iw } else { ih };
1808        // CSS-flex parity for cross-axis sizing: `Size::Fixed` is an
1809        // explicit author override and always wins. Otherwise the
1810        // parent's `Align` decides — `Stretch` (the column default)
1811        // stretches non-fixed children to the container, `Center` /
1812        // `Start` / `End` shrink to intrinsic so the alignment can
1813        // actually position them. This collapses Hug and Fill on the
1814        // cross axis (both are "follow align-items"), the same way
1815        // CSS flex doesn't distinguish between them on the cross axis.
1816        // `Aspect` derives cross from the already-resolved main. The
1817        // symmetric case (Aspect on main) is handled by `resolve_main`
1818        // above, which inverts the ordering for that child only.
1819        let cross_size = match cross_intent {
1820            Size::Fixed(v) => v,
1821            Size::Aspect(r) => main_size * r,
1822            Size::Hug | Size::Fill(_) => match node.align {
1823                Align::Stretch => cross_extent,
1824                Align::Start | Align::Center | Align::End => cross_intrinsic,
1825            },
1826        };
1827        let cross_size = if vertical {
1828            clamp_w(c, cross_size)
1829        } else {
1830            clamp_h(c, cross_size)
1831        };
1832
1833        let cross_off = match node.align {
1834            Align::Start | Align::Stretch => 0.0,
1835            Align::Center => (cross_extent - cross_size) * 0.5,
1836            Align::End => cross_extent - cross_size,
1837        };
1838
1839        let c_rect = if vertical {
1840            Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1841        } else {
1842            Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1843        };
1844        ui_state
1845            .layout
1846            .computed_rects
1847            .insert(c.computed_id.clone(), c_rect);
1848        if can_prune_scroll_child(c, c_rect, scroll_visible) {
1849            let nodes = zero_descendant_rects(c, c_rect, ui_state);
1850            record_pruned_subtree(nodes);
1851        } else {
1852            layout_children(c, c_rect, ui_state);
1853        }
1854
1855        cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1856    }
1857}
1858
1859const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1860
1861fn scroll_visible_content_rect(
1862    node: &El,
1863    inner: Rect,
1864    vertical: bool,
1865    ui_state: &UiState,
1866) -> Option<Rect> {
1867    if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1868        return None;
1869    }
1870    let offset = ui_state
1871        .scroll
1872        .offsets
1873        .get(&node.computed_id)
1874        .copied()
1875        .unwrap_or(0.0)
1876        .max(0.0);
1877    Some(Rect::new(
1878        inner.x,
1879        inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1880        inner.w,
1881        inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1882    ))
1883}
1884
1885fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1886    let Some(visible) = visible else {
1887        return false;
1888    };
1889    child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1890}
1891
1892fn subtree_is_layout_confined(node: &El) -> bool {
1893    if node.translate != (0.0, 0.0)
1894        || node.scale != 1.0
1895        || node.shadow > 0.0
1896        || node.paint_overflow != Sides::zero()
1897        || node.hit_overflow != Sides::zero()
1898        || node.layout_override.is_some()
1899        || node.virtual_items.is_some()
1900    {
1901        return false;
1902    }
1903    node.children.iter().all(subtree_is_layout_confined)
1904}
1905
1906fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1907    let mut count = 0;
1908    let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1909    for child in &node.children {
1910        ui_state
1911            .layout
1912            .computed_rects
1913            .insert(child.computed_id.clone(), zero);
1914        count += 1 + zero_descendant_rects(child, zero, ui_state);
1915    }
1916    count
1917}
1918
1919fn record_pruned_subtree(nodes: u64) {
1920    INTRINSIC_CACHE.with(|cell| {
1921        if let Some(cache) = cell.borrow_mut().as_mut() {
1922            cache.prune.subtrees += 1;
1923            cache.prune.nodes += nodes;
1924        }
1925    });
1926}
1927
1928enum MainSize {
1929    Resolved(f32),
1930    Fill(f32),
1931}
1932
1933fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1934    let s = if vertical { c.height } else { c.width };
1935    let intr = if vertical { ih } else { iw };
1936    let clamp = |v: f32| {
1937        if vertical {
1938            clamp_h(c, v)
1939        } else {
1940            clamp_w(c, v)
1941        }
1942    };
1943    match s {
1944        Size::Fixed(v) => MainSize::Resolved(clamp(v)),
1945        Size::Hug => MainSize::Resolved(clamp(intr)),
1946        Size::Fill(w) => MainSize::Fill(w),
1947        // Main-axis Aspect needs the resolved cross size to compute
1948        // `cross * r`. That requires inverting the normal main-then-
1949        // cross order and is handled by `layout_axis`'s `resolve_main`
1950        // closure. If we ever reach this arm (e.g. from a path that
1951        // bypasses `resolve_main`), fall back to intrinsic so the El
1952        // still has a finite measure.
1953        Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
1954    }
1955}
1956
1957fn child_intrinsic(
1958    c: &El,
1959    vertical: bool,
1960    parent_cross_extent: f32,
1961    parent_align: Align,
1962) -> (f32, f32) {
1963    if !vertical {
1964        return intrinsic(c);
1965    }
1966    let available_width = match c.width {
1967        Size::Fixed(v) => Some(v),
1968        Size::Fill(_) => Some(parent_cross_extent),
1969        Size::Hug => match parent_align {
1970            Align::Stretch => Some(parent_cross_extent),
1971            Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1972        },
1973        // Aspect width derives from height; we don't know height yet
1974        // at intrinsic time. Cap text wrap at the parent's cross
1975        // extent so wrappable content doesn't unwrap unnecessarily;
1976        // the Aspect post-step in `intrinsic_constrained` overrides
1977        // the returned width with `ih * r` anyway.
1978        Size::Aspect(_) => Some(parent_cross_extent),
1979    };
1980    intrinsic_constrained(c, available_width)
1981}
1982
1983/// Per-child intrinsics for a horizontal-axis (row) parent, using the
1984/// same two-pass distribution as `intrinsic_constrained_uncached`'s
1985/// Row branch. First pass measures Fixed and Hug widths unconstrained
1986/// (Hug naturally takes its intrinsic; Fixed self-resolves); second
1987/// pass distributes leftover main-axis space across Fill children and
1988/// re-measures each with its allocated width so wrap-text descendants
1989/// shape at the width they will actually paint at, not their unwrapped
1990/// intrinsic. `inner_main_extent` is the row's padded inner width.
1991fn row_child_intrinsics(node: &El, inner_main_extent: f32) -> Vec<(f32, f32)> {
1992    let n = node.children.len();
1993    let total_gap = node.gap * n.saturating_sub(1) as f32;
1994
1995    let mut first: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1996    let mut consumed: f32 = 0.0;
1997    let mut fill_weight_total: f32 = 0.0;
1998    for c in &node.children {
1999        match c.width {
2000            Size::Fill(w) => {
2001                fill_weight_total += w.max(0.001);
2002                first.push(None);
2003            }
2004            _ => {
2005                let (iw, ih) = intrinsic(c);
2006                consumed += iw;
2007                first.push(Some((iw, ih)));
2008            }
2009        }
2010    }
2011
2012    let fill_remaining = (inner_main_extent - consumed - total_gap).max(0.0);
2013
2014    node.children
2015        .iter()
2016        .zip(first)
2017        .map(|(c, slot)| match slot {
2018            Some(rc) => rc,
2019            None => {
2020                let weight = match c.width {
2021                    Size::Fill(w) => w.max(0.001),
2022                    _ => 1.0,
2023                };
2024                let av = if fill_weight_total > 0.0 {
2025                    fill_remaining * weight / fill_weight_total
2026                } else {
2027                    fill_remaining
2028                };
2029                intrinsic_constrained(c, Some(av))
2030            }
2031        })
2032        .collect()
2033}
2034
2035fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
2036    // Wrap-text height depends on width, so constrain the intrinsic
2037    // measurement to the width the child will actually be laid out at
2038    // — same shape as `child_intrinsic` does for column/row children.
2039    // Without this, a Fixed-width modal with a wrappable paragraph
2040    // measures as a single-line block and the modal's Hug height ends
2041    // up shorter than the actual content needs, eating bottom padding.
2042    let constrained_width = match c.width {
2043        Size::Fixed(v) => Some(v),
2044        Size::Fill(_) | Size::Hug => Some(parent.w),
2045        // Width derives from height — let the intrinsic post-step
2046        // override iw; don't pre-constrain text wrap to parent.w.
2047        Size::Aspect(_) => None,
2048    };
2049    let (iw, ih) = intrinsic_constrained(c, constrained_width);
2050    // Overlay isn't main/cross-asymmetric, so Aspect can resolve on
2051    // either axis here. Resolve the non-Aspect axis first, then derive
2052    // the Aspect axis. If both are Aspect (degenerate), fall back to
2053    // intrinsic for both.
2054    let (w, h) = match (c.width, c.height) {
2055        (Size::Aspect(_), Size::Aspect(_)) => (iw.min(parent.w), ih.min(parent.h)),
2056        (Size::Aspect(r), _) => {
2057            let h = match c.height {
2058                Size::Fixed(v) => v,
2059                Size::Hug => ih.min(parent.h),
2060                Size::Fill(_) => parent.h,
2061                Size::Aspect(_) => unreachable!(),
2062            };
2063            (h * r, h)
2064        }
2065        (_, Size::Aspect(r)) => {
2066            let w = match c.width {
2067                Size::Fixed(v) => v,
2068                Size::Hug => iw.min(parent.w),
2069                Size::Fill(_) => parent.w,
2070                Size::Aspect(_) => unreachable!(),
2071            };
2072            (w, w * r)
2073        }
2074        _ => {
2075            let w = match c.width {
2076                Size::Fixed(v) => v,
2077                Size::Hug => iw.min(parent.w),
2078                Size::Fill(_) => parent.w,
2079                Size::Aspect(_) => unreachable!(),
2080            };
2081            let h = match c.height {
2082                Size::Fixed(v) => v,
2083                Size::Hug => ih.min(parent.h),
2084                Size::Fill(_) => parent.h,
2085                Size::Aspect(_) => unreachable!(),
2086            };
2087            (w, h)
2088        }
2089    };
2090    let w = clamp_w(c, w);
2091    let h = clamp_h(c, h);
2092    let x = match align {
2093        Align::Start | Align::Stretch => parent.x,
2094        Align::Center => parent.x + (parent.w - w) * 0.5,
2095        Align::End => parent.right() - w,
2096    };
2097    let y = match justify {
2098        Justify::Start | Justify::SpaceBetween => parent.y,
2099        Justify::Center => parent.y + (parent.h - h) * 0.5,
2100        Justify::End => parent.bottom() - h,
2101    };
2102    Rect::new(x, y, w, h)
2103}
2104
2105/// Intrinsic (width, height) for hugging layouts.
2106pub fn intrinsic(c: &El) -> (f32, f32) {
2107    intrinsic_constrained(c, None)
2108}
2109
2110fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
2111    // A node's own Fixed width beats whatever the ancestor chain has
2112    // available: layout will resolve the node at exactly that width,
2113    // so measuring at any other width makes Hug ancestors disagree
2114    // with the final wrap of text descendants (issue #47). Applied
2115    // before the cache key so all callers unify on one entry.
2116    let available_width = match c.width {
2117        Size::Fixed(v) => Some(v),
2118        _ => available_width,
2119    };
2120    let key = intrinsic_cache_key(c, available_width);
2121    if let Some(key) = &key
2122        && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
2123            let mut slot = cell.borrow_mut();
2124            let cache = slot.as_mut()?;
2125            let cached = cache.measurements.get(key).copied();
2126            if cached.is_some() {
2127                cache.stats.hits += 1;
2128            }
2129            cached
2130        })
2131    {
2132        return cached;
2133    }
2134
2135    if key.is_some() {
2136        INTRINSIC_CACHE.with(|cell| {
2137            if let Some(cache) = cell.borrow_mut().as_mut() {
2138                cache.stats.misses += 1;
2139            }
2140        });
2141    }
2142
2143    let measured = apply_aspect(
2144        c,
2145        available_width,
2146        intrinsic_constrained_uncached(c, available_width),
2147    );
2148
2149    if let Some(key) = key {
2150        INTRINSIC_CACHE.with(|cell| {
2151            if let Some(cache) = cell.borrow_mut().as_mut() {
2152                cache.measurements.insert(key, measured);
2153            }
2154        });
2155    }
2156
2157    measured
2158}
2159
2160/// Apply `Size::Aspect` to a freshly-measured intrinsic by deriving the
2161/// aspect-locked axis from the other axis. Runs after the inner intrinsic
2162/// pass so it composes with any content type (image, text, container).
2163///
2164/// When the *other* axis is `Fill`, the layout-time size of that axis is
2165/// the parent's available extent, not the El's inner intrinsic. Using the
2166/// inner intrinsic would let a hugging parent under-size and the Aspect-
2167/// derived axis would then overflow at paint. Prefer `available_width`
2168/// for Fill width; we don't currently plumb available_height, so a Fill
2169/// height + Aspect width pairing falls back to inner intrinsic.
2170///
2171/// Both axes Aspect is degenerate — fall back to the inner intrinsic so
2172/// the El still has a finite measure. Negative ratios are clamped to zero
2173/// for the same reason.
2174fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
2175    match (c.width, c.height) {
2176        (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
2177        (Size::Aspect(r), _) => {
2178            // Basis axis is height; ih is already clamped by apply_min.
2179            // Clamp the derived width against the El's own min/max so
2180            // a hugging parent sees the intrinsic that layout will
2181            // actually paint at.
2182            (clamp_w(c, ih * r.max(0.0)), ih)
2183        }
2184        (_, Size::Aspect(r)) => {
2185            let raw_basis = match c.width {
2186                Size::Fixed(v) => v,
2187                Size::Fill(_) => available_width.unwrap_or(iw),
2188                Size::Hug | Size::Aspect(_) => iw,
2189            };
2190            // Mirror the layout-time ordering in `resolve_main`: clamp
2191            // the basis by the *basis* axis's min/max first, then derive
2192            // the other axis and clamp by its own min/max.
2193            let basis = clamp_w(c, raw_basis);
2194            (iw, clamp_h(c, basis * r.max(0.0)))
2195        }
2196        _ => (iw, ih),
2197    }
2198}
2199
2200fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
2201    if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
2202        return None;
2203    }
2204    if c.computed_id.is_empty() {
2205        return None;
2206    }
2207    Some(IntrinsicCacheKey {
2208        computed_id: c.computed_id.clone(),
2209        available_width_bits: available_width.map(f32::to_bits),
2210    })
2211}
2212
2213fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
2214    if c.layout_override.is_some() {
2215        // Custom-layout nodes don't define an intrinsic. Authors must
2216        // size them with `Fixed` or `Fill` on both axes; the returned
2217        // (0.0, 0.0) is replaced by `apply_min` for `Fixed` and is
2218        // unread for `Fill` (parent's distribution decides).
2219        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2220            panic!(
2221                "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
2222                 Size::Hug is not supported for custom layouts",
2223                c.computed_id,
2224            );
2225        }
2226        return apply_min(c, 0.0, 0.0);
2227    }
2228    if c.virtual_items.is_some() {
2229        // VirtualList sizes the whole viewport (the parent decides) and
2230        // realizes only on-screen rows. Hug-sizing it would mean
2231        // "shrink to fit all rows", defeating virtualization. Same
2232        // shape as the layout_override guard.
2233        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2234            panic!(
2235                "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
2236                 Size::Hug would defeat virtualization",
2237                c.computed_id,
2238            );
2239        }
2240        return apply_min(c, 0.0, 0.0);
2241    }
2242    if matches!(c.kind, Kind::Inlines) {
2243        return inline_paragraph_intrinsic(c, available_width);
2244    }
2245    if matches!(c.kind, Kind::HardBreak) {
2246        // HardBreak is meaningful only inside Inlines (where draw_ops
2247        // encodes it as `\n` in the attributed text). Outside Inlines
2248        // it's a no-op layout-wise.
2249        return apply_min(c, 0.0, 0.0);
2250    }
2251    if matches!(c.kind, Kind::Math) {
2252        if let Some(expr) = &c.math {
2253            let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
2254            return apply_min(
2255                c,
2256                layout.width + c.padding.left + c.padding.right,
2257                layout.height() + c.padding.top + c.padding.bottom,
2258            );
2259        }
2260        return apply_min(c, 0.0, 0.0);
2261    }
2262    if c.icon.is_some() {
2263        return apply_min(
2264            c,
2265            c.font_size + c.padding.left + c.padding.right,
2266            c.font_size + c.padding.top + c.padding.bottom,
2267        );
2268    }
2269    if let Some(img) = &c.image {
2270        // Natural pixel size as a logical-pixel intrinsic. Authors who
2271        // want a different sized box set `.width()` / `.height()`;
2272        // the projection inside that box is decided by `image_fit`.
2273        let w = img.width() as f32 + c.padding.left + c.padding.right;
2274        let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2275        return apply_min(c, w, h);
2276    }
2277    if let Some(text) = &c.text {
2278        let content_available = match c.text_wrap {
2279            TextWrap::NoWrap => None,
2280            TextWrap::Wrap => available_width
2281                .or(match c.width {
2282                    Size::Fixed(v) => Some(v),
2283                    // Aspect-on-text would be circular (text height
2284                    // depends on wrap width which would depend on
2285                    // text height). Treat like Hug — no wrap cap.
2286                    Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2287                })
2288                .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2289        };
2290        let display = display_text_for_measure(c, text, content_available);
2291        let layout = text_metrics::layout_text_with_line_height_and_family(
2292            &display,
2293            c.font_size,
2294            c.line_height,
2295            c.font_family,
2296            c.font_weight,
2297            c.font_mono,
2298            c.text_wrap,
2299            content_available,
2300        );
2301        let w = match (content_available, c.width) {
2302            (Some(available), Size::Hug | Size::Aspect(_)) => {
2303                let unwrapped = text_metrics::layout_text_with_family(
2304                    text,
2305                    c.font_size,
2306                    c.font_family,
2307                    c.font_weight,
2308                    c.font_mono,
2309                    TextWrap::NoWrap,
2310                    None,
2311                );
2312                unwrapped.width.min(available) + c.padding.left + c.padding.right
2313            }
2314            (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2315                available + c.padding.left + c.padding.right
2316            }
2317            (None, _) => layout.width + c.padding.left + c.padding.right,
2318        };
2319        let h = layout.height + c.padding.top + c.padding.bottom;
2320        return apply_min(c, w, h);
2321    }
2322    match c.axis {
2323        Axis::Overlay => {
2324            let mut w: f32 = 0.0;
2325            let mut h: f32 = 0.0;
2326            for ch in &c.children {
2327                let child_available =
2328                    available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2329                let (cw, chh) = intrinsic_constrained(ch, child_available);
2330                w = w.max(cw);
2331                h = h.max(chh);
2332            }
2333            apply_min(
2334                c,
2335                w + c.padding.left + c.padding.right,
2336                h + c.padding.top + c.padding.bottom,
2337            )
2338        }
2339        Axis::Column => {
2340            let mut w: f32 = 0.0;
2341            let mut h: f32 = c.padding.top + c.padding.bottom;
2342            let n = c.children.len();
2343            let child_available =
2344                available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2345            for (i, ch) in c.children.iter().enumerate() {
2346                let (cw, chh) = intrinsic_constrained(ch, child_available);
2347                w = w.max(cw);
2348                h += chh;
2349                if i + 1 < n {
2350                    h += c.gap;
2351                }
2352            }
2353            apply_min(c, w + c.padding.left + c.padding.right, h)
2354        }
2355        Axis::Row => {
2356            // Two-pass measurement so that wrappable Fill children see
2357            // the width they will actually be laid out at. Without
2358            // this, a `Size::Fill` paragraph inside a row falls through
2359            // `inline_paragraph_intrinsic`'s `available_width` fallback
2360            // with `None` and reports its unwrapped single-line height
2361            // — the row then under-reserves vertical space and the
2362            // wrapped text overflows downward into the next row. This
2363            // mirrors how `layout_axis` (the runtime pass) already
2364            // splits Resolved vs. Fill main-axis sizing.
2365            let n = c.children.len();
2366            let total_gap = c.gap * n.saturating_sub(1) as f32;
2367            let inner_available = available_width
2368                .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2369
2370            // First pass: Fixed and Hug children measure unconstrained.
2371            // Fixed-width wrappable children self-resolve their wrap
2372            // width via `inline_paragraph_intrinsic`'s own Fixed
2373            // fallback; Hug children take their natural width. We only
2374            // need to feed an explicit available width to Fill.
2375            let mut consumed: f32 = 0.0;
2376            let mut fill_weight_total: f32 = 0.0;
2377            let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2378            for ch in &c.children {
2379                match ch.width {
2380                    Size::Fill(w) => {
2381                        fill_weight_total += w.max(0.001);
2382                        sizes.push(None);
2383                    }
2384                    _ => {
2385                        let (cw, chh) = intrinsic(ch);
2386                        consumed += cw;
2387                        sizes.push(Some((cw, chh)));
2388                    }
2389                }
2390            }
2391
2392            // Second pass: distribute the leftover among Fill children
2393            // by weight and remeasure each with its share. Without an
2394            // available_width hint (row inside a Hug ancestor with no
2395            // outer constraint) we fall back to unconstrained
2396            // measurement — same lossy shape as the prior code, but
2397            // limited to the case where there's genuinely no width to
2398            // distribute.
2399            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2400            let mut w_total: f32 = c.padding.left + c.padding.right;
2401            let mut h_max: f32 = 0.0;
2402            for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2403                let (cw, chh) = match slot {
2404                    Some(rc) => rc,
2405                    None => match (fill_remaining, fill_weight_total > 0.0) {
2406                        (Some(av), true) => {
2407                            let weight = match ch.width {
2408                                Size::Fill(w) => w.max(0.001),
2409                                _ => 1.0,
2410                            };
2411                            intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2412                        }
2413                        _ => intrinsic(ch),
2414                    },
2415                };
2416                w_total += cw;
2417                if i + 1 < n {
2418                    w_total += c.gap;
2419                }
2420                h_max = h_max.max(chh);
2421            }
2422            apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2423        }
2424    }
2425}
2426
2427pub(crate) fn text_layout(
2428    c: &El,
2429    available_width: Option<f32>,
2430) -> Option<text_metrics::TextLayout> {
2431    let text = c.text.as_ref()?;
2432    let content_available = match c.text_wrap {
2433        TextWrap::NoWrap => None,
2434        TextWrap::Wrap => available_width
2435            .or(match c.width {
2436                Size::Fixed(v) => Some(v),
2437                Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2438            })
2439            .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2440    };
2441    let display = display_text_for_measure(c, text, content_available);
2442    Some(text_metrics::layout_text_with_line_height_and_family(
2443        &display,
2444        c.font_size,
2445        c.line_height,
2446        c.font_family,
2447        c.font_weight,
2448        c.font_mono,
2449        c.text_wrap,
2450        content_available,
2451    ))
2452}
2453
2454fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2455    if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2456        (c.text_wrap, c.text_max_lines, available_width)
2457    {
2458        text_metrics::clamp_text_to_lines_with_family(
2459            text,
2460            c.font_size,
2461            c.font_family,
2462            c.font_weight,
2463            c.font_mono,
2464            width,
2465            max_lines,
2466        )
2467    } else {
2468        text.to_string()
2469    }
2470}
2471
2472fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2473    if let Size::Fixed(v) = c.width {
2474        w = v;
2475    }
2476    if let Size::Fixed(v) = c.height {
2477        h = v;
2478    }
2479    (clamp_w(c, w), clamp_h(c, h))
2480}
2481
2482/// Apply [`El::min_width`] / [`El::max_width`] to a resolved width,
2483/// matching CSS's `min-width` over `max-width` precedence (when both
2484/// constraints conflict, the lower bound wins). Also clamps to a
2485/// non-negative result so a zero-padding Hug never reports a negative
2486/// intrinsic.
2487pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2488    if let Some(max_w) = c.max_width {
2489        w = w.min(max_w);
2490    }
2491    if let Some(min_w) = c.min_width {
2492        w = w.max(min_w);
2493    }
2494    w.max(0.0)
2495}
2496
2497/// Height-axis companion to [`clamp_w`].
2498pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2499    if let Some(max_h) = c.max_height {
2500        h = h.min(max_h);
2501    }
2502    if let Some(min_h) = c.min_height {
2503        h = h.max(min_h);
2504    }
2505    h.max(0.0)
2506}
2507
2508/// Approximate intrinsic measurement for `Kind::Inlines` paragraphs.
2509///
2510/// The paragraph paints through cosmic-text's rich-text shaping (which
2511/// resolves bold/italic/mono runs against fontdb), but layout needs a
2512/// width and height *before* we get to the renderer. We concatenate
2513/// the runs' text into one string and call `text_metrics::layout_text`
2514/// at the dominant font size — same approximation the lint pass uses
2515/// for single-style text. Bold/italic widths are slightly different
2516/// from regular; for body-text paragraphs that difference is well
2517/// under one wrap-line and we accept it. If a fixture wraps within
2518/// 1-2 characters of a boundary the rendered glyphs may straddle the
2519/// laid-out rect by a fraction of a glyph.
2520fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2521    if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2522        return inline_mixed_intrinsic(node, available_width);
2523    }
2524    let concat = concat_inline_text(&node.children);
2525    let size = inline_paragraph_size(node);
2526    let line_height = inline_paragraph_line_height(node);
2527    let content_available = match node.text_wrap {
2528        TextWrap::NoWrap => None,
2529        TextWrap::Wrap => available_width
2530            .or(match node.width {
2531                Size::Fixed(v) => Some(v),
2532                Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2533            })
2534            .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2535    };
2536    let layout = text_metrics::layout_text_with_line_height_and_family(
2537        &concat,
2538        size,
2539        line_height,
2540        node.font_family,
2541        FontWeight::Regular,
2542        false,
2543        node.text_wrap,
2544        content_available,
2545    );
2546    let w = match (content_available, node.width) {
2547        (Some(available), Size::Hug | Size::Aspect(_)) => {
2548            let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2549                &concat,
2550                size,
2551                line_height,
2552                node.font_family,
2553                FontWeight::Regular,
2554                false,
2555                TextWrap::NoWrap,
2556                None,
2557            );
2558            unwrapped.width.min(available) + node.padding.left + node.padding.right
2559        }
2560        (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2561            available + node.padding.left + node.padding.right
2562        }
2563        (None, _) => layout.width + node.padding.left + node.padding.right,
2564    };
2565    let h = layout.height + node.padding.top + node.padding.bottom;
2566    apply_min(node, w, h)
2567}
2568
2569fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2570    let wrap_width = match node.text_wrap {
2571        TextWrap::Wrap => available_width.or(match node.width {
2572            Size::Fixed(v) => Some(v),
2573            Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2574        }),
2575        TextWrap::NoWrap => None,
2576    }
2577    .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2578
2579    let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2580        node.text_wrap,
2581        wrap_width,
2582        node.font_size * 0.82,
2583        node.font_size * 0.22,
2584        node.line_height,
2585    );
2586
2587    for child in &node.children {
2588        match child.kind {
2589            Kind::HardBreak => {
2590                breaker.finish_line();
2591                continue;
2592            }
2593            Kind::Text => {
2594                let text = child.text.as_deref().unwrap_or("");
2595                for chunk in inline_text_chunks(text) {
2596                    let is_space = chunk.chars().all(char::is_whitespace);
2597                    if breaker.skips_leading_space(is_space) {
2598                        continue;
2599                    }
2600                    let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2601                    if breaker.wraps_before(is_space, w) {
2602                        breaker.finish_line();
2603                    }
2604                    if breaker.skips_overflowing_space(is_space, w) {
2605                        continue;
2606                    }
2607                    breaker.push(w, ascent, descent);
2608                }
2609                continue;
2610            }
2611            _ => {}
2612        }
2613        let (w, ascent, descent) = inline_child_metrics(child);
2614        if breaker.wraps_before(false, w) {
2615            breaker.finish_line();
2616        }
2617        breaker.push(w, ascent, descent);
2618    }
2619    let measurement = breaker.finish();
2620    let w = measurement.width + node.padding.left + node.padding.right;
2621    let h = measurement.height + node.padding.top + node.padding.bottom;
2622    apply_min(node, w, h)
2623}
2624
2625fn inline_text_chunks(text: &str) -> Vec<&str> {
2626    let mut chunks = Vec::new();
2627    let mut start = 0;
2628    let mut last_space = None;
2629    for (i, ch) in text.char_indices() {
2630        let is_space = ch.is_whitespace();
2631        match last_space {
2632            None => last_space = Some(is_space),
2633            Some(prev) if prev != is_space => {
2634                chunks.push(&text[start..i]);
2635                start = i;
2636                last_space = Some(is_space);
2637            }
2638            _ => {}
2639        }
2640    }
2641    if start < text.len() {
2642        chunks.push(&text[start..]);
2643    }
2644    chunks
2645}
2646
2647fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2648    let layout = text_metrics::layout_text_with_line_height_and_family(
2649        text,
2650        child.font_size,
2651        child.line_height,
2652        child.font_family,
2653        child.font_weight,
2654        child.font_mono,
2655        TextWrap::NoWrap,
2656        None,
2657    );
2658    (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2659}
2660
2661fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2662    match child.kind {
2663        Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2664        Kind::Math => {
2665            if let Some(expr) = &child.math {
2666                let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2667                (layout.width, layout.ascent, layout.descent)
2668            } else {
2669                (0.0, 0.0, 0.0)
2670            }
2671        }
2672        _ => (0.0, 0.0, 0.0),
2673    }
2674}
2675
2676/// Walk an Inlines paragraph's children and produce the source-order
2677/// concatenation that draw_ops will hand to the atlas. `Kind::Text`
2678/// contributes its `text` field; `Kind::HardBreak` contributes a
2679/// newline; anything else contributes nothing (an unsupported child
2680/// kind inside Inlines is a programmer error elsewhere — measurement
2681/// silently ignores it).
2682fn concat_inline_text(children: &[El]) -> String {
2683    let mut s = String::new();
2684    for c in children {
2685        match c.kind {
2686            Kind::Text => {
2687                if let Some(t) = &c.text {
2688                    s.push_str(t);
2689                }
2690            }
2691            Kind::HardBreak => s.push('\n'),
2692            _ => {}
2693        }
2694    }
2695    s
2696}
2697
2698/// Pick the font size that drives the paragraph's measurement. We use
2699/// the maximum across text children rather than the parent's own
2700/// `font_size`, because builders set sizes on the leaf text nodes.
2701fn inline_paragraph_size(node: &El) -> f32 {
2702    let mut size: f32 = node.font_size;
2703    for c in &node.children {
2704        if matches!(c.kind, Kind::Text) {
2705            size = size.max(c.font_size);
2706        }
2707    }
2708    size
2709}
2710
2711fn inline_paragraph_line_height(node: &El) -> f32 {
2712    let mut line_height: f32 = node.line_height;
2713    let mut max_size: f32 = node.font_size;
2714    for c in &node.children {
2715        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2716            max_size = c.font_size;
2717            line_height = c.line_height;
2718        }
2719    }
2720    line_height
2721}
2722
2723#[cfg(test)]
2724mod tests {
2725    use super::*;
2726    use crate::state::UiState;
2727
2728    /// CSS-flex parity: a `Size::Fill` child of a column with
2729    /// `align(Center)` should shrink to its intrinsic cross-axis size
2730    /// and be horizontally centered, matching `align-items: center`
2731    /// in CSS flex (which causes flex items to lose their stretch).
2732    #[test]
2733    fn align_center_shrinks_fill_child_to_intrinsic() {
2734        // Column with align(Center). Inner row has the default
2735        // El::new width = Fill(1.0); without Proposal B it would
2736        // claim the full 200px and align would be a no-op.
2737        let mut root = column([crate::row([crate::widgets::text::text("hi")
2738            .width(Size::Fixed(40.0))
2739            .height(Size::Fixed(20.0))])])
2740        .align(Align::Center)
2741        .width(Size::Fixed(200.0))
2742        .height(Size::Fixed(100.0));
2743        let mut state = UiState::new();
2744        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2745        let row_rect = state.rect(&root.children[0].computed_id);
2746        // Row's intrinsic width = 40 (single fixed child). 200 - 40 = 160
2747        // leftover; centered → row starts at x=80.
2748        assert!(
2749            (row_rect.x - 80.0).abs() < 0.5,
2750            "expected x≈80 (centered), got {}",
2751            row_rect.x
2752        );
2753        assert!(
2754            (row_rect.w - 40.0).abs() < 0.5,
2755            "expected w≈40 (shrunk to intrinsic), got {}",
2756            row_rect.w
2757        );
2758    }
2759
2760    /// `align(Stretch)` (the default) preserves Fill stretching: a
2761    /// Fill-width child still claims the full cross axis.
2762    #[test]
2763    fn align_stretch_preserves_fill_stretch() {
2764        let mut root = column([crate::row([crate::widgets::text::text("hi")
2765            .width(Size::Fixed(40.0))
2766            .height(Size::Fixed(20.0))])])
2767        .align(Align::Stretch)
2768        .width(Size::Fixed(200.0))
2769        .height(Size::Fixed(100.0));
2770        let mut state = UiState::new();
2771        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2772        let row_rect = state.rect(&root.children[0].computed_id);
2773        assert!(
2774            (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2775            "expected stretched (x=0, w=200), got x={} w={}",
2776            row_rect.x,
2777            row_rect.w
2778        );
2779    }
2780
2781    /// Issue #47: a Hug ancestor must measure a wrap-text descendant
2782    /// at the width layout will resolve for it — the node's own Fixed
2783    /// width — not at whatever wider width the ancestor chain has
2784    /// available. Inverted precedence here made measure wrap at the
2785    /// card's inner width while layout wrapped at the text's fixed
2786    /// width, leaving every Hug ancestor short by the difference.
2787    #[test]
2788    fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
2789        let long = "The quick brown fox jumps over the lazy dog, then \
2790                    does it again and again until the line is long \
2791                    enough to wrap several times.";
2792        // "card": Fill-wide, Hug-tall → measures its subtree. Inner
2793        // column Fixed(240); wrap text Fixed(200) — measure must use
2794        // 200, not the card's much wider inner width.
2795        let mut root = column([column([column([crate::widgets::text::text(long)
2796            .wrap_text()
2797            .width(Size::Fixed(200.0))])
2798        .width(Size::Fixed(240.0))])
2799        .width(Size::Fill(1.0))]);
2800        let mut state = UiState::new();
2801        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
2802        let card = state.rect(&root.children[0].computed_id);
2803        let text_rect = state.rect(&root.children[0].children[0].children[0].computed_id);
2804        assert!(
2805            text_rect.h > 25.0,
2806            "text should wrap to multiple lines at 200px, got h={}",
2807            text_rect.h
2808        );
2809        assert!(
2810            (card.h - text_rect.h).abs() < 0.5,
2811            "Hug card height {} must match wrapped text height {}",
2812            card.h,
2813            text_rect.h
2814        );
2815    }
2816
2817    /// When all children are Hug-sized, `Justify::Center` should split
2818    /// the leftover space symmetrically across the main axis.
2819    #[test]
2820    fn justify_center_centers_hug_children() {
2821        let mut root = column([crate::widgets::text::text("hi")
2822            .width(Size::Fixed(40.0))
2823            .height(Size::Fixed(20.0))])
2824        .justify(Justify::Center)
2825        .height(Size::Fill(1.0));
2826        let mut state = UiState::new();
2827        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2828        let child_rect = state.rect(&root.children[0].computed_id);
2829        // Expected: 100 - 20 = 80 leftover; centered → starts at y=40.
2830        assert!(
2831            (child_rect.y - 40.0).abs() < 0.5,
2832            "expected y≈40, got {}",
2833            child_rect.y
2834        );
2835    }
2836
2837    #[test]
2838    fn justify_end_pushes_to_bottom() {
2839        let mut root = column([crate::widgets::text::text("hi")
2840            .width(Size::Fixed(40.0))
2841            .height(Size::Fixed(20.0))])
2842        .justify(Justify::End)
2843        .height(Size::Fill(1.0));
2844        let mut state = UiState::new();
2845        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2846        let child_rect = state.rect(&root.children[0].computed_id);
2847        assert!(
2848            (child_rect.y - 80.0).abs() < 0.5,
2849            "expected y≈80, got {}",
2850            child_rect.y
2851        );
2852    }
2853
2854    /// CSS `justify-content: space-between`: when no main-axis Fill
2855    /// children claim the slack, the leftover space is distributed
2856    /// evenly *between* (not around) the children — outer edges flush.
2857    #[test]
2858    fn justify_space_between_distributes_evenly() {
2859        let row_child = || {
2860            crate::widgets::text::text("x")
2861                .width(Size::Fixed(20.0))
2862                .height(Size::Fixed(20.0))
2863        };
2864        let mut root = column([row_child(), row_child(), row_child()])
2865            .justify(Justify::SpaceBetween)
2866            .height(Size::Fixed(200.0));
2867        let mut state = UiState::new();
2868        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2869        // Used main = 3 * 20 = 60. Leftover = 140 over (n-1) = 2 gaps
2870        // → 70 between. Positions: 0, 90, 180.
2871        let y0 = state.rect(&root.children[0].computed_id).y;
2872        let y1 = state.rect(&root.children[1].computed_id).y;
2873        let y2 = state.rect(&root.children[2].computed_id).y;
2874        assert!(
2875            y0.abs() < 0.5,
2876            "first child should be flush at y=0, got {y0}"
2877        );
2878        assert!(
2879            (y1 - 90.0).abs() < 0.5,
2880            "middle child should be at y≈90, got {y1}"
2881        );
2882        assert!(
2883            (y2 - 180.0).abs() < 0.5,
2884            "last child should be flush at y≈180, got {y2}"
2885        );
2886    }
2887
2888    /// CSS `flex: <weight>`: when multiple `Size::Fill` children share
2889    /// a container, the available space is distributed in proportion
2890    /// to their weights.
2891    #[test]
2892    fn fill_weight_distributes_proportionally() {
2893        let big = crate::widgets::text::text("big")
2894            .width(Size::Fixed(40.0))
2895            .height(Size::Fill(2.0));
2896        let small = crate::widgets::text::text("small")
2897            .width(Size::Fixed(40.0))
2898            .height(Size::Fill(1.0));
2899        let mut root = column([big, small]).height(Size::Fixed(300.0));
2900        let mut state = UiState::new();
2901        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2902        // Total weight = 3, available = 300. Big = 200, small = 100.
2903        let big_h = state.rect(&root.children[0].computed_id).h;
2904        let small_h = state.rect(&root.children[1].computed_id).h;
2905        assert!(
2906            (big_h - 200.0).abs() < 0.5,
2907            "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2908        );
2909        assert!(
2910            (small_h - 100.0).abs() < 0.5,
2911            "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2912        );
2913    }
2914
2915    /// `padding` on a `Hug`-sized container is included in the
2916    /// container's intrinsic — matching CSS `box-sizing: content-box`
2917    /// where padding adds to the rendered size.
2918    #[test]
2919    fn padding_on_hug_includes_in_intrinsic() {
2920        let root = column([crate::widgets::text::text("x")
2921            .width(Size::Fixed(40.0))
2922            .height(Size::Fixed(40.0))])
2923        .padding(Sides::all(20.0));
2924        let (w, h) = intrinsic(&root);
2925        // 40 content + 2*20 padding on each axis = 80.
2926        assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2927        assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2928    }
2929
2930    /// Cross-axis `Align::End` on a row pins children to the bottom
2931    /// edge — CSS `align-items: flex-end`. Mirror of `justify_end`
2932    /// but on the cross axis instead of the main axis.
2933    #[test]
2934    fn align_end_pins_to_cross_axis_far_edge() {
2935        let mut root = crate::row([crate::widgets::text::text("hi")
2936            .width(Size::Fixed(40.0))
2937            .height(Size::Fixed(20.0))])
2938        .align(Align::End)
2939        .width(Size::Fixed(200.0))
2940        .height(Size::Fixed(100.0));
2941        let mut state = UiState::new();
2942        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2943        let child_rect = state.rect(&root.children[0].computed_id);
2944        // Row cross axis = height. End → child y = 100 - 20 = 80.
2945        assert!(
2946            (child_rect.y - 80.0).abs() < 0.5,
2947            "expected y≈80 (pinned to bottom), got {}",
2948            child_rect.y
2949        );
2950    }
2951
2952    #[test]
2953    fn overlay_can_center_hug_child() {
2954        let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2955            .width(Size::Fixed(200.0))
2956            .height(Size::Hug)])
2957        .align(Align::Center)
2958        .justify(Justify::Center);
2959        let mut state = UiState::new();
2960        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2961        let child_rect = state.rect(&root.children[0].computed_id);
2962        assert!(
2963            (child_rect.x - 200.0).abs() < 0.5,
2964            "expected x≈200, got {}",
2965            child_rect.x
2966        );
2967        assert!(
2968            child_rect.y > 100.0 && child_rect.y < 200.0,
2969            "expected centered y, got {}",
2970            child_rect.y
2971        );
2972    }
2973
2974    #[test]
2975    fn scroll_offset_translates_children_and_clamps_to_content() {
2976        // Six 50px-tall rows in a 200px-tall scroll viewport.
2977        // Content height = 6 * 50 + 5 * 12 (gap) = 360 px. Visible
2978        // viewport (no padding) = 200 px → max_offset = 160.
2979        let mut root = scroll(
2980            (0..6)
2981                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2982        )
2983        .key("list")
2984        .gap(12.0)
2985        .height(Size::Fixed(200.0));
2986        let mut state = UiState::new();
2987        assign_ids(&mut root);
2988        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2989
2990        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2991
2992        // Offset is in range, applied verbatim.
2993        let stored = state
2994            .scroll
2995            .offsets
2996            .get(&root.computed_id)
2997            .copied()
2998            .unwrap_or(0.0);
2999        assert!(
3000            (stored - 80.0).abs() < 0.01,
3001            "offset clamped unexpectedly: {stored}"
3002        );
3003        // First child shifted up by 80.
3004        let c0 = state.rect(&root.children[0].computed_id);
3005        assert!(
3006            (c0.y - (-80.0)).abs() < 0.01,
3007            "child 0 y = {} (expected -80)",
3008            c0.y
3009        );
3010        // Now overshoot — should clamp to max_offset=160.
3011        state
3012            .scroll
3013            .offsets
3014            .insert(root.computed_id.clone(), 9999.0);
3015        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3016        let stored = state
3017            .scroll
3018            .offsets
3019            .get(&root.computed_id)
3020            .copied()
3021            .unwrap_or(0.0);
3022        assert!(
3023            (stored - 160.0).abs() < 0.01,
3024            "overshoot clamped to {stored}"
3025        );
3026        // Content fits → offset clamps to 0.
3027        let mut tiny =
3028            scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
3029                .height(Size::Fixed(200.0));
3030        let mut tiny_state = UiState::new();
3031        assign_ids(&mut tiny);
3032        tiny_state
3033            .scroll
3034            .offsets
3035            .insert(tiny.computed_id.clone(), 50.0);
3036        layout(
3037            &mut tiny,
3038            &mut tiny_state,
3039            Rect::new(0.0, 0.0, 300.0, 200.0),
3040        );
3041        assert_eq!(
3042            tiny_state
3043                .scroll
3044                .offsets
3045                .get(&tiny.computed_id)
3046                .copied()
3047                .unwrap_or(0.0),
3048            0.0
3049        );
3050    }
3051
3052    #[test]
3053    fn scroll_layout_prunes_far_offscreen_descendants() {
3054        let far = column([crate::widgets::text::text("far row body").key("far-text")])
3055            .height(Size::Fixed(40.0));
3056        let mut root = scroll([
3057            column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
3058            crate::tree::spacer().height(Size::Fixed(400.0)),
3059            far,
3060        ])
3061        .key("list")
3062        .height(Size::Fixed(80.0));
3063        let mut state = UiState::new();
3064        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
3065        let stats = take_prune_stats();
3066
3067        assert!(
3068            stats.subtrees >= 1,
3069            "expected at least one far scroll child to be pruned, got {stats:?}"
3070        );
3071        assert!(
3072            stats.nodes >= 1,
3073            "expected pruned descendants to be zeroed, got {stats:?}"
3074        );
3075        let far_text = state
3076            .rect_of_key("far-text")
3077            .expect("far text keeps a zero rect while pruned");
3078        assert_eq!(far_text.w, 0.0);
3079        assert_eq!(far_text.h, 0.0);
3080    }
3081
3082    #[test]
3083    fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
3084        let make_root = || {
3085            let paragraph_text = "Variable width text wraps into a different number of lines when \
3086                                  the viewport narrows, which used to make a plain scroll box lose \
3087                                  the item the user was reading.";
3088            scroll([column((0..30).map(|i| {
3089                crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
3090                    .key(format!("paragraph-{i}"))
3091            }))
3092            .gap(8.0)])
3093            .key("article")
3094            .height(Size::Fixed(180.0))
3095        };
3096
3097        let mut root = make_root();
3098        let mut state = UiState::new();
3099        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3100
3101        state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
3102        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3103
3104        let anchor = state
3105            .scroll
3106            .scroll_anchors
3107            .get(&root.computed_id)
3108            .cloned()
3109            .expect("plain scroll should store a visible descendant anchor");
3110        let before_rect = state.rect(&anchor.node_id);
3111        let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
3112        let before_offset = state.scroll_offset(&root.computed_id);
3113
3114        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
3115
3116        let after_rect = state.rect(&anchor.node_id);
3117        let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
3118        let after_offset = state.scroll_offset(&root.computed_id);
3119        assert!(
3120            (after_anchor_y - before_anchor_y).abs() < 0.5,
3121            "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
3122        );
3123        assert!(
3124            (after_offset - before_offset).abs() > 20.0,
3125            "offset should absorb height changes above the anchor"
3126        );
3127    }
3128
3129    #[test]
3130    fn scrollbar_thumb_size_and_position_track_overflow() {
3131        // 6 rows x 50px + 5 gaps x 12 = 360 content; 200 viewport.
3132        // viewport/content = 200/360 ≈ 0.555 → thumb_h ≈ 111.1.
3133        let mut root = scroll(
3134            (0..6)
3135                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3136        )
3137        .gap(12.0)
3138        .height(Size::Fixed(200.0));
3139        let mut state = UiState::new();
3140        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3141
3142        let metrics = state
3143            .scroll
3144            .metrics
3145            .get(&root.computed_id)
3146            .copied()
3147            .expect("scrollable should have metrics");
3148        assert!((metrics.viewport_h - 200.0).abs() < 0.01);
3149        assert!((metrics.content_h - 360.0).abs() < 0.01);
3150        assert!((metrics.max_offset - 160.0).abs() < 0.01);
3151
3152        let thumb = state
3153            .scroll
3154            .thumb_rects
3155            .get(&root.computed_id)
3156            .copied()
3157            .expect("scrollable with scrollbar() and overflow gets a thumb");
3158        // viewport^2 / content_h = 200^2 / 360 = 111.11..
3159        assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
3160        assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
3161        // At offset 0, thumb sits at the top of the inner rect.
3162        assert!(thumb.y.abs() < 0.01);
3163        // Right-anchored: thumb_x + thumb_w + track_inset == viewport_right.
3164        assert!(
3165            (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
3166            "thumb anchored at {} (expected {})",
3167            thumb.x,
3168            300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
3169        );
3170
3171        // Slide to half — thumb should be at half the track_remaining.
3172        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
3173        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3174        let thumb = state
3175            .scroll
3176            .thumb_rects
3177            .get(&root.computed_id)
3178            .copied()
3179            .unwrap();
3180        let track_remaining = 200.0 - thumb.h;
3181        let expected_y = track_remaining * (80.0 / 160.0);
3182        assert!(
3183            (thumb.y - expected_y).abs() < 0.5,
3184            "thumb at half-scroll y = {} (expected {expected_y})",
3185            thumb.y,
3186        );
3187    }
3188
3189    #[test]
3190    fn scrollbar_track_is_wider_than_thumb_and_full_height() {
3191        // The track is the click hitbox: wider than the visible
3192        // thumb (Fitts's law) and tall enough to detect track
3193        // clicks above and below the thumb for paging.
3194        let mut root = scroll(
3195            (0..6)
3196                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3197        )
3198        .gap(12.0)
3199        .height(Size::Fixed(200.0));
3200        let mut state = UiState::new();
3201        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3202
3203        let thumb = state
3204            .scroll
3205            .thumb_rects
3206            .get(&root.computed_id)
3207            .copied()
3208            .unwrap();
3209        let track = state
3210            .scroll
3211            .thumb_tracks
3212            .get(&root.computed_id)
3213            .copied()
3214            .unwrap();
3215        // Track wider than thumb on the same right edge.
3216        assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
3217        assert!(
3218            (track.right() - thumb.right()).abs() < 0.01,
3219            "track and thumb must share the right edge",
3220        );
3221        // Track spans the full inner viewport (so above/below thumb
3222        // are both inside it for click-to-page).
3223        assert!(
3224            (track.h - 200.0).abs() < 0.01,
3225            "track height = {} (expected 200)",
3226            track.h,
3227        );
3228    }
3229
3230    #[test]
3231    fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
3232        // Same scrollable, but author opted out — no thumb_rect.
3233        let mut suppressed = scroll(
3234            (0..6)
3235                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3236        )
3237        .no_scrollbar()
3238        .height(Size::Fixed(200.0));
3239        let mut state = UiState::new();
3240        layout(
3241            &mut suppressed,
3242            &mut state,
3243            Rect::new(0.0, 0.0, 300.0, 200.0),
3244        );
3245        assert!(
3246            !state
3247                .scroll
3248                .thumb_rects
3249                .contains_key(&suppressed.computed_id)
3250        );
3251
3252        // Same scrollable, content fits → no thumb either.
3253        let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
3254            .height(Size::Fixed(200.0));
3255        let mut tiny_state = UiState::new();
3256        layout(
3257            &mut tiny,
3258            &mut tiny_state,
3259            Rect::new(0.0, 0.0, 300.0, 200.0),
3260        );
3261        assert!(
3262            !tiny_state
3263                .scroll
3264                .thumb_rects
3265                .contains_key(&tiny.computed_id)
3266        );
3267    }
3268
3269    #[test]
3270    fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
3271        let make_root = || {
3272            scroll([
3273                crate::tree::spacer().height(Size::Fixed(80.0)),
3274                scroll((0..6).map(|i| {
3275                    crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
3276                }))
3277                .key("inner")
3278                .height(Size::Fixed(120.0)),
3279                crate::tree::spacer().height(Size::Fixed(260.0)),
3280            ])
3281            .key("outer")
3282            .height(Size::Fixed(220.0))
3283        };
3284
3285        let mut root = make_root();
3286        let mut state = UiState::new();
3287        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3288        let inner = root
3289            .children
3290            .iter()
3291            .find(|child| child.key.as_deref() == Some("inner"))
3292            .expect("inner scroll");
3293        let inner_id = inner.computed_id.clone();
3294        let inner_rect = state.rect(&inner_id);
3295        let thumb = state
3296            .scroll
3297            .thumb_rects
3298            .get(&inner_id)
3299            .copied()
3300            .expect("inner scroll should have a thumb");
3301        let track = state
3302            .scroll
3303            .thumb_tracks
3304            .get(&inner_id)
3305            .copied()
3306            .expect("inner scroll should have a track");
3307        let thumb_rel_y = thumb.y - inner_rect.y;
3308        let track_rel_y = track.y - inner_rect.y;
3309
3310        state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3311        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3312        let inner_rect_after = state.rect(&inner_id);
3313        let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3314        let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3315
3316        assert!(
3317            (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3318            "outer scroll should shift the inner viewport"
3319        );
3320        assert!(
3321            (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3322            "inner thumb should stay fixed relative to its viewport"
3323        );
3324        assert!(
3325            (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3326            "inner track should stay fixed relative to its viewport"
3327        );
3328    }
3329
3330    #[test]
3331    fn layout_override_places_children_at_returned_rects() {
3332        // A custom layout that just stacks children diagonally inside the container.
3333        let mut root = column((0..3).map(|i| {
3334            crate::widgets::text::text(format!("dot {i}"))
3335                .width(Size::Fixed(20.0))
3336                .height(Size::Fixed(20.0))
3337        }))
3338        .width(Size::Fixed(200.0))
3339        .height(Size::Fixed(200.0))
3340        .layout(|ctx| {
3341            ctx.children
3342                .iter()
3343                .enumerate()
3344                .map(|(i, _)| {
3345                    let off = i as f32 * 30.0;
3346                    Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3347                })
3348                .collect()
3349        });
3350        let mut state = UiState::new();
3351        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3352        let r0 = state.rect(&root.children[0].computed_id);
3353        let r1 = state.rect(&root.children[1].computed_id);
3354        let r2 = state.rect(&root.children[2].computed_id);
3355        assert_eq!((r0.x, r0.y), (0.0, 0.0));
3356        assert_eq!((r1.x, r1.y), (30.0, 30.0));
3357        assert_eq!((r2.x, r2.y), (60.0, 60.0));
3358    }
3359
3360    #[test]
3361    fn layout_override_rect_of_key_resolves_earlier_sibling() {
3362        // The popover-anchor pattern: a custom-laid-out node positions
3363        // its child by reading another keyed node's rect via the new
3364        // LayoutCtx::rect_of_key callback. The trigger lives in an
3365        // earlier sibling so its rect is already in `computed_rects`
3366        // by the time the popover layer's layout_override runs.
3367        use crate::tree::stack;
3368        let trigger_x = 40.0;
3369        let trigger_y = 20.0;
3370        let trigger_w = 60.0;
3371        let trigger_h = 30.0;
3372        let mut root = stack([
3373            // Earlier sibling: the trigger.
3374            crate::widgets::button::button("Open")
3375                .key("trig")
3376                .width(Size::Fixed(trigger_w))
3377                .height(Size::Fixed(trigger_h)),
3378            // Later sibling: a custom-laid-out container that reads
3379            // the trigger's rect to position its single child.
3380            stack([crate::widgets::text::text("popover")
3381                .width(Size::Fixed(80.0))
3382                .height(Size::Fixed(20.0))])
3383            .width(Size::Fill(1.0))
3384            .height(Size::Fill(1.0))
3385            .layout(|ctx| {
3386                let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3387                vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3388            }),
3389        ])
3390        .padding(Sides::xy(trigger_x, trigger_y));
3391        let mut state = UiState::new();
3392        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3393
3394        let popover_layer = &root.children[1];
3395        let panel_id = &popover_layer.children[0].computed_id;
3396        let panel_rect = state.rect(panel_id);
3397        // Anchored to (trigger.x, trigger.bottom() + 4.0). With padding
3398        // (40, 20) and trigger height 30 → expect (40, 54).
3399        assert!(
3400            (panel_rect.x - trigger_x).abs() < 0.01,
3401            "popover x = {} (expected {trigger_x})",
3402            panel_rect.x,
3403        );
3404        assert!(
3405            (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3406            "popover y = {} (expected {})",
3407            panel_rect.y,
3408            trigger_y + trigger_h + 4.0,
3409        );
3410    }
3411
3412    #[test]
3413    fn layout_override_rect_of_key_returns_none_for_missing_key() {
3414        let mut root = column([crate::widgets::text::text("inner")
3415            .width(Size::Fixed(40.0))
3416            .height(Size::Fixed(20.0))])
3417        .width(Size::Fixed(200.0))
3418        .height(Size::Fixed(200.0))
3419        .layout(|ctx| {
3420            assert!((ctx.rect_of_key)("nope").is_none());
3421            vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3422        });
3423        let mut state = UiState::new();
3424        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3425    }
3426
3427    #[test]
3428    fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3429        // First-frame contract: a custom layout running before its
3430        // target's sibling has been laid out should see `None`, not a
3431        // zero rect or a panic. This is what makes the popover pattern
3432        // (trigger first, popover layer second in source order) the
3433        // supported shape — the reverse direction simply gets `None`.
3434        use crate::tree::stack;
3435        let mut root = stack([
3436            stack([crate::widgets::text::text("panel")
3437                .width(Size::Fixed(40.0))
3438                .height(Size::Fixed(20.0))])
3439            .width(Size::Fill(1.0))
3440            .height(Size::Fill(1.0))
3441            .layout(|ctx| {
3442                assert!(
3443                    (ctx.rect_of_key)("later").is_none(),
3444                    "later sibling's rect must not be available yet"
3445                );
3446                vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3447            }),
3448            crate::widgets::button::button("after").key("later"),
3449        ]);
3450        let mut state = UiState::new();
3451        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3452    }
3453
3454    #[test]
3455    fn layout_override_measure_returns_intrinsic() {
3456        // The custom layout reads `measure` to size each child.
3457        let mut root = column([crate::widgets::text::text("hi")
3458            .width(Size::Fixed(40.0))
3459            .height(Size::Fixed(20.0))])
3460        .width(Size::Fixed(200.0))
3461        .height(Size::Fixed(200.0))
3462        .layout(|ctx| {
3463            let (w, h) = (ctx.measure)(&ctx.children[0]);
3464            assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3465            assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3466            vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3467        });
3468        let mut state = UiState::new();
3469        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3470        let r = state.rect(&root.children[0].computed_id);
3471        assert_eq!((r.w, r.h), (40.0, 20.0));
3472    }
3473
3474    #[test]
3475    #[should_panic(expected = "returned 1 rects for 2 children")]
3476    fn layout_override_length_mismatch_panics() {
3477        let mut root = column([
3478            crate::widgets::text::text("a")
3479                .width(Size::Fixed(10.0))
3480                .height(Size::Fixed(10.0)),
3481            crate::widgets::text::text("b")
3482                .width(Size::Fixed(10.0))
3483                .height(Size::Fixed(10.0)),
3484        ])
3485        .width(Size::Fixed(200.0))
3486        .height(Size::Fixed(200.0))
3487        .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3488        let mut state = UiState::new();
3489        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3490    }
3491
3492    #[test]
3493    #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3494    fn layout_override_hug_panics() {
3495        // Hug check fires when the parent's layout pass measures the
3496        // custom-layout child for sizing — i.e. when a layout_override
3497        // node is a child of a column/row, not when it's the root.
3498        let mut root = column([column([crate::widgets::text::text("c")])
3499            .width(Size::Hug)
3500            .height(Size::Fixed(200.0))
3501            .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3502        .width(Size::Fixed(200.0))
3503        .height(Size::Fixed(200.0));
3504        let mut state = UiState::new();
3505        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3506    }
3507
3508    #[test]
3509    fn virtual_list_realizes_only_visible_rows() {
3510        // 100 rows × 50px each in a 200px viewport, offset = 120.
3511        // Visible range: rows whose y in [-50, 200) → start = floor(120/50) = 2,
3512        // end = ceil((120+200)/50) = ceil(6.4) = 7. Five rows realized.
3513        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3514            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3515        });
3516        let mut state = UiState::new();
3517        assign_ids(&mut root);
3518        state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3519        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3520
3521        assert_eq!(
3522            root.children.len(),
3523            5,
3524            "expected 5 realized rows, got {}",
3525            root.children.len()
3526        );
3527        // Identity check: the first realized row should be the row keyed "row-2".
3528        assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3529        assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3530        // Position check: first realized row's y = inner.y + 2*50 - 120 = -20.
3531        let r0 = state.rect(&root.children[0].computed_id);
3532        assert!(
3533            (r0.y - (-20.0)).abs() < 0.5,
3534            "row 2 expected y≈-20, got {}",
3535            r0.y
3536        );
3537    }
3538
3539    #[test]
3540    fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
3541        let mut root = crate::tree::virtual_list(10, 40.0, |i| {
3542            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3543        })
3544        .gap(10.0);
3545        let mut state = UiState::new();
3546        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3547
3548        assert_eq!(
3549            root.children.len(),
3550            3,
3551            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3552        );
3553        let row_1 = root
3554            .children
3555            .iter()
3556            .find(|c| c.key.as_deref() == Some("row-1"))
3557            .expect("row 1 should be realized");
3558        assert!(
3559            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3560            "gap should place row 1 at y=50"
3561        );
3562        let metrics = state
3563            .scroll
3564            .metrics
3565            .get(&root.computed_id)
3566            .expect("virtual list writes scroll metrics");
3567        assert!(
3568            (metrics.content_h - 490.0).abs() < 0.5,
3569            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3570            metrics.content_h
3571        );
3572    }
3573
3574    #[test]
3575    fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3576        let make_root = || {
3577            crate::tree::virtual_list(50, 50.0, |i| {
3578                crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3579            })
3580        };
3581
3582        let mut state = UiState::new();
3583        let mut root_a = make_root();
3584        assign_ids(&mut root_a);
3585        // Scroll so row 5 is visible.
3586        state
3587            .scroll
3588            .offsets
3589            .insert(root_a.computed_id.clone(), 250.0);
3590        layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3591        let id_at_offset_a = root_a
3592            .children
3593            .iter()
3594            .find(|c| c.key.as_deref() == Some("row-5"))
3595            .unwrap()
3596            .computed_id
3597            .clone();
3598
3599        // Re-layout with a different offset — row 5 is still visible.
3600        let mut root_b = make_root();
3601        assign_ids(&mut root_b);
3602        state
3603            .scroll
3604            .offsets
3605            .insert(root_b.computed_id.clone(), 200.0);
3606        layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3607        let id_at_offset_b = root_b
3608            .children
3609            .iter()
3610            .find(|c| c.key.as_deref() == Some("row-5"))
3611            .unwrap()
3612            .computed_id
3613            .clone();
3614
3615        assert_eq!(
3616            id_at_offset_a, id_at_offset_b,
3617            "row-5's computed_id changed when scroll offset moved"
3618        );
3619    }
3620
3621    #[test]
3622    fn virtual_list_clamps_overshoot_offset() {
3623        // 10 rows × 50 = 500 content height; viewport 200; max offset = 300.
3624        let mut root =
3625            crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3626        let mut state = UiState::new();
3627        assign_ids(&mut root);
3628        state
3629            .scroll
3630            .offsets
3631            .insert(root.computed_id.clone(), 9999.0);
3632        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3633        let stored = state
3634            .scroll
3635            .offsets
3636            .get(&root.computed_id)
3637            .copied()
3638            .unwrap_or(0.0);
3639        assert!(
3640            (stored - 300.0).abs() < 0.01,
3641            "expected clamp to 300, got {stored}"
3642        );
3643    }
3644
3645    #[test]
3646    fn virtual_list_empty_count_realizes_no_children() {
3647        let mut root =
3648            crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3649        let mut state = UiState::new();
3650        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3651        assert_eq!(root.children.len(), 0);
3652    }
3653
3654    #[test]
3655    #[should_panic(expected = "row_height > 0.0")]
3656    fn virtual_list_zero_row_height_panics() {
3657        let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3658    }
3659
3660    #[test]
3661    #[should_panic(expected = "Size::Hug would defeat virtualization")]
3662    fn virtual_list_hug_panics() {
3663        let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3664            crate::widgets::text::text(format!("r{i}"))
3665        })
3666        .height(Size::Hug)])
3667        .width(Size::Fixed(300.0))
3668        .height(Size::Fixed(200.0));
3669        let mut state = UiState::new();
3670        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3671    }
3672
3673    #[test]
3674    fn virtual_list_dyn_respects_per_row_fixed_heights() {
3675        // Alternating 40px / 80px rows. With a 200px viewport and offset 0,
3676        // accumulated y goes 0, 40, 120, 160, 240 — the fifth row starts
3677        // past the viewport, so four rows are realized.
3678        let mut root = crate::tree::virtual_list_dyn(
3679            20,
3680            50.0,
3681            |i| format!("row-{i}"),
3682            |i| {
3683                let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3684                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3685                    .key(format!("row-{i}"))
3686                    .height(Size::Fixed(h))
3687            },
3688        );
3689        let mut state = UiState::new();
3690        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3691
3692        assert_eq!(
3693            root.children.len(),
3694            4,
3695            "expected 4 realized rows, got {}",
3696            root.children.len()
3697        );
3698        // y positions: row 0 → 0, row 1 → 40, row 2 → 120, row 3 → 160.
3699        let ys: Vec<f32> = root
3700            .children
3701            .iter()
3702            .map(|c| state.rect(&c.computed_id).y)
3703            .collect();
3704        assert!(
3705            (ys[0] - 0.0).abs() < 0.5,
3706            "row 0 expected y≈0, got {}",
3707            ys[0]
3708        );
3709        assert!(
3710            (ys[1] - 40.0).abs() < 0.5,
3711            "row 1 expected y≈40, got {}",
3712            ys[1]
3713        );
3714        assert!(
3715            (ys[2] - 120.0).abs() < 0.5,
3716            "row 2 expected y≈120, got {}",
3717            ys[2]
3718        );
3719        assert!(
3720            (ys[3] - 160.0).abs() < 0.5,
3721            "row 3 expected y≈160, got {}",
3722            ys[3]
3723        );
3724    }
3725
3726    #[test]
3727    fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3728        let mut root = crate::tree::virtual_list_dyn(
3729            10,
3730            40.0,
3731            |i| format!("row-{i}"),
3732            |i| {
3733                crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3734                    .key(format!("row-{i}"))
3735                    .height(Size::Fixed(40.0))
3736            },
3737        )
3738        .gap(10.0);
3739        let mut state = UiState::new();
3740        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3741
3742        assert_eq!(
3743            root.children.len(),
3744            3,
3745            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3746        );
3747        let row_1 = root
3748            .children
3749            .iter()
3750            .find(|c| c.key.as_deref() == Some("row-1"))
3751            .expect("row 1 should be realized");
3752        assert!(
3753            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3754            "gap should place row 1 at y=50"
3755        );
3756        let metrics = state
3757            .scroll
3758            .metrics
3759            .get(&root.computed_id)
3760            .expect("virtual list writes scroll metrics");
3761        assert!(
3762            (metrics.content_h - 490.0).abs() < 0.5,
3763            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3764            metrics.content_h
3765        );
3766    }
3767
3768    #[test]
3769    fn virtual_list_dyn_caches_measured_heights() {
3770        // Build a list where the first frame realizes rows 0..k, measuring
3771        // each. After layout the cache should hold those measurements and
3772        // the next frame should read them.
3773        let mut root = crate::tree::virtual_list_dyn(
3774            50,
3775            50.0,
3776            |i| format!("row-{i}"),
3777            |i| {
3778                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3779                    .key(format!("row-{i}"))
3780                    .height(Size::Fixed(30.0))
3781            },
3782        );
3783        let mut state = UiState::new();
3784        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3785
3786        let measured = state
3787            .scroll
3788            .measured_row_heights
3789            .get(&root.computed_id)
3790            .expect("dynamic virtual list should populate the height cache");
3791        // The first pass measures the estimate-derived window, then
3792        // the anchored final pass can extend it with newly revealed
3793        // rows. At least six rows are visible/cached here.
3794        assert!(
3795            measured.len() >= 6,
3796            "expected ≥ 6 cached row heights, got {}",
3797            measured.len()
3798        );
3799        for by_width in measured.values() {
3800            let h = by_width
3801                .get(&300)
3802                .copied()
3803                .expect("measurement should be keyed at the 300px width bucket");
3804            assert!(
3805                (h - 30.0).abs() < 0.5,
3806                "expected cached height ≈ 30, got {h}"
3807            );
3808        }
3809    }
3810
3811    #[test]
3812    fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3813        let make_root = || {
3814            crate::tree::virtual_list_dyn(
3815                100,
3816                40.0,
3817                |i| format!("row-{i}"),
3818                |i| {
3819                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3820                        .key(format!("row-{i}"))
3821                        .height(Size::Fixed(40.0))
3822                },
3823            )
3824        };
3825        let mut root = make_root();
3826        let mut state = UiState::new();
3827        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3828
3829        state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3830        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3831
3832        let anchor = state
3833            .scroll
3834            .virtual_anchors
3835            .get(&root.computed_id)
3836            .cloned()
3837            .expect("dynamic list should store a visible anchor");
3838        let before_y = root
3839            .children
3840            .iter()
3841            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3842            .map(|child| state.rect(&child.computed_id).y)
3843            .expect("anchor row should be realized");
3844        let before_offset = state.scroll_offset(&root.computed_id);
3845
3846        state
3847            .scroll
3848            .measured_row_heights
3849            .entry(root.computed_id.clone())
3850            .or_default()
3851            .entry("row-0".to_string())
3852            .or_default()
3853            .insert(300, 120.0);
3854
3855        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3856        let after_y = root
3857            .children
3858            .iter()
3859            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3860            .map(|child| state.rect(&child.computed_id).y)
3861            .expect("anchor row should remain realized");
3862        let after_offset = state.scroll_offset(&root.computed_id);
3863
3864        assert!(
3865            (after_y - before_y).abs() < 0.5,
3866            "anchor row should stay at y={before_y}, got {after_y}"
3867        );
3868        assert!(
3869            (after_offset - (before_offset + 80.0)).abs() < 0.5,
3870            "offset should absorb the 80px measurement delta above anchor"
3871        );
3872    }
3873
3874    #[test]
3875    fn virtual_list_dyn_height_cache_is_width_bucketed() {
3876        let mut root = crate::tree::virtual_list_dyn(
3877            20,
3878            50.0,
3879            |i| format!("row-{i}"),
3880            |i| {
3881                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3882                    .key(format!("row-{i}"))
3883                    .height(Size::Fixed(30.0))
3884            },
3885        );
3886        let mut state = UiState::new();
3887        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3888        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3889
3890        let row_0 = state
3891            .scroll
3892            .measured_row_heights
3893            .get(&root.computed_id)
3894            .and_then(|m| m.get("row-0"))
3895            .expect("row 0 should be measured");
3896        assert!(
3897            row_0.contains_key(&300) && row_0.contains_key(&240),
3898            "expected width buckets 300 and 240, got {:?}",
3899            row_0.keys().collect::<Vec<_>>()
3900        );
3901    }
3902
3903    #[test]
3904    fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3905        // Measured rows use their cached fixed 30px height; rows that
3906        // have not been seen at this width still use the 50px estimate.
3907        // An overshoot offset must clamp to the mixed measured/estimated
3908        // content height after the final visible measurements land.
3909        let make_root = || {
3910            crate::tree::virtual_list_dyn(
3911                20,
3912                50.0,
3913                |i| format!("row-{i}"),
3914                |i| {
3915                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3916                        .key(format!("row-{i}"))
3917                        .height(Size::Fixed(30.0))
3918                },
3919            )
3920        };
3921        let mut state = UiState::new();
3922        let mut root = make_root();
3923        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3924
3925        state
3926            .scroll
3927            .offsets
3928            .insert(root.computed_id.clone(), 9999.0);
3929        let mut root2 = make_root();
3930        layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3931
3932        let measured = state
3933            .scroll
3934            .measured_row_heights
3935            .get(&root2.computed_id)
3936            .expect("dynamic virtual list should populate the height cache");
3937        let measured_sum = measured
3938            .values()
3939            .filter_map(|by_width| by_width.get(&300))
3940            .sum::<f32>();
3941        let measured_count = measured
3942            .values()
3943            .filter(|by_width| by_width.contains_key(&300))
3944            .count();
3945        let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3946        let expected_max_offset = expected_total - 200.0;
3947
3948        let stored = state
3949            .scroll
3950            .offsets
3951            .get(&root2.computed_id)
3952            .copied()
3953            .unwrap_or(0.0);
3954        assert!(
3955            (stored - expected_max_offset).abs() < 0.5,
3956            "expected offset clamped to {expected_max_offset}, got {stored}"
3957        );
3958    }
3959
3960    #[test]
3961    fn virtual_list_dyn_empty_count_realizes_no_children() {
3962        let mut root = crate::tree::virtual_list_dyn(
3963            0,
3964            50.0,
3965            |i| format!("row-{i}"),
3966            |i| crate::widgets::text::text(format!("r{i}")),
3967        );
3968        let mut state = UiState::new();
3969        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3970        assert_eq!(root.children.len(), 0);
3971    }
3972
3973    #[test]
3974    #[should_panic(expected = "estimated_row_height > 0.0")]
3975    fn virtual_list_dyn_zero_estimate_panics() {
3976        let _ = crate::tree::virtual_list_dyn(
3977            10,
3978            0.0,
3979            |i| format!("row-{i}"),
3980            |i| crate::widgets::text::text(format!("r{i}")),
3981        );
3982    }
3983
3984    #[test]
3985    fn text_runs_constructor_shape_smoke() {
3986        let el = crate::tree::text_runs([
3987            crate::widgets::text::text("Hello, "),
3988            crate::widgets::text::text("world").bold(),
3989            crate::tree::hard_break(),
3990            crate::widgets::text::text("of text").italic(),
3991        ]);
3992        assert_eq!(el.kind, Kind::Inlines);
3993        assert_eq!(el.children.len(), 4);
3994        assert!(matches!(
3995            el.children[1].font_weight,
3996            FontWeight::Bold | FontWeight::Semibold
3997        ));
3998        assert_eq!(el.children[2].kind, Kind::HardBreak);
3999        assert!(el.children[3].text_italic);
4000    }
4001
4002    #[test]
4003    fn wrapped_text_hugs_multiline_height_from_available_width() {
4004        let mut root = column([crate::paragraph(
4005            "A longer sentence should wrap into multiple measured lines.",
4006        )])
4007        .width(Size::Fill(1.0))
4008        .height(Size::Hug);
4009
4010        let mut state = UiState::new();
4011        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
4012
4013        let child_rect = state.rect(&root.children[0].computed_id);
4014        assert_eq!(child_rect.w, 180.0);
4015        assert!(
4016            child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4017            "expected multiline paragraph height, got {}",
4018            child_rect.h
4019        );
4020    }
4021
4022    #[test]
4023    fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
4024        // Regression: overlay_rect used to call `intrinsic(c)` with no
4025        // width hint, so a Fixed-width modal containing a wrappable
4026        // paragraph measured the paragraph as a single line — leaving
4027        // the modal's Hug height short by the wrapped lines and
4028        // crowding the buttons against the bottom edge of the panel
4029        // (rumble cert-pending modal showed this).
4030        //
4031        // The fix: pass the child's resolved width as the available
4032        // width for intrinsic measurement, mirroring what column/row
4033        // already do.
4034        const PANEL_W: f32 = 240.0;
4035        const PADDING: f32 = 18.0;
4036        const GAP: f32 = 12.0;
4037
4038        let panel = column([
4039            crate::paragraph(
4040                "A long enough warning paragraph that it has to wrap onto a second line \
4041                 inside this narrow panel.",
4042            ),
4043            crate::widgets::button::button("OK").key("ok"),
4044        ])
4045        .width(Size::Fixed(PANEL_W))
4046        .height(Size::Hug)
4047        .padding(Sides::all(PADDING))
4048        .gap(GAP)
4049        .align(Align::Stretch);
4050
4051        let mut root = crate::stack([panel])
4052            .width(Size::Fill(1.0))
4053            .height(Size::Fill(1.0));
4054        let mut state = UiState::new();
4055        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4056
4057        let panel_rect = state.rect(&root.children[0].computed_id);
4058        assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
4059
4060        let para_rect = state.rect(&root.children[0].children[0].computed_id);
4061        let button_rect = state.rect(&root.children[0].children[1].computed_id);
4062
4063        // Paragraph wrapped to ≥ 2 lines (exact line count depends on
4064        // glyph metrics; just guard against the single-line bug).
4065        assert!(
4066            para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4067            "paragraph should wrap to multiple lines inside the Fixed-width panel; \
4068             got h={}",
4069            para_rect.h
4070        );
4071
4072        // Panel height must accommodate top padding + paragraph +
4073        // gap + button + bottom padding. The bug was that the panel
4074        // came out exactly `padding + gap + 1-line-paragraph + button`
4075        // — short by the second wrap line — and the button overshot
4076        // the inner area, leaving zero pixels of bottom padding.
4077        let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
4078        assert!(
4079            (bottom_padding - PADDING).abs() < 0.5,
4080            "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
4081        );
4082    }
4083
4084    #[test]
4085    fn row_with_fill_paragraph_propagates_height_to_parent_column() {
4086        // Regression: the Row branch of `intrinsic_constrained` called
4087        // `intrinsic(ch)` unconstrained, so a wrappable Fill child
4088        // (paragraph) measured as a single unwrapped line. Two such rows
4089        // in a column then got one-line-tall allocations and the second
4090        // row's gutter rect overlapped the first row's wrapped text
4091        // (chat-port event-log recipe in damascene-core/README.md hit this).
4092        //
4093        // The fix mirrors `layout_axis`: the Row intrinsic distributes
4094        // its available width across Fill children before measuring,
4095        // so wrappable Fill children see the width they will actually
4096        // be laid out at.
4097        const COL_W: f32 = 600.0;
4098        const GUTTER_W: f32 = 3.0;
4099
4100        let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
4101                    sed do eiusmod tempor incididunt ut labore et dolore magna \
4102                    aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
4103                    ullamco laboris nisi ut aliquip ex ea commodo consequat.";
4104
4105        let make_row = || {
4106            let gutter = El::new(Kind::Custom("gutter"))
4107                .width(Size::Fixed(GUTTER_W))
4108                .height(Size::Fill(1.0));
4109            let body = crate::paragraph(long).width(Size::Fill(1.0));
4110            crate::row([gutter, body]).width(Size::Fill(1.0))
4111        };
4112
4113        let mut root = column([make_row(), make_row()])
4114            .width(Size::Fixed(COL_W))
4115            .height(Size::Hug)
4116            .align(Align::Stretch);
4117        let mut state = UiState::new();
4118        layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
4119
4120        let row0_rect = state.rect(&root.children[0].computed_id);
4121        let row1_rect = state.rect(&root.children[1].computed_id);
4122        let para0_rect = state.rect(&root.children[0].children[1].computed_id);
4123
4124        // Both the paragraph rect and the row rect must reflect the
4125        // wrapped (multi-line) height. The bug pinned them to a single
4126        // line (~`TEXT_SM.line_height` = 20px), so the wrapped text
4127        // painted outside the row's allocated rect.
4128        let line_height = crate::tokens::TEXT_SM.line_height;
4129        assert!(
4130            para0_rect.h > line_height * 1.5,
4131            "paragraph should wrap to multiple lines at ~597px wide; \
4132             got h={} (line_height={})",
4133            para0_rect.h,
4134            line_height,
4135        );
4136        assert!(
4137            row0_rect.h > line_height * 1.5,
4138            "row 0 should accommodate the wrapped paragraph height; \
4139             got h={} (line_height={})",
4140            row0_rect.h,
4141            line_height,
4142        );
4143
4144        // Sanity: row 1 sits below row 0's allocated rect, not above it.
4145        assert!(
4146            row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
4147            "row 1 starts at y={} but row 0 occupies y={}..{}",
4148            row1_rect.y,
4149            row0_rect.y,
4150            row0_rect.y + row0_rect.h,
4151        );
4152    }
4153
4154    /// `min_width` floors a child whose resolved cross-axis size is
4155    /// below the floor. Tests against an `align(Start)` column so
4156    /// `Size::Fixed` doesn't get widened by the default Stretch
4157    /// alignment before clamping has a chance to apply.
4158    #[test]
4159    fn min_width_floors_resolved_cross_axis_size() {
4160        let mut root = column([crate::widgets::text::text("hi")
4161            .width(Size::Fixed(40.0))
4162            .height(Size::Fixed(20.0))
4163            .min_width(120.0)])
4164        .align(Align::Start)
4165        .width(Size::Fixed(500.0))
4166        .height(Size::Fixed(200.0));
4167        let mut state = UiState::new();
4168        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4169        let child_rect = state.rect(&root.children[0].computed_id);
4170        assert!(
4171            (child_rect.w - 120.0).abs() < 0.5,
4172            "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
4173            child_rect.w,
4174        );
4175    }
4176
4177    /// `max_width` caps a `Size::Fill` child even when the surrounding
4178    /// row offers more space.
4179    #[test]
4180    fn max_width_caps_fill_child() {
4181        let mut root = crate::row([crate::widgets::text::text("body")
4182            .width(Size::Fill(1.0))
4183            .height(Size::Fixed(20.0))
4184            .max_width(160.0)])
4185        .width(Size::Fixed(800.0))
4186        .height(Size::Fixed(40.0));
4187        let mut state = UiState::new();
4188        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
4189        let child_rect = state.rect(&root.children[0].computed_id);
4190        assert!(
4191            (child_rect.w - 160.0).abs() < 0.5,
4192            "expected Fill child capped at 160, got w={}",
4193            child_rect.w,
4194        );
4195    }
4196
4197    /// When `min_width` and `max_width` conflict, the lower bound wins
4198    /// (CSS `min-width` precedence over `max-width`).
4199    #[test]
4200    fn min_width_wins_over_max_width_when_conflicting() {
4201        let mut root = column([crate::widgets::text::text("x")
4202            .width(Size::Fixed(50.0))
4203            .height(Size::Fixed(20.0))
4204            .max_width(80.0)
4205            .min_width(120.0)]);
4206        let mut state = UiState::new();
4207        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4208        let child_rect = state.rect(&root.children[0].computed_id);
4209        assert!(
4210            (child_rect.w - 120.0).abs() < 0.5,
4211            "expected min_width (120) to win over max_width (80), got w={}",
4212            child_rect.w,
4213        );
4214    }
4215
4216    /// `min_height` floors a Hug child column whose children sum to
4217    /// less than the floor. Tested through a fixed-size parent so the
4218    /// resolved rect of the inner column reflects the clamp.
4219    #[test]
4220    fn min_height_floors_hug_column_inside_fixed_parent() {
4221        let inner = column([crate::widgets::text::text("a")
4222            .width(Size::Fixed(40.0))
4223            .height(Size::Fixed(20.0))])
4224        .width(Size::Fixed(80.0))
4225        .height(Size::Hug)
4226        .min_height(200.0);
4227        let mut root = column([inner])
4228            .align(Align::Start)
4229            .width(Size::Fixed(800.0))
4230            .height(Size::Fixed(600.0));
4231        let mut state = UiState::new();
4232        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4233        let inner_rect = state.rect(&root.children[0].computed_id);
4234        assert!(
4235            (inner_rect.h - 200.0).abs() < 0.5,
4236            "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
4237            inner_rect.h,
4238        );
4239    }
4240
4241    /// Row laying out a `Fill` Hug-column with a wrap-text child must
4242    /// measure the column's height at the column's allocated width, not
4243    /// unconstrained. Repro for the lint regression that fires on a
4244    /// `row([column([wrap_text(...).fill_width()]).fill_width(), fixed])`
4245    /// shape: without the constrained measurement, the column reports
4246    /// its single-line unwrapped height to the row, the row sizes the
4247    /// column rect at that height, and the wrapped text then overflows
4248    /// the column vertically (Overflow `B=N` finding).
4249    #[test]
4250    fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
4251        // 200px-wide row. The fixed child takes 40; the Fill column gets
4252        // 200 - 40 - 12 (gap) = 148. The paragraph wraps at 148px to two
4253        // lines; the column's intrinsic height should reflect that.
4254        let mut root = crate::row([
4255            column([crate::widgets::text::paragraph(
4256                "A long enough description that must wrap to two lines at 148px",
4257            )])
4258            .width(Size::Fill(1.0)),
4259            crate::widgets::text::text("ok")
4260                .width(Size::Fixed(40.0))
4261                .height(Size::Fixed(20.0)),
4262        ])
4263        .gap(12.0)
4264        .align(Align::Center)
4265        .width(Size::Fixed(200.0));
4266        let mut state = UiState::new();
4267        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
4268        // Find the column child (root.children[0]) and its paragraph leaf.
4269        let col_rect = state.rect(&root.children[0].computed_id);
4270        let para_rect = state.rect(&root.children[0].children[0].computed_id);
4271        assert!(
4272            (col_rect.h - para_rect.h).abs() < 0.5,
4273            "column height ({}) should track its wrapped child's height ({})",
4274            col_rect.h,
4275            para_rect.h,
4276        );
4277    }
4278
4279    /// `Size::Aspect` on the main axis (height inside a Column) derives
4280    /// from the resolved cross size. Width fills its column's 200px;
4281    /// height should be 200 * 0.5 = 100.
4282    #[test]
4283    fn aspect_on_column_main_axis_derives_from_cross() {
4284        let mut root = column([El::new(Kind::Group)
4285            .width(Size::Fill(1.0))
4286            .height(Size::Aspect(0.5))])
4287        .width(Size::Fixed(200.0))
4288        .height(Size::Fixed(400.0));
4289        let mut state = UiState::new();
4290        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
4291        let r = state.rect(&root.children[0].computed_id);
4292        assert!(
4293            (r.w - 200.0).abs() < 0.5,
4294            "expected w≈200 (Fill), got {}",
4295            r.w,
4296        );
4297        assert!(
4298            (r.h - 100.0).abs() < 0.5,
4299            "expected h≈100 (Aspect 0.5 of 200), got {}",
4300            r.h,
4301        );
4302    }
4303
4304    /// Surrounding layout flows around an Aspect-sized image: a Hug
4305    /// column containing an Aspect-height El + a fixed-height sibling
4306    /// must have an outer height equal to derived height + sibling.
4307    #[test]
4308    fn aspect_height_pushes_siblings_in_column() {
4309        let mut root = column([
4310            El::new(Kind::Group)
4311                .width(Size::Fill(1.0))
4312                .height(Size::Aspect(0.25)),
4313            crate::widgets::text::text("caption")
4314                .width(Size::Fixed(40.0))
4315                .height(Size::Fixed(20.0)),
4316        ])
4317        .width(Size::Fixed(400.0))
4318        .height(Size::Fixed(500.0));
4319        let mut state = UiState::new();
4320        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
4321        let img = state.rect(&root.children[0].computed_id);
4322        let cap = state.rect(&root.children[1].computed_id);
4323        assert!(
4324            (img.h - 100.0).abs() < 0.5,
4325            "expected aspect-derived height ≈100, got {}",
4326            img.h,
4327        );
4328        assert!(
4329            (cap.y - 100.0).abs() < 0.5,
4330            "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
4331            cap.y,
4332        );
4333    }
4334
4335    /// `Size::Aspect` on the cross axis (width inside a Row) derives
4336    /// from the resolved main (height). Height fills 200; width should
4337    /// be 200 * 2.0 = 400.
4338    #[test]
4339    fn aspect_on_row_cross_axis_derives_from_main() {
4340        let mut root = crate::row([El::new(Kind::Group)
4341            .height(Size::Fill(1.0))
4342            .width(Size::Aspect(2.0))])
4343        .width(Size::Fixed(800.0))
4344        .height(Size::Fixed(200.0));
4345        let mut state = UiState::new();
4346        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
4347        let r = state.rect(&root.children[0].computed_id);
4348        assert!(
4349            (r.h - 200.0).abs() < 0.5,
4350            "expected h≈200 (Fill), got {}",
4351            r.h,
4352        );
4353        assert!(
4354            (r.w - 400.0).abs() < 0.5,
4355            "expected w≈400 (Aspect 2.0 of 200), got {}",
4356            r.w,
4357        );
4358    }
4359
4360    /// Both axes `Aspect` is degenerate — falls back to intrinsic so
4361    /// the El still has a finite measure.
4362    #[test]
4363    fn aspect_on_both_axes_falls_back_to_intrinsic() {
4364        let mut root = column([crate::widgets::text::text("hi")
4365            .width(Size::Aspect(1.0))
4366            .height(Size::Aspect(1.0))])
4367        .width(Size::Fixed(200.0))
4368        .height(Size::Fixed(200.0));
4369        let mut state = UiState::new();
4370        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4371        let r = state.rect(&root.children[0].computed_id);
4372        assert!(
4373            r.w > 0.0 && r.h > 0.0,
4374            "expected finite size for both-Aspect fallback, got {}x{}",
4375            r.w,
4376            r.h,
4377        );
4378    }
4379
4380    /// `max_height` and `min_height` cap the Aspect-derived axis, and
4381    /// the hugging parent's intrinsic agrees with the layout-time size
4382    /// (no overflow, no gap).
4383    #[test]
4384    fn aspect_respects_min_and_max_on_derived_axis() {
4385        // Case 1: max_height caps a too-tall derived height.
4386        // Fill(1.0) width inside Fixed(400) → 400 wide; Aspect(1.0) →
4387        // 400 tall; max_height=120 → clamped to 120.
4388        let mut root = column([column([El::new(Kind::Group)
4389            .width(Size::Fill(1.0))
4390            .height(Size::Aspect(1.0))
4391            .max_height(120.0)])
4392        .width(Size::Hug)
4393        .height(Size::Hug)])
4394        .width(Size::Fixed(400.0))
4395        .height(Size::Fixed(600.0));
4396        let mut state = UiState::new();
4397        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4398        let panel = state.rect(&root.children[0].computed_id);
4399        let img = state.rect(&root.children[0].children[0].computed_id);
4400        assert!(
4401            (img.h - 120.0).abs() < 0.5,
4402            "max_height should clamp aspect-derived height to 120, got {}",
4403            img.h,
4404        );
4405        assert!(
4406            (panel.h - 120.0).abs() < 0.5,
4407            "hugging panel should match clamped child (120), got {}",
4408            panel.h,
4409        );
4410
4411        // Case 2: min_height pushes a too-short derived height up.
4412        // Aspect(0.1) of 400 = 40; min_height=200 → bumped to 200.
4413        let mut root = column([column([El::new(Kind::Group)
4414            .width(Size::Fill(1.0))
4415            .height(Size::Aspect(0.1))
4416            .min_height(200.0)])
4417        .width(Size::Hug)
4418        .height(Size::Hug)])
4419        .width(Size::Fixed(400.0))
4420        .height(Size::Fixed(600.0));
4421        let mut state = UiState::new();
4422        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4423        let panel = state.rect(&root.children[0].computed_id);
4424        let img = state.rect(&root.children[0].children[0].computed_id);
4425        assert!(
4426            (img.h - 200.0).abs() < 0.5,
4427            "min_height should bump aspect-derived height to 200, got {}",
4428            img.h,
4429        );
4430        assert!(
4431            (panel.h - 200.0).abs() < 0.5,
4432            "hugging panel should match bumped child (200), got {}",
4433            panel.h,
4434        );
4435    }
4436
4437    /// `max_width` on the basis axis caps the Fill basis *before* the
4438    /// Aspect-derived axis is computed, matching the layout-time path.
4439    #[test]
4440    fn aspect_basis_is_clamped_before_deriving() {
4441        // Fill width in 400-wide column, but max_width=100 → basis=100.
4442        // Aspect(0.5) → height=50, not 200.
4443        // Align::Stretch (default) so Fill claims the column's cross
4444        // extent; with Align::Start a Fill child would shrink to its
4445        // intrinsic (0 for a bare Group), defeating the test.
4446        let mut root = column([El::new(Kind::Group)
4447            .width(Size::Fill(1.0))
4448            .height(Size::Aspect(0.5))
4449            .max_width(100.0)])
4450        .width(Size::Fixed(400.0))
4451        .height(Size::Fixed(400.0));
4452        let mut state = UiState::new();
4453        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4454        let img = state.rect(&root.children[0].computed_id);
4455        assert!(
4456            (img.w - 100.0).abs() < 0.5,
4457            "max_width should cap Fill width at 100, got {}",
4458            img.w,
4459        );
4460        assert!(
4461            (img.h - 50.0).abs() < 0.5,
4462            "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
4463            img.h,
4464        );
4465    }
4466
4467    /// Regression: when a `Fill+Aspect` child sits inside a Hug column,
4468    /// the hugging column must size itself to the Aspect-derived height
4469    /// derived against the *parent's* available width, not the El's
4470    /// own natural intrinsic. Otherwise the column hugs too small and
4471    /// the child overflows downward at paint.
4472    #[test]
4473    fn hug_column_around_fill_aspect_child_does_not_overflow() {
4474        // Outer column is Fixed(400, 400) — the available width handed
4475        // down. Middle column hugs (the panel/card surrogate); inner
4476        // image has width=Fill, height=Aspect(0.5). At layout time the
4477        // image should be (400, 200), so the hugging panel must also
4478        // hug to (400, 200) — not to (nat_w, nat_w * 0.5) which would
4479        // be (1, 0.5) for a default-pixel-less El.
4480        let mut root = column([column([El::new(Kind::Group)
4481            .width(Size::Fill(1.0))
4482            .height(Size::Aspect(0.5))])
4483        .width(Size::Hug)
4484        .height(Size::Hug)])
4485        .width(Size::Fixed(400.0))
4486        .height(Size::Fixed(400.0));
4487        let mut state = UiState::new();
4488        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4489        let panel = state.rect(&root.children[0].computed_id);
4490        let img = state.rect(&root.children[0].children[0].computed_id);
4491        assert!(
4492            (panel.h - 200.0).abs() < 0.5,
4493            "hugging panel should hug to aspect-derived height 200, got {}",
4494            panel.h,
4495        );
4496        assert!(
4497            (img.h - 200.0).abs() < 0.5,
4498            "image should layout to height 200, got {}",
4499            img.h,
4500        );
4501        assert!(
4502            img.bottom() <= panel.bottom() + 0.5,
4503            "image (bottom={}) must fit within hugging panel (bottom={})",
4504            img.bottom(),
4505            panel.bottom(),
4506        );
4507    }
4508
4509    /// When a parent hugs to its child and the child has `Aspect`, the
4510    /// hugging parent reports a size that matches what the child will
4511    /// actually paint at — the intrinsic post-step ensures consistency.
4512    #[test]
4513    fn hugging_parent_sees_aspect_corrected_intrinsic() {
4514        // Inner El has width=Fixed(80), height=Aspect(0.5) → intrinsic
4515        // height should derive to 40. The hugging column wrapping it
4516        // should size to (80, 40), not (80, 0) or (80, natural).
4517        let mut root = column([column([El::new(Kind::Group)
4518            .width(Size::Fixed(80.0))
4519            .height(Size::Aspect(0.5))])
4520        .width(Size::Hug)
4521        .height(Size::Hug)])
4522        .width(Size::Fixed(400.0))
4523        .height(Size::Fixed(400.0))
4524        .align(Align::Start);
4525        let mut state = UiState::new();
4526        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4527        let hugger = state.rect(&root.children[0].computed_id);
4528        assert!(
4529            (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
4530            "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
4531            hugger.w,
4532            hugger.h,
4533        );
4534    }
4535
4536    /// `max_height` caps a `Hug` overlay child below its intrinsic.
4537    #[test]
4538    fn max_height_caps_overlay_child_below_intrinsic() {
4539        // Overlay parent sized 600x600; child Hug column whose intrinsic
4540        // height is 300 (single 300-tall fixed leaf), capped at 100.
4541        let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
4542            .width(Size::Fixed(40.0))
4543            .height(Size::Fixed(300.0))])
4544        .width(Size::Hug)
4545        .height(Size::Hug)
4546        .max_height(100.0)])
4547        .width(Size::Fixed(600.0))
4548        .height(Size::Fixed(600.0));
4549        let mut state = UiState::new();
4550        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
4551        let child_rect = state.rect(&root.children[0].computed_id);
4552        assert!(
4553            (child_rect.h - 100.0).abs() < 0.5,
4554            "expected child height capped at 100, got h={}",
4555            child_rect.h,
4556        );
4557    }
4558}