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    let key = intrinsic_cache_key(c, available_width);
2112    if let Some(key) = &key
2113        && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
2114            let mut slot = cell.borrow_mut();
2115            let cache = slot.as_mut()?;
2116            let cached = cache.measurements.get(key).copied();
2117            if cached.is_some() {
2118                cache.stats.hits += 1;
2119            }
2120            cached
2121        })
2122    {
2123        return cached;
2124    }
2125
2126    if key.is_some() {
2127        INTRINSIC_CACHE.with(|cell| {
2128            if let Some(cache) = cell.borrow_mut().as_mut() {
2129                cache.stats.misses += 1;
2130            }
2131        });
2132    }
2133
2134    let measured = apply_aspect(
2135        c,
2136        available_width,
2137        intrinsic_constrained_uncached(c, available_width),
2138    );
2139
2140    if let Some(key) = key {
2141        INTRINSIC_CACHE.with(|cell| {
2142            if let Some(cache) = cell.borrow_mut().as_mut() {
2143                cache.measurements.insert(key, measured);
2144            }
2145        });
2146    }
2147
2148    measured
2149}
2150
2151/// Apply `Size::Aspect` to a freshly-measured intrinsic by deriving the
2152/// aspect-locked axis from the other axis. Runs after the inner intrinsic
2153/// pass so it composes with any content type (image, text, container).
2154///
2155/// When the *other* axis is `Fill`, the layout-time size of that axis is
2156/// the parent's available extent, not the El's inner intrinsic. Using the
2157/// inner intrinsic would let a hugging parent under-size and the Aspect-
2158/// derived axis would then overflow at paint. Prefer `available_width`
2159/// for Fill width; we don't currently plumb available_height, so a Fill
2160/// height + Aspect width pairing falls back to inner intrinsic.
2161///
2162/// Both axes Aspect is degenerate — fall back to the inner intrinsic so
2163/// the El still has a finite measure. Negative ratios are clamped to zero
2164/// for the same reason.
2165fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
2166    match (c.width, c.height) {
2167        (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
2168        (Size::Aspect(r), _) => {
2169            // Basis axis is height; ih is already clamped by apply_min.
2170            // Clamp the derived width against the El's own min/max so
2171            // a hugging parent sees the intrinsic that layout will
2172            // actually paint at.
2173            (clamp_w(c, ih * r.max(0.0)), ih)
2174        }
2175        (_, Size::Aspect(r)) => {
2176            let raw_basis = match c.width {
2177                Size::Fixed(v) => v,
2178                Size::Fill(_) => available_width.unwrap_or(iw),
2179                Size::Hug | Size::Aspect(_) => iw,
2180            };
2181            // Mirror the layout-time ordering in `resolve_main`: clamp
2182            // the basis by the *basis* axis's min/max first, then derive
2183            // the other axis and clamp by its own min/max.
2184            let basis = clamp_w(c, raw_basis);
2185            (iw, clamp_h(c, basis * r.max(0.0)))
2186        }
2187        _ => (iw, ih),
2188    }
2189}
2190
2191fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
2192    if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
2193        return None;
2194    }
2195    if c.computed_id.is_empty() {
2196        return None;
2197    }
2198    Some(IntrinsicCacheKey {
2199        computed_id: c.computed_id.clone(),
2200        available_width_bits: available_width.map(f32::to_bits),
2201    })
2202}
2203
2204fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
2205    if c.layout_override.is_some() {
2206        // Custom-layout nodes don't define an intrinsic. Authors must
2207        // size them with `Fixed` or `Fill` on both axes; the returned
2208        // (0.0, 0.0) is replaced by `apply_min` for `Fixed` and is
2209        // unread for `Fill` (parent's distribution decides).
2210        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2211            panic!(
2212                "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
2213                 Size::Hug is not supported for custom layouts",
2214                c.computed_id,
2215            );
2216        }
2217        return apply_min(c, 0.0, 0.0);
2218    }
2219    if c.virtual_items.is_some() {
2220        // VirtualList sizes the whole viewport (the parent decides) and
2221        // realizes only on-screen rows. Hug-sizing it would mean
2222        // "shrink to fit all rows", defeating virtualization. Same
2223        // shape as the layout_override guard.
2224        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2225            panic!(
2226                "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
2227                 Size::Hug would defeat virtualization",
2228                c.computed_id,
2229            );
2230        }
2231        return apply_min(c, 0.0, 0.0);
2232    }
2233    if matches!(c.kind, Kind::Inlines) {
2234        return inline_paragraph_intrinsic(c, available_width);
2235    }
2236    if matches!(c.kind, Kind::HardBreak) {
2237        // HardBreak is meaningful only inside Inlines (where draw_ops
2238        // encodes it as `\n` in the attributed text). Outside Inlines
2239        // it's a no-op layout-wise.
2240        return apply_min(c, 0.0, 0.0);
2241    }
2242    if matches!(c.kind, Kind::Math) {
2243        if let Some(expr) = &c.math {
2244            let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
2245            return apply_min(
2246                c,
2247                layout.width + c.padding.left + c.padding.right,
2248                layout.height() + c.padding.top + c.padding.bottom,
2249            );
2250        }
2251        return apply_min(c, 0.0, 0.0);
2252    }
2253    if c.icon.is_some() {
2254        return apply_min(
2255            c,
2256            c.font_size + c.padding.left + c.padding.right,
2257            c.font_size + c.padding.top + c.padding.bottom,
2258        );
2259    }
2260    if let Some(img) = &c.image {
2261        // Natural pixel size as a logical-pixel intrinsic. Authors who
2262        // want a different sized box set `.width()` / `.height()`;
2263        // the projection inside that box is decided by `image_fit`.
2264        let w = img.width() as f32 + c.padding.left + c.padding.right;
2265        let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2266        return apply_min(c, w, h);
2267    }
2268    if let Some(text) = &c.text {
2269        let content_available = match c.text_wrap {
2270            TextWrap::NoWrap => None,
2271            TextWrap::Wrap => available_width
2272                .or(match c.width {
2273                    Size::Fixed(v) => Some(v),
2274                    // Aspect-on-text would be circular (text height
2275                    // depends on wrap width which would depend on
2276                    // text height). Treat like Hug — no wrap cap.
2277                    Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2278                })
2279                .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2280        };
2281        let display = display_text_for_measure(c, text, content_available);
2282        let layout = text_metrics::layout_text_with_line_height_and_family(
2283            &display,
2284            c.font_size,
2285            c.line_height,
2286            c.font_family,
2287            c.font_weight,
2288            c.font_mono,
2289            c.text_wrap,
2290            content_available,
2291        );
2292        let w = match (content_available, c.width) {
2293            (Some(available), Size::Hug | Size::Aspect(_)) => {
2294                let unwrapped = text_metrics::layout_text_with_family(
2295                    text,
2296                    c.font_size,
2297                    c.font_family,
2298                    c.font_weight,
2299                    c.font_mono,
2300                    TextWrap::NoWrap,
2301                    None,
2302                );
2303                unwrapped.width.min(available) + c.padding.left + c.padding.right
2304            }
2305            (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2306                available + c.padding.left + c.padding.right
2307            }
2308            (None, _) => layout.width + c.padding.left + c.padding.right,
2309        };
2310        let h = layout.height + c.padding.top + c.padding.bottom;
2311        return apply_min(c, w, h);
2312    }
2313    match c.axis {
2314        Axis::Overlay => {
2315            let mut w: f32 = 0.0;
2316            let mut h: f32 = 0.0;
2317            for ch in &c.children {
2318                let child_available =
2319                    available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2320                let (cw, chh) = intrinsic_constrained(ch, child_available);
2321                w = w.max(cw);
2322                h = h.max(chh);
2323            }
2324            apply_min(
2325                c,
2326                w + c.padding.left + c.padding.right,
2327                h + c.padding.top + c.padding.bottom,
2328            )
2329        }
2330        Axis::Column => {
2331            let mut w: f32 = 0.0;
2332            let mut h: f32 = c.padding.top + c.padding.bottom;
2333            let n = c.children.len();
2334            let child_available =
2335                available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2336            for (i, ch) in c.children.iter().enumerate() {
2337                let (cw, chh) = intrinsic_constrained(ch, child_available);
2338                w = w.max(cw);
2339                h += chh;
2340                if i + 1 < n {
2341                    h += c.gap;
2342                }
2343            }
2344            apply_min(c, w + c.padding.left + c.padding.right, h)
2345        }
2346        Axis::Row => {
2347            // Two-pass measurement so that wrappable Fill children see
2348            // the width they will actually be laid out at. Without
2349            // this, a `Size::Fill` paragraph inside a row falls through
2350            // `inline_paragraph_intrinsic`'s `available_width` fallback
2351            // with `None` and reports its unwrapped single-line height
2352            // — the row then under-reserves vertical space and the
2353            // wrapped text overflows downward into the next row. This
2354            // mirrors how `layout_axis` (the runtime pass) already
2355            // splits Resolved vs. Fill main-axis sizing.
2356            let n = c.children.len();
2357            let total_gap = c.gap * n.saturating_sub(1) as f32;
2358            let inner_available = available_width
2359                .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2360
2361            // First pass: Fixed and Hug children measure unconstrained.
2362            // Fixed-width wrappable children self-resolve their wrap
2363            // width via `inline_paragraph_intrinsic`'s own Fixed
2364            // fallback; Hug children take their natural width. We only
2365            // need to feed an explicit available width to Fill.
2366            let mut consumed: f32 = 0.0;
2367            let mut fill_weight_total: f32 = 0.0;
2368            let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2369            for ch in &c.children {
2370                match ch.width {
2371                    Size::Fill(w) => {
2372                        fill_weight_total += w.max(0.001);
2373                        sizes.push(None);
2374                    }
2375                    _ => {
2376                        let (cw, chh) = intrinsic(ch);
2377                        consumed += cw;
2378                        sizes.push(Some((cw, chh)));
2379                    }
2380                }
2381            }
2382
2383            // Second pass: distribute the leftover among Fill children
2384            // by weight and remeasure each with its share. Without an
2385            // available_width hint (row inside a Hug ancestor with no
2386            // outer constraint) we fall back to unconstrained
2387            // measurement — same lossy shape as the prior code, but
2388            // limited to the case where there's genuinely no width to
2389            // distribute.
2390            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2391            let mut w_total: f32 = c.padding.left + c.padding.right;
2392            let mut h_max: f32 = 0.0;
2393            for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2394                let (cw, chh) = match slot {
2395                    Some(rc) => rc,
2396                    None => match (fill_remaining, fill_weight_total > 0.0) {
2397                        (Some(av), true) => {
2398                            let weight = match ch.width {
2399                                Size::Fill(w) => w.max(0.001),
2400                                _ => 1.0,
2401                            };
2402                            intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2403                        }
2404                        _ => intrinsic(ch),
2405                    },
2406                };
2407                w_total += cw;
2408                if i + 1 < n {
2409                    w_total += c.gap;
2410                }
2411                h_max = h_max.max(chh);
2412            }
2413            apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2414        }
2415    }
2416}
2417
2418pub(crate) fn text_layout(
2419    c: &El,
2420    available_width: Option<f32>,
2421) -> Option<text_metrics::TextLayout> {
2422    let text = c.text.as_ref()?;
2423    let content_available = match c.text_wrap {
2424        TextWrap::NoWrap => None,
2425        TextWrap::Wrap => available_width
2426            .or(match c.width {
2427                Size::Fixed(v) => Some(v),
2428                Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2429            })
2430            .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2431    };
2432    let display = display_text_for_measure(c, text, content_available);
2433    Some(text_metrics::layout_text_with_line_height_and_family(
2434        &display,
2435        c.font_size,
2436        c.line_height,
2437        c.font_family,
2438        c.font_weight,
2439        c.font_mono,
2440        c.text_wrap,
2441        content_available,
2442    ))
2443}
2444
2445fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2446    if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2447        (c.text_wrap, c.text_max_lines, available_width)
2448    {
2449        text_metrics::clamp_text_to_lines_with_family(
2450            text,
2451            c.font_size,
2452            c.font_family,
2453            c.font_weight,
2454            c.font_mono,
2455            width,
2456            max_lines,
2457        )
2458    } else {
2459        text.to_string()
2460    }
2461}
2462
2463fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2464    if let Size::Fixed(v) = c.width {
2465        w = v;
2466    }
2467    if let Size::Fixed(v) = c.height {
2468        h = v;
2469    }
2470    (clamp_w(c, w), clamp_h(c, h))
2471}
2472
2473/// Apply [`El::min_width`] / [`El::max_width`] to a resolved width,
2474/// matching CSS's `min-width` over `max-width` precedence (when both
2475/// constraints conflict, the lower bound wins). Also clamps to a
2476/// non-negative result so a zero-padding Hug never reports a negative
2477/// intrinsic.
2478pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2479    if let Some(max_w) = c.max_width {
2480        w = w.min(max_w);
2481    }
2482    if let Some(min_w) = c.min_width {
2483        w = w.max(min_w);
2484    }
2485    w.max(0.0)
2486}
2487
2488/// Height-axis companion to [`clamp_w`].
2489pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2490    if let Some(max_h) = c.max_height {
2491        h = h.min(max_h);
2492    }
2493    if let Some(min_h) = c.min_height {
2494        h = h.max(min_h);
2495    }
2496    h.max(0.0)
2497}
2498
2499/// Approximate intrinsic measurement for `Kind::Inlines` paragraphs.
2500///
2501/// The paragraph paints through cosmic-text's rich-text shaping (which
2502/// resolves bold/italic/mono runs against fontdb), but layout needs a
2503/// width and height *before* we get to the renderer. We concatenate
2504/// the runs' text into one string and call `text_metrics::layout_text`
2505/// at the dominant font size — same approximation the lint pass uses
2506/// for single-style text. Bold/italic widths are slightly different
2507/// from regular; for body-text paragraphs that difference is well
2508/// under one wrap-line and we accept it. If a fixture wraps within
2509/// 1-2 characters of a boundary the rendered glyphs may straddle the
2510/// laid-out rect by a fraction of a glyph.
2511fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2512    if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2513        return inline_mixed_intrinsic(node, available_width);
2514    }
2515    let concat = concat_inline_text(&node.children);
2516    let size = inline_paragraph_size(node);
2517    let line_height = inline_paragraph_line_height(node);
2518    let content_available = match node.text_wrap {
2519        TextWrap::NoWrap => None,
2520        TextWrap::Wrap => available_width
2521            .or(match node.width {
2522                Size::Fixed(v) => Some(v),
2523                Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2524            })
2525            .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2526    };
2527    let layout = text_metrics::layout_text_with_line_height_and_family(
2528        &concat,
2529        size,
2530        line_height,
2531        node.font_family,
2532        FontWeight::Regular,
2533        false,
2534        node.text_wrap,
2535        content_available,
2536    );
2537    let w = match (content_available, node.width) {
2538        (Some(available), Size::Hug | Size::Aspect(_)) => {
2539            let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2540                &concat,
2541                size,
2542                line_height,
2543                node.font_family,
2544                FontWeight::Regular,
2545                false,
2546                TextWrap::NoWrap,
2547                None,
2548            );
2549            unwrapped.width.min(available) + node.padding.left + node.padding.right
2550        }
2551        (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2552            available + node.padding.left + node.padding.right
2553        }
2554        (None, _) => layout.width + node.padding.left + node.padding.right,
2555    };
2556    let h = layout.height + node.padding.top + node.padding.bottom;
2557    apply_min(node, w, h)
2558}
2559
2560fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2561    let wrap_width = match node.text_wrap {
2562        TextWrap::Wrap => available_width.or(match node.width {
2563            Size::Fixed(v) => Some(v),
2564            Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2565        }),
2566        TextWrap::NoWrap => None,
2567    }
2568    .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2569
2570    let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2571        node.text_wrap,
2572        wrap_width,
2573        node.font_size * 0.82,
2574        node.font_size * 0.22,
2575        node.line_height,
2576    );
2577
2578    for child in &node.children {
2579        match child.kind {
2580            Kind::HardBreak => {
2581                breaker.finish_line();
2582                continue;
2583            }
2584            Kind::Text => {
2585                let text = child.text.as_deref().unwrap_or("");
2586                for chunk in inline_text_chunks(text) {
2587                    let is_space = chunk.chars().all(char::is_whitespace);
2588                    if breaker.skips_leading_space(is_space) {
2589                        continue;
2590                    }
2591                    let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2592                    if breaker.wraps_before(is_space, w) {
2593                        breaker.finish_line();
2594                    }
2595                    if breaker.skips_overflowing_space(is_space, w) {
2596                        continue;
2597                    }
2598                    breaker.push(w, ascent, descent);
2599                }
2600                continue;
2601            }
2602            _ => {}
2603        }
2604        let (w, ascent, descent) = inline_child_metrics(child);
2605        if breaker.wraps_before(false, w) {
2606            breaker.finish_line();
2607        }
2608        breaker.push(w, ascent, descent);
2609    }
2610    let measurement = breaker.finish();
2611    let w = measurement.width + node.padding.left + node.padding.right;
2612    let h = measurement.height + node.padding.top + node.padding.bottom;
2613    apply_min(node, w, h)
2614}
2615
2616fn inline_text_chunks(text: &str) -> Vec<&str> {
2617    let mut chunks = Vec::new();
2618    let mut start = 0;
2619    let mut last_space = None;
2620    for (i, ch) in text.char_indices() {
2621        let is_space = ch.is_whitespace();
2622        match last_space {
2623            None => last_space = Some(is_space),
2624            Some(prev) if prev != is_space => {
2625                chunks.push(&text[start..i]);
2626                start = i;
2627                last_space = Some(is_space);
2628            }
2629            _ => {}
2630        }
2631    }
2632    if start < text.len() {
2633        chunks.push(&text[start..]);
2634    }
2635    chunks
2636}
2637
2638fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2639    let layout = text_metrics::layout_text_with_line_height_and_family(
2640        text,
2641        child.font_size,
2642        child.line_height,
2643        child.font_family,
2644        child.font_weight,
2645        child.font_mono,
2646        TextWrap::NoWrap,
2647        None,
2648    );
2649    (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2650}
2651
2652fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2653    match child.kind {
2654        Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2655        Kind::Math => {
2656            if let Some(expr) = &child.math {
2657                let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2658                (layout.width, layout.ascent, layout.descent)
2659            } else {
2660                (0.0, 0.0, 0.0)
2661            }
2662        }
2663        _ => (0.0, 0.0, 0.0),
2664    }
2665}
2666
2667/// Walk an Inlines paragraph's children and produce the source-order
2668/// concatenation that draw_ops will hand to the atlas. `Kind::Text`
2669/// contributes its `text` field; `Kind::HardBreak` contributes a
2670/// newline; anything else contributes nothing (an unsupported child
2671/// kind inside Inlines is a programmer error elsewhere — measurement
2672/// silently ignores it).
2673fn concat_inline_text(children: &[El]) -> String {
2674    let mut s = String::new();
2675    for c in children {
2676        match c.kind {
2677            Kind::Text => {
2678                if let Some(t) = &c.text {
2679                    s.push_str(t);
2680                }
2681            }
2682            Kind::HardBreak => s.push('\n'),
2683            _ => {}
2684        }
2685    }
2686    s
2687}
2688
2689/// Pick the font size that drives the paragraph's measurement. We use
2690/// the maximum across text children rather than the parent's own
2691/// `font_size`, because builders set sizes on the leaf text nodes.
2692fn inline_paragraph_size(node: &El) -> f32 {
2693    let mut size: f32 = node.font_size;
2694    for c in &node.children {
2695        if matches!(c.kind, Kind::Text) {
2696            size = size.max(c.font_size);
2697        }
2698    }
2699    size
2700}
2701
2702fn inline_paragraph_line_height(node: &El) -> f32 {
2703    let mut line_height: f32 = node.line_height;
2704    let mut max_size: f32 = node.font_size;
2705    for c in &node.children {
2706        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2707            max_size = c.font_size;
2708            line_height = c.line_height;
2709        }
2710    }
2711    line_height
2712}
2713
2714#[cfg(test)]
2715mod tests {
2716    use super::*;
2717    use crate::state::UiState;
2718
2719    /// CSS-flex parity: a `Size::Fill` child of a column with
2720    /// `align(Center)` should shrink to its intrinsic cross-axis size
2721    /// and be horizontally centered, matching `align-items: center`
2722    /// in CSS flex (which causes flex items to lose their stretch).
2723    #[test]
2724    fn align_center_shrinks_fill_child_to_intrinsic() {
2725        // Column with align(Center). Inner row has the default
2726        // El::new width = Fill(1.0); without Proposal B it would
2727        // claim the full 200px and align would be a no-op.
2728        let mut root = column([crate::row([crate::widgets::text::text("hi")
2729            .width(Size::Fixed(40.0))
2730            .height(Size::Fixed(20.0))])])
2731        .align(Align::Center)
2732        .width(Size::Fixed(200.0))
2733        .height(Size::Fixed(100.0));
2734        let mut state = UiState::new();
2735        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2736        let row_rect = state.rect(&root.children[0].computed_id);
2737        // Row's intrinsic width = 40 (single fixed child). 200 - 40 = 160
2738        // leftover; centered → row starts at x=80.
2739        assert!(
2740            (row_rect.x - 80.0).abs() < 0.5,
2741            "expected x≈80 (centered), got {}",
2742            row_rect.x
2743        );
2744        assert!(
2745            (row_rect.w - 40.0).abs() < 0.5,
2746            "expected w≈40 (shrunk to intrinsic), got {}",
2747            row_rect.w
2748        );
2749    }
2750
2751    /// `align(Stretch)` (the default) preserves Fill stretching: a
2752    /// Fill-width child still claims the full cross axis.
2753    #[test]
2754    fn align_stretch_preserves_fill_stretch() {
2755        let mut root = column([crate::row([crate::widgets::text::text("hi")
2756            .width(Size::Fixed(40.0))
2757            .height(Size::Fixed(20.0))])])
2758        .align(Align::Stretch)
2759        .width(Size::Fixed(200.0))
2760        .height(Size::Fixed(100.0));
2761        let mut state = UiState::new();
2762        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2763        let row_rect = state.rect(&root.children[0].computed_id);
2764        assert!(
2765            (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2766            "expected stretched (x=0, w=200), got x={} w={}",
2767            row_rect.x,
2768            row_rect.w
2769        );
2770    }
2771
2772    /// When all children are Hug-sized, `Justify::Center` should split
2773    /// the leftover space symmetrically across the main axis.
2774    #[test]
2775    fn justify_center_centers_hug_children() {
2776        let mut root = column([crate::widgets::text::text("hi")
2777            .width(Size::Fixed(40.0))
2778            .height(Size::Fixed(20.0))])
2779        .justify(Justify::Center)
2780        .height(Size::Fill(1.0));
2781        let mut state = UiState::new();
2782        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2783        let child_rect = state.rect(&root.children[0].computed_id);
2784        // Expected: 100 - 20 = 80 leftover; centered → starts at y=40.
2785        assert!(
2786            (child_rect.y - 40.0).abs() < 0.5,
2787            "expected y≈40, got {}",
2788            child_rect.y
2789        );
2790    }
2791
2792    #[test]
2793    fn justify_end_pushes_to_bottom() {
2794        let mut root = column([crate::widgets::text::text("hi")
2795            .width(Size::Fixed(40.0))
2796            .height(Size::Fixed(20.0))])
2797        .justify(Justify::End)
2798        .height(Size::Fill(1.0));
2799        let mut state = UiState::new();
2800        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2801        let child_rect = state.rect(&root.children[0].computed_id);
2802        assert!(
2803            (child_rect.y - 80.0).abs() < 0.5,
2804            "expected y≈80, got {}",
2805            child_rect.y
2806        );
2807    }
2808
2809    /// CSS `justify-content: space-between`: when no main-axis Fill
2810    /// children claim the slack, the leftover space is distributed
2811    /// evenly *between* (not around) the children — outer edges flush.
2812    #[test]
2813    fn justify_space_between_distributes_evenly() {
2814        let row_child = || {
2815            crate::widgets::text::text("x")
2816                .width(Size::Fixed(20.0))
2817                .height(Size::Fixed(20.0))
2818        };
2819        let mut root = column([row_child(), row_child(), row_child()])
2820            .justify(Justify::SpaceBetween)
2821            .height(Size::Fixed(200.0));
2822        let mut state = UiState::new();
2823        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2824        // Used main = 3 * 20 = 60. Leftover = 140 over (n-1) = 2 gaps
2825        // → 70 between. Positions: 0, 90, 180.
2826        let y0 = state.rect(&root.children[0].computed_id).y;
2827        let y1 = state.rect(&root.children[1].computed_id).y;
2828        let y2 = state.rect(&root.children[2].computed_id).y;
2829        assert!(
2830            y0.abs() < 0.5,
2831            "first child should be flush at y=0, got {y0}"
2832        );
2833        assert!(
2834            (y1 - 90.0).abs() < 0.5,
2835            "middle child should be at y≈90, got {y1}"
2836        );
2837        assert!(
2838            (y2 - 180.0).abs() < 0.5,
2839            "last child should be flush at y≈180, got {y2}"
2840        );
2841    }
2842
2843    /// CSS `flex: <weight>`: when multiple `Size::Fill` children share
2844    /// a container, the available space is distributed in proportion
2845    /// to their weights.
2846    #[test]
2847    fn fill_weight_distributes_proportionally() {
2848        let big = crate::widgets::text::text("big")
2849            .width(Size::Fixed(40.0))
2850            .height(Size::Fill(2.0));
2851        let small = crate::widgets::text::text("small")
2852            .width(Size::Fixed(40.0))
2853            .height(Size::Fill(1.0));
2854        let mut root = column([big, small]).height(Size::Fixed(300.0));
2855        let mut state = UiState::new();
2856        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2857        // Total weight = 3, available = 300. Big = 200, small = 100.
2858        let big_h = state.rect(&root.children[0].computed_id).h;
2859        let small_h = state.rect(&root.children[1].computed_id).h;
2860        assert!(
2861            (big_h - 200.0).abs() < 0.5,
2862            "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2863        );
2864        assert!(
2865            (small_h - 100.0).abs() < 0.5,
2866            "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2867        );
2868    }
2869
2870    /// `padding` on a `Hug`-sized container is included in the
2871    /// container's intrinsic — matching CSS `box-sizing: content-box`
2872    /// where padding adds to the rendered size.
2873    #[test]
2874    fn padding_on_hug_includes_in_intrinsic() {
2875        let root = column([crate::widgets::text::text("x")
2876            .width(Size::Fixed(40.0))
2877            .height(Size::Fixed(40.0))])
2878        .padding(Sides::all(20.0));
2879        let (w, h) = intrinsic(&root);
2880        // 40 content + 2*20 padding on each axis = 80.
2881        assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2882        assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2883    }
2884
2885    /// Cross-axis `Align::End` on a row pins children to the bottom
2886    /// edge — CSS `align-items: flex-end`. Mirror of `justify_end`
2887    /// but on the cross axis instead of the main axis.
2888    #[test]
2889    fn align_end_pins_to_cross_axis_far_edge() {
2890        let mut root = crate::row([crate::widgets::text::text("hi")
2891            .width(Size::Fixed(40.0))
2892            .height(Size::Fixed(20.0))])
2893        .align(Align::End)
2894        .width(Size::Fixed(200.0))
2895        .height(Size::Fixed(100.0));
2896        let mut state = UiState::new();
2897        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2898        let child_rect = state.rect(&root.children[0].computed_id);
2899        // Row cross axis = height. End → child y = 100 - 20 = 80.
2900        assert!(
2901            (child_rect.y - 80.0).abs() < 0.5,
2902            "expected y≈80 (pinned to bottom), got {}",
2903            child_rect.y
2904        );
2905    }
2906
2907    #[test]
2908    fn overlay_can_center_hug_child() {
2909        let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2910            .width(Size::Fixed(200.0))
2911            .height(Size::Hug)])
2912        .align(Align::Center)
2913        .justify(Justify::Center);
2914        let mut state = UiState::new();
2915        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2916        let child_rect = state.rect(&root.children[0].computed_id);
2917        assert!(
2918            (child_rect.x - 200.0).abs() < 0.5,
2919            "expected x≈200, got {}",
2920            child_rect.x
2921        );
2922        assert!(
2923            child_rect.y > 100.0 && child_rect.y < 200.0,
2924            "expected centered y, got {}",
2925            child_rect.y
2926        );
2927    }
2928
2929    #[test]
2930    fn scroll_offset_translates_children_and_clamps_to_content() {
2931        // Six 50px-tall rows in a 200px-tall scroll viewport.
2932        // Content height = 6 * 50 + 5 * 12 (gap) = 360 px. Visible
2933        // viewport (no padding) = 200 px → max_offset = 160.
2934        let mut root = scroll(
2935            (0..6)
2936                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2937        )
2938        .key("list")
2939        .gap(12.0)
2940        .height(Size::Fixed(200.0));
2941        let mut state = UiState::new();
2942        assign_ids(&mut root);
2943        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2944
2945        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2946
2947        // Offset is in range, applied verbatim.
2948        let stored = state
2949            .scroll
2950            .offsets
2951            .get(&root.computed_id)
2952            .copied()
2953            .unwrap_or(0.0);
2954        assert!(
2955            (stored - 80.0).abs() < 0.01,
2956            "offset clamped unexpectedly: {stored}"
2957        );
2958        // First child shifted up by 80.
2959        let c0 = state.rect(&root.children[0].computed_id);
2960        assert!(
2961            (c0.y - (-80.0)).abs() < 0.01,
2962            "child 0 y = {} (expected -80)",
2963            c0.y
2964        );
2965        // Now overshoot — should clamp to max_offset=160.
2966        state
2967            .scroll
2968            .offsets
2969            .insert(root.computed_id.clone(), 9999.0);
2970        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2971        let stored = state
2972            .scroll
2973            .offsets
2974            .get(&root.computed_id)
2975            .copied()
2976            .unwrap_or(0.0);
2977        assert!(
2978            (stored - 160.0).abs() < 0.01,
2979            "overshoot clamped to {stored}"
2980        );
2981        // Content fits → offset clamps to 0.
2982        let mut tiny =
2983            scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
2984                .height(Size::Fixed(200.0));
2985        let mut tiny_state = UiState::new();
2986        assign_ids(&mut tiny);
2987        tiny_state
2988            .scroll
2989            .offsets
2990            .insert(tiny.computed_id.clone(), 50.0);
2991        layout(
2992            &mut tiny,
2993            &mut tiny_state,
2994            Rect::new(0.0, 0.0, 300.0, 200.0),
2995        );
2996        assert_eq!(
2997            tiny_state
2998                .scroll
2999                .offsets
3000                .get(&tiny.computed_id)
3001                .copied()
3002                .unwrap_or(0.0),
3003            0.0
3004        );
3005    }
3006
3007    #[test]
3008    fn scroll_layout_prunes_far_offscreen_descendants() {
3009        let far = column([crate::widgets::text::text("far row body").key("far-text")])
3010            .height(Size::Fixed(40.0));
3011        let mut root = scroll([
3012            column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
3013            crate::tree::spacer().height(Size::Fixed(400.0)),
3014            far,
3015        ])
3016        .key("list")
3017        .height(Size::Fixed(80.0));
3018        let mut state = UiState::new();
3019        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
3020        let stats = take_prune_stats();
3021
3022        assert!(
3023            stats.subtrees >= 1,
3024            "expected at least one far scroll child to be pruned, got {stats:?}"
3025        );
3026        assert!(
3027            stats.nodes >= 1,
3028            "expected pruned descendants to be zeroed, got {stats:?}"
3029        );
3030        let far_text = state
3031            .rect_of_key(&root, "far-text")
3032            .expect("far text keeps a zero rect while pruned");
3033        assert_eq!(far_text.w, 0.0);
3034        assert_eq!(far_text.h, 0.0);
3035    }
3036
3037    #[test]
3038    fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
3039        let make_root = || {
3040            let paragraph_text = "Variable width text wraps into a different number of lines when \
3041                                  the viewport narrows, which used to make a plain scroll box lose \
3042                                  the item the user was reading.";
3043            scroll([column((0..30).map(|i| {
3044                crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
3045                    .key(format!("paragraph-{i}"))
3046            }))
3047            .gap(8.0)])
3048            .key("article")
3049            .height(Size::Fixed(180.0))
3050        };
3051
3052        let mut root = make_root();
3053        let mut state = UiState::new();
3054        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3055
3056        state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
3057        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3058
3059        let anchor = state
3060            .scroll
3061            .scroll_anchors
3062            .get(&root.computed_id)
3063            .cloned()
3064            .expect("plain scroll should store a visible descendant anchor");
3065        let before_rect = state.rect(&anchor.node_id);
3066        let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
3067        let before_offset = state.scroll_offset(&root.computed_id);
3068
3069        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
3070
3071        let after_rect = state.rect(&anchor.node_id);
3072        let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
3073        let after_offset = state.scroll_offset(&root.computed_id);
3074        assert!(
3075            (after_anchor_y - before_anchor_y).abs() < 0.5,
3076            "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
3077        );
3078        assert!(
3079            (after_offset - before_offset).abs() > 20.0,
3080            "offset should absorb height changes above the anchor"
3081        );
3082    }
3083
3084    #[test]
3085    fn scrollbar_thumb_size_and_position_track_overflow() {
3086        // 6 rows x 50px + 5 gaps x 12 = 360 content; 200 viewport.
3087        // viewport/content = 200/360 ≈ 0.555 → thumb_h ≈ 111.1.
3088        let mut root = scroll(
3089            (0..6)
3090                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3091        )
3092        .gap(12.0)
3093        .height(Size::Fixed(200.0));
3094        let mut state = UiState::new();
3095        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3096
3097        let metrics = state
3098            .scroll
3099            .metrics
3100            .get(&root.computed_id)
3101            .copied()
3102            .expect("scrollable should have metrics");
3103        assert!((metrics.viewport_h - 200.0).abs() < 0.01);
3104        assert!((metrics.content_h - 360.0).abs() < 0.01);
3105        assert!((metrics.max_offset - 160.0).abs() < 0.01);
3106
3107        let thumb = state
3108            .scroll
3109            .thumb_rects
3110            .get(&root.computed_id)
3111            .copied()
3112            .expect("scrollable with scrollbar() and overflow gets a thumb");
3113        // viewport^2 / content_h = 200^2 / 360 = 111.11..
3114        assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
3115        assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
3116        // At offset 0, thumb sits at the top of the inner rect.
3117        assert!(thumb.y.abs() < 0.01);
3118        // Right-anchored: thumb_x + thumb_w + track_inset == viewport_right.
3119        assert!(
3120            (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
3121            "thumb anchored at {} (expected {})",
3122            thumb.x,
3123            300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
3124        );
3125
3126        // Slide to half — thumb should be at half the track_remaining.
3127        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
3128        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3129        let thumb = state
3130            .scroll
3131            .thumb_rects
3132            .get(&root.computed_id)
3133            .copied()
3134            .unwrap();
3135        let track_remaining = 200.0 - thumb.h;
3136        let expected_y = track_remaining * (80.0 / 160.0);
3137        assert!(
3138            (thumb.y - expected_y).abs() < 0.5,
3139            "thumb at half-scroll y = {} (expected {expected_y})",
3140            thumb.y,
3141        );
3142    }
3143
3144    #[test]
3145    fn scrollbar_track_is_wider_than_thumb_and_full_height() {
3146        // The track is the click hitbox: wider than the visible
3147        // thumb (Fitts's law) and tall enough to detect track
3148        // clicks above and below the thumb for paging.
3149        let mut root = scroll(
3150            (0..6)
3151                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3152        )
3153        .gap(12.0)
3154        .height(Size::Fixed(200.0));
3155        let mut state = UiState::new();
3156        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3157
3158        let thumb = state
3159            .scroll
3160            .thumb_rects
3161            .get(&root.computed_id)
3162            .copied()
3163            .unwrap();
3164        let track = state
3165            .scroll
3166            .thumb_tracks
3167            .get(&root.computed_id)
3168            .copied()
3169            .unwrap();
3170        // Track wider than thumb on the same right edge.
3171        assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
3172        assert!(
3173            (track.right() - thumb.right()).abs() < 0.01,
3174            "track and thumb must share the right edge",
3175        );
3176        // Track spans the full inner viewport (so above/below thumb
3177        // are both inside it for click-to-page).
3178        assert!(
3179            (track.h - 200.0).abs() < 0.01,
3180            "track height = {} (expected 200)",
3181            track.h,
3182        );
3183    }
3184
3185    #[test]
3186    fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
3187        // Same scrollable, but author opted out — no thumb_rect.
3188        let mut suppressed = scroll(
3189            (0..6)
3190                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3191        )
3192        .no_scrollbar()
3193        .height(Size::Fixed(200.0));
3194        let mut state = UiState::new();
3195        layout(
3196            &mut suppressed,
3197            &mut state,
3198            Rect::new(0.0, 0.0, 300.0, 200.0),
3199        );
3200        assert!(
3201            !state
3202                .scroll
3203                .thumb_rects
3204                .contains_key(&suppressed.computed_id)
3205        );
3206
3207        // Same scrollable, content fits → no thumb either.
3208        let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
3209            .height(Size::Fixed(200.0));
3210        let mut tiny_state = UiState::new();
3211        layout(
3212            &mut tiny,
3213            &mut tiny_state,
3214            Rect::new(0.0, 0.0, 300.0, 200.0),
3215        );
3216        assert!(
3217            !tiny_state
3218                .scroll
3219                .thumb_rects
3220                .contains_key(&tiny.computed_id)
3221        );
3222    }
3223
3224    #[test]
3225    fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
3226        let make_root = || {
3227            scroll([
3228                crate::tree::spacer().height(Size::Fixed(80.0)),
3229                scroll((0..6).map(|i| {
3230                    crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
3231                }))
3232                .key("inner")
3233                .height(Size::Fixed(120.0)),
3234                crate::tree::spacer().height(Size::Fixed(260.0)),
3235            ])
3236            .key("outer")
3237            .height(Size::Fixed(220.0))
3238        };
3239
3240        let mut root = make_root();
3241        let mut state = UiState::new();
3242        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3243        let inner = root
3244            .children
3245            .iter()
3246            .find(|child| child.key.as_deref() == Some("inner"))
3247            .expect("inner scroll");
3248        let inner_id = inner.computed_id.clone();
3249        let inner_rect = state.rect(&inner_id);
3250        let thumb = state
3251            .scroll
3252            .thumb_rects
3253            .get(&inner_id)
3254            .copied()
3255            .expect("inner scroll should have a thumb");
3256        let track = state
3257            .scroll
3258            .thumb_tracks
3259            .get(&inner_id)
3260            .copied()
3261            .expect("inner scroll should have a track");
3262        let thumb_rel_y = thumb.y - inner_rect.y;
3263        let track_rel_y = track.y - inner_rect.y;
3264
3265        state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3266        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3267        let inner_rect_after = state.rect(&inner_id);
3268        let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3269        let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3270
3271        assert!(
3272            (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3273            "outer scroll should shift the inner viewport"
3274        );
3275        assert!(
3276            (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3277            "inner thumb should stay fixed relative to its viewport"
3278        );
3279        assert!(
3280            (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3281            "inner track should stay fixed relative to its viewport"
3282        );
3283    }
3284
3285    #[test]
3286    fn layout_override_places_children_at_returned_rects() {
3287        // A custom layout that just stacks children diagonally inside the container.
3288        let mut root = column((0..3).map(|i| {
3289            crate::widgets::text::text(format!("dot {i}"))
3290                .width(Size::Fixed(20.0))
3291                .height(Size::Fixed(20.0))
3292        }))
3293        .width(Size::Fixed(200.0))
3294        .height(Size::Fixed(200.0))
3295        .layout(|ctx| {
3296            ctx.children
3297                .iter()
3298                .enumerate()
3299                .map(|(i, _)| {
3300                    let off = i as f32 * 30.0;
3301                    Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3302                })
3303                .collect()
3304        });
3305        let mut state = UiState::new();
3306        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3307        let r0 = state.rect(&root.children[0].computed_id);
3308        let r1 = state.rect(&root.children[1].computed_id);
3309        let r2 = state.rect(&root.children[2].computed_id);
3310        assert_eq!((r0.x, r0.y), (0.0, 0.0));
3311        assert_eq!((r1.x, r1.y), (30.0, 30.0));
3312        assert_eq!((r2.x, r2.y), (60.0, 60.0));
3313    }
3314
3315    #[test]
3316    fn layout_override_rect_of_key_resolves_earlier_sibling() {
3317        // The popover-anchor pattern: a custom-laid-out node positions
3318        // its child by reading another keyed node's rect via the new
3319        // LayoutCtx::rect_of_key callback. The trigger lives in an
3320        // earlier sibling so its rect is already in `computed_rects`
3321        // by the time the popover layer's layout_override runs.
3322        use crate::tree::stack;
3323        let trigger_x = 40.0;
3324        let trigger_y = 20.0;
3325        let trigger_w = 60.0;
3326        let trigger_h = 30.0;
3327        let mut root = stack([
3328            // Earlier sibling: the trigger.
3329            crate::widgets::button::button("Open")
3330                .key("trig")
3331                .width(Size::Fixed(trigger_w))
3332                .height(Size::Fixed(trigger_h)),
3333            // Later sibling: a custom-laid-out container that reads
3334            // the trigger's rect to position its single child.
3335            stack([crate::widgets::text::text("popover")
3336                .width(Size::Fixed(80.0))
3337                .height(Size::Fixed(20.0))])
3338            .width(Size::Fill(1.0))
3339            .height(Size::Fill(1.0))
3340            .layout(|ctx| {
3341                let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3342                vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3343            }),
3344        ])
3345        .padding(Sides::xy(trigger_x, trigger_y));
3346        let mut state = UiState::new();
3347        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3348
3349        let popover_layer = &root.children[1];
3350        let panel_id = &popover_layer.children[0].computed_id;
3351        let panel_rect = state.rect(panel_id);
3352        // Anchored to (trigger.x, trigger.bottom() + 4.0). With padding
3353        // (40, 20) and trigger height 30 → expect (40, 54).
3354        assert!(
3355            (panel_rect.x - trigger_x).abs() < 0.01,
3356            "popover x = {} (expected {trigger_x})",
3357            panel_rect.x,
3358        );
3359        assert!(
3360            (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3361            "popover y = {} (expected {})",
3362            panel_rect.y,
3363            trigger_y + trigger_h + 4.0,
3364        );
3365    }
3366
3367    #[test]
3368    fn layout_override_rect_of_key_returns_none_for_missing_key() {
3369        let mut root = column([crate::widgets::text::text("inner")
3370            .width(Size::Fixed(40.0))
3371            .height(Size::Fixed(20.0))])
3372        .width(Size::Fixed(200.0))
3373        .height(Size::Fixed(200.0))
3374        .layout(|ctx| {
3375            assert!((ctx.rect_of_key)("nope").is_none());
3376            vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3377        });
3378        let mut state = UiState::new();
3379        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3380    }
3381
3382    #[test]
3383    fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3384        // First-frame contract: a custom layout running before its
3385        // target's sibling has been laid out should see `None`, not a
3386        // zero rect or a panic. This is what makes the popover pattern
3387        // (trigger first, popover layer second in source order) the
3388        // supported shape — the reverse direction simply gets `None`.
3389        use crate::tree::stack;
3390        let mut root = stack([
3391            stack([crate::widgets::text::text("panel")
3392                .width(Size::Fixed(40.0))
3393                .height(Size::Fixed(20.0))])
3394            .width(Size::Fill(1.0))
3395            .height(Size::Fill(1.0))
3396            .layout(|ctx| {
3397                assert!(
3398                    (ctx.rect_of_key)("later").is_none(),
3399                    "later sibling's rect must not be available yet"
3400                );
3401                vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3402            }),
3403            crate::widgets::button::button("after").key("later"),
3404        ]);
3405        let mut state = UiState::new();
3406        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3407    }
3408
3409    #[test]
3410    fn layout_override_measure_returns_intrinsic() {
3411        // The custom layout reads `measure` to size each child.
3412        let mut root = column([crate::widgets::text::text("hi")
3413            .width(Size::Fixed(40.0))
3414            .height(Size::Fixed(20.0))])
3415        .width(Size::Fixed(200.0))
3416        .height(Size::Fixed(200.0))
3417        .layout(|ctx| {
3418            let (w, h) = (ctx.measure)(&ctx.children[0]);
3419            assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3420            assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3421            vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3422        });
3423        let mut state = UiState::new();
3424        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3425        let r = state.rect(&root.children[0].computed_id);
3426        assert_eq!((r.w, r.h), (40.0, 20.0));
3427    }
3428
3429    #[test]
3430    #[should_panic(expected = "returned 1 rects for 2 children")]
3431    fn layout_override_length_mismatch_panics() {
3432        let mut root = column([
3433            crate::widgets::text::text("a")
3434                .width(Size::Fixed(10.0))
3435                .height(Size::Fixed(10.0)),
3436            crate::widgets::text::text("b")
3437                .width(Size::Fixed(10.0))
3438                .height(Size::Fixed(10.0)),
3439        ])
3440        .width(Size::Fixed(200.0))
3441        .height(Size::Fixed(200.0))
3442        .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3443        let mut state = UiState::new();
3444        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3445    }
3446
3447    #[test]
3448    #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3449    fn layout_override_hug_panics() {
3450        // Hug check fires when the parent's layout pass measures the
3451        // custom-layout child for sizing — i.e. when a layout_override
3452        // node is a child of a column/row, not when it's the root.
3453        let mut root = column([column([crate::widgets::text::text("c")])
3454            .width(Size::Hug)
3455            .height(Size::Fixed(200.0))
3456            .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3457        .width(Size::Fixed(200.0))
3458        .height(Size::Fixed(200.0));
3459        let mut state = UiState::new();
3460        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3461    }
3462
3463    #[test]
3464    fn virtual_list_realizes_only_visible_rows() {
3465        // 100 rows × 50px each in a 200px viewport, offset = 120.
3466        // Visible range: rows whose y in [-50, 200) → start = floor(120/50) = 2,
3467        // end = ceil((120+200)/50) = ceil(6.4) = 7. Five rows realized.
3468        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3469            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3470        });
3471        let mut state = UiState::new();
3472        assign_ids(&mut root);
3473        state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3474        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3475
3476        assert_eq!(
3477            root.children.len(),
3478            5,
3479            "expected 5 realized rows, got {}",
3480            root.children.len()
3481        );
3482        // Identity check: the first realized row should be the row keyed "row-2".
3483        assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3484        assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3485        // Position check: first realized row's y = inner.y + 2*50 - 120 = -20.
3486        let r0 = state.rect(&root.children[0].computed_id);
3487        assert!(
3488            (r0.y - (-20.0)).abs() < 0.5,
3489            "row 2 expected y≈-20, got {}",
3490            r0.y
3491        );
3492    }
3493
3494    #[test]
3495    fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
3496        let mut root = crate::tree::virtual_list(10, 40.0, |i| {
3497            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3498        })
3499        .gap(10.0);
3500        let mut state = UiState::new();
3501        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3502
3503        assert_eq!(
3504            root.children.len(),
3505            3,
3506            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3507        );
3508        let row_1 = root
3509            .children
3510            .iter()
3511            .find(|c| c.key.as_deref() == Some("row-1"))
3512            .expect("row 1 should be realized");
3513        assert!(
3514            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3515            "gap should place row 1 at y=50"
3516        );
3517        let metrics = state
3518            .scroll
3519            .metrics
3520            .get(&root.computed_id)
3521            .expect("virtual list writes scroll metrics");
3522        assert!(
3523            (metrics.content_h - 490.0).abs() < 0.5,
3524            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3525            metrics.content_h
3526        );
3527    }
3528
3529    #[test]
3530    fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3531        let make_root = || {
3532            crate::tree::virtual_list(50, 50.0, |i| {
3533                crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3534            })
3535        };
3536
3537        let mut state = UiState::new();
3538        let mut root_a = make_root();
3539        assign_ids(&mut root_a);
3540        // Scroll so row 5 is visible.
3541        state
3542            .scroll
3543            .offsets
3544            .insert(root_a.computed_id.clone(), 250.0);
3545        layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3546        let id_at_offset_a = root_a
3547            .children
3548            .iter()
3549            .find(|c| c.key.as_deref() == Some("row-5"))
3550            .unwrap()
3551            .computed_id
3552            .clone();
3553
3554        // Re-layout with a different offset — row 5 is still visible.
3555        let mut root_b = make_root();
3556        assign_ids(&mut root_b);
3557        state
3558            .scroll
3559            .offsets
3560            .insert(root_b.computed_id.clone(), 200.0);
3561        layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3562        let id_at_offset_b = root_b
3563            .children
3564            .iter()
3565            .find(|c| c.key.as_deref() == Some("row-5"))
3566            .unwrap()
3567            .computed_id
3568            .clone();
3569
3570        assert_eq!(
3571            id_at_offset_a, id_at_offset_b,
3572            "row-5's computed_id changed when scroll offset moved"
3573        );
3574    }
3575
3576    #[test]
3577    fn virtual_list_clamps_overshoot_offset() {
3578        // 10 rows × 50 = 500 content height; viewport 200; max offset = 300.
3579        let mut root =
3580            crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3581        let mut state = UiState::new();
3582        assign_ids(&mut root);
3583        state
3584            .scroll
3585            .offsets
3586            .insert(root.computed_id.clone(), 9999.0);
3587        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3588        let stored = state
3589            .scroll
3590            .offsets
3591            .get(&root.computed_id)
3592            .copied()
3593            .unwrap_or(0.0);
3594        assert!(
3595            (stored - 300.0).abs() < 0.01,
3596            "expected clamp to 300, got {stored}"
3597        );
3598    }
3599
3600    #[test]
3601    fn virtual_list_empty_count_realizes_no_children() {
3602        let mut root =
3603            crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3604        let mut state = UiState::new();
3605        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3606        assert_eq!(root.children.len(), 0);
3607    }
3608
3609    #[test]
3610    #[should_panic(expected = "row_height > 0.0")]
3611    fn virtual_list_zero_row_height_panics() {
3612        let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3613    }
3614
3615    #[test]
3616    #[should_panic(expected = "Size::Hug would defeat virtualization")]
3617    fn virtual_list_hug_panics() {
3618        let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3619            crate::widgets::text::text(format!("r{i}"))
3620        })
3621        .height(Size::Hug)])
3622        .width(Size::Fixed(300.0))
3623        .height(Size::Fixed(200.0));
3624        let mut state = UiState::new();
3625        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3626    }
3627
3628    #[test]
3629    fn virtual_list_dyn_respects_per_row_fixed_heights() {
3630        // Alternating 40px / 80px rows. With a 200px viewport and offset 0,
3631        // accumulated y goes 0, 40, 120, 160, 240 — the fifth row starts
3632        // past the viewport, so four rows are realized.
3633        let mut root = crate::tree::virtual_list_dyn(
3634            20,
3635            50.0,
3636            |i| format!("row-{i}"),
3637            |i| {
3638                let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3639                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3640                    .key(format!("row-{i}"))
3641                    .height(Size::Fixed(h))
3642            },
3643        );
3644        let mut state = UiState::new();
3645        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3646
3647        assert_eq!(
3648            root.children.len(),
3649            4,
3650            "expected 4 realized rows, got {}",
3651            root.children.len()
3652        );
3653        // y positions: row 0 → 0, row 1 → 40, row 2 → 120, row 3 → 160.
3654        let ys: Vec<f32> = root
3655            .children
3656            .iter()
3657            .map(|c| state.rect(&c.computed_id).y)
3658            .collect();
3659        assert!(
3660            (ys[0] - 0.0).abs() < 0.5,
3661            "row 0 expected y≈0, got {}",
3662            ys[0]
3663        );
3664        assert!(
3665            (ys[1] - 40.0).abs() < 0.5,
3666            "row 1 expected y≈40, got {}",
3667            ys[1]
3668        );
3669        assert!(
3670            (ys[2] - 120.0).abs() < 0.5,
3671            "row 2 expected y≈120, got {}",
3672            ys[2]
3673        );
3674        assert!(
3675            (ys[3] - 160.0).abs() < 0.5,
3676            "row 3 expected y≈160, got {}",
3677            ys[3]
3678        );
3679    }
3680
3681    #[test]
3682    fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3683        let mut root = crate::tree::virtual_list_dyn(
3684            10,
3685            40.0,
3686            |i| format!("row-{i}"),
3687            |i| {
3688                crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3689                    .key(format!("row-{i}"))
3690                    .height(Size::Fixed(40.0))
3691            },
3692        )
3693        .gap(10.0);
3694        let mut state = UiState::new();
3695        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3696
3697        assert_eq!(
3698            root.children.len(),
3699            3,
3700            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3701        );
3702        let row_1 = root
3703            .children
3704            .iter()
3705            .find(|c| c.key.as_deref() == Some("row-1"))
3706            .expect("row 1 should be realized");
3707        assert!(
3708            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3709            "gap should place row 1 at y=50"
3710        );
3711        let metrics = state
3712            .scroll
3713            .metrics
3714            .get(&root.computed_id)
3715            .expect("virtual list writes scroll metrics");
3716        assert!(
3717            (metrics.content_h - 490.0).abs() < 0.5,
3718            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3719            metrics.content_h
3720        );
3721    }
3722
3723    #[test]
3724    fn virtual_list_dyn_caches_measured_heights() {
3725        // Build a list where the first frame realizes rows 0..k, measuring
3726        // each. After layout the cache should hold those measurements and
3727        // the next frame should read them.
3728        let mut root = crate::tree::virtual_list_dyn(
3729            50,
3730            50.0,
3731            |i| format!("row-{i}"),
3732            |i| {
3733                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3734                    .key(format!("row-{i}"))
3735                    .height(Size::Fixed(30.0))
3736            },
3737        );
3738        let mut state = UiState::new();
3739        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3740
3741        let measured = state
3742            .scroll
3743            .measured_row_heights
3744            .get(&root.computed_id)
3745            .expect("dynamic virtual list should populate the height cache");
3746        // The first pass measures the estimate-derived window, then
3747        // the anchored final pass can extend it with newly revealed
3748        // rows. At least six rows are visible/cached here.
3749        assert!(
3750            measured.len() >= 6,
3751            "expected ≥ 6 cached row heights, got {}",
3752            measured.len()
3753        );
3754        for by_width in measured.values() {
3755            let h = by_width
3756                .get(&300)
3757                .copied()
3758                .expect("measurement should be keyed at the 300px width bucket");
3759            assert!(
3760                (h - 30.0).abs() < 0.5,
3761                "expected cached height ≈ 30, got {h}"
3762            );
3763        }
3764    }
3765
3766    #[test]
3767    fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3768        let make_root = || {
3769            crate::tree::virtual_list_dyn(
3770                100,
3771                40.0,
3772                |i| format!("row-{i}"),
3773                |i| {
3774                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3775                        .key(format!("row-{i}"))
3776                        .height(Size::Fixed(40.0))
3777                },
3778            )
3779        };
3780        let mut root = make_root();
3781        let mut state = UiState::new();
3782        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3783
3784        state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3785        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3786
3787        let anchor = state
3788            .scroll
3789            .virtual_anchors
3790            .get(&root.computed_id)
3791            .cloned()
3792            .expect("dynamic list should store a visible anchor");
3793        let before_y = root
3794            .children
3795            .iter()
3796            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3797            .map(|child| state.rect(&child.computed_id).y)
3798            .expect("anchor row should be realized");
3799        let before_offset = state.scroll_offset(&root.computed_id);
3800
3801        state
3802            .scroll
3803            .measured_row_heights
3804            .entry(root.computed_id.clone())
3805            .or_default()
3806            .entry("row-0".to_string())
3807            .or_default()
3808            .insert(300, 120.0);
3809
3810        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3811        let after_y = root
3812            .children
3813            .iter()
3814            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3815            .map(|child| state.rect(&child.computed_id).y)
3816            .expect("anchor row should remain realized");
3817        let after_offset = state.scroll_offset(&root.computed_id);
3818
3819        assert!(
3820            (after_y - before_y).abs() < 0.5,
3821            "anchor row should stay at y={before_y}, got {after_y}"
3822        );
3823        assert!(
3824            (after_offset - (before_offset + 80.0)).abs() < 0.5,
3825            "offset should absorb the 80px measurement delta above anchor"
3826        );
3827    }
3828
3829    #[test]
3830    fn virtual_list_dyn_height_cache_is_width_bucketed() {
3831        let mut root = crate::tree::virtual_list_dyn(
3832            20,
3833            50.0,
3834            |i| format!("row-{i}"),
3835            |i| {
3836                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3837                    .key(format!("row-{i}"))
3838                    .height(Size::Fixed(30.0))
3839            },
3840        );
3841        let mut state = UiState::new();
3842        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3843        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3844
3845        let row_0 = state
3846            .scroll
3847            .measured_row_heights
3848            .get(&root.computed_id)
3849            .and_then(|m| m.get("row-0"))
3850            .expect("row 0 should be measured");
3851        assert!(
3852            row_0.contains_key(&300) && row_0.contains_key(&240),
3853            "expected width buckets 300 and 240, got {:?}",
3854            row_0.keys().collect::<Vec<_>>()
3855        );
3856    }
3857
3858    #[test]
3859    fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3860        // Measured rows use their cached fixed 30px height; rows that
3861        // have not been seen at this width still use the 50px estimate.
3862        // An overshoot offset must clamp to the mixed measured/estimated
3863        // content height after the final visible measurements land.
3864        let make_root = || {
3865            crate::tree::virtual_list_dyn(
3866                20,
3867                50.0,
3868                |i| format!("row-{i}"),
3869                |i| {
3870                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3871                        .key(format!("row-{i}"))
3872                        .height(Size::Fixed(30.0))
3873                },
3874            )
3875        };
3876        let mut state = UiState::new();
3877        let mut root = make_root();
3878        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3879
3880        state
3881            .scroll
3882            .offsets
3883            .insert(root.computed_id.clone(), 9999.0);
3884        let mut root2 = make_root();
3885        layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3886
3887        let measured = state
3888            .scroll
3889            .measured_row_heights
3890            .get(&root2.computed_id)
3891            .expect("dynamic virtual list should populate the height cache");
3892        let measured_sum = measured
3893            .values()
3894            .filter_map(|by_width| by_width.get(&300))
3895            .sum::<f32>();
3896        let measured_count = measured
3897            .values()
3898            .filter(|by_width| by_width.contains_key(&300))
3899            .count();
3900        let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3901        let expected_max_offset = expected_total - 200.0;
3902
3903        let stored = state
3904            .scroll
3905            .offsets
3906            .get(&root2.computed_id)
3907            .copied()
3908            .unwrap_or(0.0);
3909        assert!(
3910            (stored - expected_max_offset).abs() < 0.5,
3911            "expected offset clamped to {expected_max_offset}, got {stored}"
3912        );
3913    }
3914
3915    #[test]
3916    fn virtual_list_dyn_empty_count_realizes_no_children() {
3917        let mut root = crate::tree::virtual_list_dyn(
3918            0,
3919            50.0,
3920            |i| format!("row-{i}"),
3921            |i| crate::widgets::text::text(format!("r{i}")),
3922        );
3923        let mut state = UiState::new();
3924        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3925        assert_eq!(root.children.len(), 0);
3926    }
3927
3928    #[test]
3929    #[should_panic(expected = "estimated_row_height > 0.0")]
3930    fn virtual_list_dyn_zero_estimate_panics() {
3931        let _ = crate::tree::virtual_list_dyn(
3932            10,
3933            0.0,
3934            |i| format!("row-{i}"),
3935            |i| crate::widgets::text::text(format!("r{i}")),
3936        );
3937    }
3938
3939    #[test]
3940    fn text_runs_constructor_shape_smoke() {
3941        let el = crate::tree::text_runs([
3942            crate::widgets::text::text("Hello, "),
3943            crate::widgets::text::text("world").bold(),
3944            crate::tree::hard_break(),
3945            crate::widgets::text::text("of text").italic(),
3946        ]);
3947        assert_eq!(el.kind, Kind::Inlines);
3948        assert_eq!(el.children.len(), 4);
3949        assert!(matches!(
3950            el.children[1].font_weight,
3951            FontWeight::Bold | FontWeight::Semibold
3952        ));
3953        assert_eq!(el.children[2].kind, Kind::HardBreak);
3954        assert!(el.children[3].text_italic);
3955    }
3956
3957    #[test]
3958    fn wrapped_text_hugs_multiline_height_from_available_width() {
3959        let mut root = column([crate::paragraph(
3960            "A longer sentence should wrap into multiple measured lines.",
3961        )])
3962        .width(Size::Fill(1.0))
3963        .height(Size::Hug);
3964
3965        let mut state = UiState::new();
3966        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
3967
3968        let child_rect = state.rect(&root.children[0].computed_id);
3969        assert_eq!(child_rect.w, 180.0);
3970        assert!(
3971            child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3972            "expected multiline paragraph height, got {}",
3973            child_rect.h
3974        );
3975    }
3976
3977    #[test]
3978    fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
3979        // Regression: overlay_rect used to call `intrinsic(c)` with no
3980        // width hint, so a Fixed-width modal containing a wrappable
3981        // paragraph measured the paragraph as a single line — leaving
3982        // the modal's Hug height short by the wrapped lines and
3983        // crowding the buttons against the bottom edge of the panel
3984        // (rumble cert-pending modal showed this).
3985        //
3986        // The fix: pass the child's resolved width as the available
3987        // width for intrinsic measurement, mirroring what column/row
3988        // already do.
3989        const PANEL_W: f32 = 240.0;
3990        const PADDING: f32 = 18.0;
3991        const GAP: f32 = 12.0;
3992
3993        let panel = column([
3994            crate::paragraph(
3995                "A long enough warning paragraph that it has to wrap onto a second line \
3996                 inside this narrow panel.",
3997            ),
3998            crate::widgets::button::button("OK").key("ok"),
3999        ])
4000        .width(Size::Fixed(PANEL_W))
4001        .height(Size::Hug)
4002        .padding(Sides::all(PADDING))
4003        .gap(GAP)
4004        .align(Align::Stretch);
4005
4006        let mut root = crate::stack([panel])
4007            .width(Size::Fill(1.0))
4008            .height(Size::Fill(1.0));
4009        let mut state = UiState::new();
4010        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4011
4012        let panel_rect = state.rect(&root.children[0].computed_id);
4013        assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
4014
4015        let para_rect = state.rect(&root.children[0].children[0].computed_id);
4016        let button_rect = state.rect(&root.children[0].children[1].computed_id);
4017
4018        // Paragraph wrapped to ≥ 2 lines (exact line count depends on
4019        // glyph metrics; just guard against the single-line bug).
4020        assert!(
4021            para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4022            "paragraph should wrap to multiple lines inside the Fixed-width panel; \
4023             got h={}",
4024            para_rect.h
4025        );
4026
4027        // Panel height must accommodate top padding + paragraph +
4028        // gap + button + bottom padding. The bug was that the panel
4029        // came out exactly `padding + gap + 1-line-paragraph + button`
4030        // — short by the second wrap line — and the button overshot
4031        // the inner area, leaving zero pixels of bottom padding.
4032        let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
4033        assert!(
4034            (bottom_padding - PADDING).abs() < 0.5,
4035            "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
4036        );
4037    }
4038
4039    #[test]
4040    fn row_with_fill_paragraph_propagates_height_to_parent_column() {
4041        // Regression: the Row branch of `intrinsic_constrained` called
4042        // `intrinsic(ch)` unconstrained, so a wrappable Fill child
4043        // (paragraph) measured as a single unwrapped line. Two such rows
4044        // in a column then got one-line-tall allocations and the second
4045        // row's gutter rect overlapped the first row's wrapped text
4046        // (chat-port event-log recipe in damascene-core/README.md hit this).
4047        //
4048        // The fix mirrors `layout_axis`: the Row intrinsic distributes
4049        // its available width across Fill children before measuring,
4050        // so wrappable Fill children see the width they will actually
4051        // be laid out at.
4052        const COL_W: f32 = 600.0;
4053        const GUTTER_W: f32 = 3.0;
4054
4055        let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
4056                    sed do eiusmod tempor incididunt ut labore et dolore magna \
4057                    aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
4058                    ullamco laboris nisi ut aliquip ex ea commodo consequat.";
4059
4060        let make_row = || {
4061            let gutter = El::new(Kind::Custom("gutter"))
4062                .width(Size::Fixed(GUTTER_W))
4063                .height(Size::Fill(1.0));
4064            let body = crate::paragraph(long).width(Size::Fill(1.0));
4065            crate::row([gutter, body]).width(Size::Fill(1.0))
4066        };
4067
4068        let mut root = column([make_row(), make_row()])
4069            .width(Size::Fixed(COL_W))
4070            .height(Size::Hug)
4071            .align(Align::Stretch);
4072        let mut state = UiState::new();
4073        layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
4074
4075        let row0_rect = state.rect(&root.children[0].computed_id);
4076        let row1_rect = state.rect(&root.children[1].computed_id);
4077        let para0_rect = state.rect(&root.children[0].children[1].computed_id);
4078
4079        // Both the paragraph rect and the row rect must reflect the
4080        // wrapped (multi-line) height. The bug pinned them to a single
4081        // line (~`TEXT_SM.line_height` = 20px), so the wrapped text
4082        // painted outside the row's allocated rect.
4083        let line_height = crate::tokens::TEXT_SM.line_height;
4084        assert!(
4085            para0_rect.h > line_height * 1.5,
4086            "paragraph should wrap to multiple lines at ~597px wide; \
4087             got h={} (line_height={})",
4088            para0_rect.h,
4089            line_height,
4090        );
4091        assert!(
4092            row0_rect.h > line_height * 1.5,
4093            "row 0 should accommodate the wrapped paragraph height; \
4094             got h={} (line_height={})",
4095            row0_rect.h,
4096            line_height,
4097        );
4098
4099        // Sanity: row 1 sits below row 0's allocated rect, not above it.
4100        assert!(
4101            row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
4102            "row 1 starts at y={} but row 0 occupies y={}..{}",
4103            row1_rect.y,
4104            row0_rect.y,
4105            row0_rect.y + row0_rect.h,
4106        );
4107    }
4108
4109    /// `min_width` floors a child whose resolved cross-axis size is
4110    /// below the floor. Tests against an `align(Start)` column so
4111    /// `Size::Fixed` doesn't get widened by the default Stretch
4112    /// alignment before clamping has a chance to apply.
4113    #[test]
4114    fn min_width_floors_resolved_cross_axis_size() {
4115        let mut root = column([crate::widgets::text::text("hi")
4116            .width(Size::Fixed(40.0))
4117            .height(Size::Fixed(20.0))
4118            .min_width(120.0)])
4119        .align(Align::Start)
4120        .width(Size::Fixed(500.0))
4121        .height(Size::Fixed(200.0));
4122        let mut state = UiState::new();
4123        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4124        let child_rect = state.rect(&root.children[0].computed_id);
4125        assert!(
4126            (child_rect.w - 120.0).abs() < 0.5,
4127            "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
4128            child_rect.w,
4129        );
4130    }
4131
4132    /// `max_width` caps a `Size::Fill` child even when the surrounding
4133    /// row offers more space.
4134    #[test]
4135    fn max_width_caps_fill_child() {
4136        let mut root = crate::row([crate::widgets::text::text("body")
4137            .width(Size::Fill(1.0))
4138            .height(Size::Fixed(20.0))
4139            .max_width(160.0)])
4140        .width(Size::Fixed(800.0))
4141        .height(Size::Fixed(40.0));
4142        let mut state = UiState::new();
4143        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
4144        let child_rect = state.rect(&root.children[0].computed_id);
4145        assert!(
4146            (child_rect.w - 160.0).abs() < 0.5,
4147            "expected Fill child capped at 160, got w={}",
4148            child_rect.w,
4149        );
4150    }
4151
4152    /// When `min_width` and `max_width` conflict, the lower bound wins
4153    /// (CSS `min-width` precedence over `max-width`).
4154    #[test]
4155    fn min_width_wins_over_max_width_when_conflicting() {
4156        let mut root = column([crate::widgets::text::text("x")
4157            .width(Size::Fixed(50.0))
4158            .height(Size::Fixed(20.0))
4159            .max_width(80.0)
4160            .min_width(120.0)]);
4161        let mut state = UiState::new();
4162        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4163        let child_rect = state.rect(&root.children[0].computed_id);
4164        assert!(
4165            (child_rect.w - 120.0).abs() < 0.5,
4166            "expected min_width (120) to win over max_width (80), got w={}",
4167            child_rect.w,
4168        );
4169    }
4170
4171    /// `min_height` floors a Hug child column whose children sum to
4172    /// less than the floor. Tested through a fixed-size parent so the
4173    /// resolved rect of the inner column reflects the clamp.
4174    #[test]
4175    fn min_height_floors_hug_column_inside_fixed_parent() {
4176        let inner = column([crate::widgets::text::text("a")
4177            .width(Size::Fixed(40.0))
4178            .height(Size::Fixed(20.0))])
4179        .width(Size::Fixed(80.0))
4180        .height(Size::Hug)
4181        .min_height(200.0);
4182        let mut root = column([inner])
4183            .align(Align::Start)
4184            .width(Size::Fixed(800.0))
4185            .height(Size::Fixed(600.0));
4186        let mut state = UiState::new();
4187        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4188        let inner_rect = state.rect(&root.children[0].computed_id);
4189        assert!(
4190            (inner_rect.h - 200.0).abs() < 0.5,
4191            "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
4192            inner_rect.h,
4193        );
4194    }
4195
4196    /// Row laying out a `Fill` Hug-column with a wrap-text child must
4197    /// measure the column's height at the column's allocated width, not
4198    /// unconstrained. Repro for the lint regression that fires on a
4199    /// `row([column([wrap_text(...).fill_width()]).fill_width(), fixed])`
4200    /// shape: without the constrained measurement, the column reports
4201    /// its single-line unwrapped height to the row, the row sizes the
4202    /// column rect at that height, and the wrapped text then overflows
4203    /// the column vertically (Overflow `B=N` finding).
4204    #[test]
4205    fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
4206        // 200px-wide row. The fixed child takes 40; the Fill column gets
4207        // 200 - 40 - 12 (gap) = 148. The paragraph wraps at 148px to two
4208        // lines; the column's intrinsic height should reflect that.
4209        let mut root = crate::row([
4210            column([crate::widgets::text::paragraph(
4211                "A long enough description that must wrap to two lines at 148px",
4212            )])
4213            .width(Size::Fill(1.0)),
4214            crate::widgets::text::text("ok")
4215                .width(Size::Fixed(40.0))
4216                .height(Size::Fixed(20.0)),
4217        ])
4218        .gap(12.0)
4219        .align(Align::Center)
4220        .width(Size::Fixed(200.0));
4221        let mut state = UiState::new();
4222        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
4223        // Find the column child (root.children[0]) and its paragraph leaf.
4224        let col_rect = state.rect(&root.children[0].computed_id);
4225        let para_rect = state.rect(&root.children[0].children[0].computed_id);
4226        assert!(
4227            (col_rect.h - para_rect.h).abs() < 0.5,
4228            "column height ({}) should track its wrapped child's height ({})",
4229            col_rect.h,
4230            para_rect.h,
4231        );
4232    }
4233
4234    /// `Size::Aspect` on the main axis (height inside a Column) derives
4235    /// from the resolved cross size. Width fills its column's 200px;
4236    /// height should be 200 * 0.5 = 100.
4237    #[test]
4238    fn aspect_on_column_main_axis_derives_from_cross() {
4239        let mut root = column([El::new(Kind::Group)
4240            .width(Size::Fill(1.0))
4241            .height(Size::Aspect(0.5))])
4242        .width(Size::Fixed(200.0))
4243        .height(Size::Fixed(400.0));
4244        let mut state = UiState::new();
4245        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
4246        let r = state.rect(&root.children[0].computed_id);
4247        assert!(
4248            (r.w - 200.0).abs() < 0.5,
4249            "expected w≈200 (Fill), got {}",
4250            r.w,
4251        );
4252        assert!(
4253            (r.h - 100.0).abs() < 0.5,
4254            "expected h≈100 (Aspect 0.5 of 200), got {}",
4255            r.h,
4256        );
4257    }
4258
4259    /// Surrounding layout flows around an Aspect-sized image: a Hug
4260    /// column containing an Aspect-height El + a fixed-height sibling
4261    /// must have an outer height equal to derived height + sibling.
4262    #[test]
4263    fn aspect_height_pushes_siblings_in_column() {
4264        let mut root = column([
4265            El::new(Kind::Group)
4266                .width(Size::Fill(1.0))
4267                .height(Size::Aspect(0.25)),
4268            crate::widgets::text::text("caption")
4269                .width(Size::Fixed(40.0))
4270                .height(Size::Fixed(20.0)),
4271        ])
4272        .width(Size::Fixed(400.0))
4273        .height(Size::Fixed(500.0));
4274        let mut state = UiState::new();
4275        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
4276        let img = state.rect(&root.children[0].computed_id);
4277        let cap = state.rect(&root.children[1].computed_id);
4278        assert!(
4279            (img.h - 100.0).abs() < 0.5,
4280            "expected aspect-derived height ≈100, got {}",
4281            img.h,
4282        );
4283        assert!(
4284            (cap.y - 100.0).abs() < 0.5,
4285            "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
4286            cap.y,
4287        );
4288    }
4289
4290    /// `Size::Aspect` on the cross axis (width inside a Row) derives
4291    /// from the resolved main (height). Height fills 200; width should
4292    /// be 200 * 2.0 = 400.
4293    #[test]
4294    fn aspect_on_row_cross_axis_derives_from_main() {
4295        let mut root = crate::row([El::new(Kind::Group)
4296            .height(Size::Fill(1.0))
4297            .width(Size::Aspect(2.0))])
4298        .width(Size::Fixed(800.0))
4299        .height(Size::Fixed(200.0));
4300        let mut state = UiState::new();
4301        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
4302        let r = state.rect(&root.children[0].computed_id);
4303        assert!(
4304            (r.h - 200.0).abs() < 0.5,
4305            "expected h≈200 (Fill), got {}",
4306            r.h,
4307        );
4308        assert!(
4309            (r.w - 400.0).abs() < 0.5,
4310            "expected w≈400 (Aspect 2.0 of 200), got {}",
4311            r.w,
4312        );
4313    }
4314
4315    /// Both axes `Aspect` is degenerate — falls back to intrinsic so
4316    /// the El still has a finite measure.
4317    #[test]
4318    fn aspect_on_both_axes_falls_back_to_intrinsic() {
4319        let mut root = column([crate::widgets::text::text("hi")
4320            .width(Size::Aspect(1.0))
4321            .height(Size::Aspect(1.0))])
4322        .width(Size::Fixed(200.0))
4323        .height(Size::Fixed(200.0));
4324        let mut state = UiState::new();
4325        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4326        let r = state.rect(&root.children[0].computed_id);
4327        assert!(
4328            r.w > 0.0 && r.h > 0.0,
4329            "expected finite size for both-Aspect fallback, got {}x{}",
4330            r.w,
4331            r.h,
4332        );
4333    }
4334
4335    /// `max_height` and `min_height` cap the Aspect-derived axis, and
4336    /// the hugging parent's intrinsic agrees with the layout-time size
4337    /// (no overflow, no gap).
4338    #[test]
4339    fn aspect_respects_min_and_max_on_derived_axis() {
4340        // Case 1: max_height caps a too-tall derived height.
4341        // Fill(1.0) width inside Fixed(400) → 400 wide; Aspect(1.0) →
4342        // 400 tall; max_height=120 → clamped to 120.
4343        let mut root = column([column([El::new(Kind::Group)
4344            .width(Size::Fill(1.0))
4345            .height(Size::Aspect(1.0))
4346            .max_height(120.0)])
4347        .width(Size::Hug)
4348        .height(Size::Hug)])
4349        .width(Size::Fixed(400.0))
4350        .height(Size::Fixed(600.0));
4351        let mut state = UiState::new();
4352        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4353        let panel = state.rect(&root.children[0].computed_id);
4354        let img = state.rect(&root.children[0].children[0].computed_id);
4355        assert!(
4356            (img.h - 120.0).abs() < 0.5,
4357            "max_height should clamp aspect-derived height to 120, got {}",
4358            img.h,
4359        );
4360        assert!(
4361            (panel.h - 120.0).abs() < 0.5,
4362            "hugging panel should match clamped child (120), got {}",
4363            panel.h,
4364        );
4365
4366        // Case 2: min_height pushes a too-short derived height up.
4367        // Aspect(0.1) of 400 = 40; min_height=200 → bumped to 200.
4368        let mut root = column([column([El::new(Kind::Group)
4369            .width(Size::Fill(1.0))
4370            .height(Size::Aspect(0.1))
4371            .min_height(200.0)])
4372        .width(Size::Hug)
4373        .height(Size::Hug)])
4374        .width(Size::Fixed(400.0))
4375        .height(Size::Fixed(600.0));
4376        let mut state = UiState::new();
4377        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4378        let panel = state.rect(&root.children[0].computed_id);
4379        let img = state.rect(&root.children[0].children[0].computed_id);
4380        assert!(
4381            (img.h - 200.0).abs() < 0.5,
4382            "min_height should bump aspect-derived height to 200, got {}",
4383            img.h,
4384        );
4385        assert!(
4386            (panel.h - 200.0).abs() < 0.5,
4387            "hugging panel should match bumped child (200), got {}",
4388            panel.h,
4389        );
4390    }
4391
4392    /// `max_width` on the basis axis caps the Fill basis *before* the
4393    /// Aspect-derived axis is computed, matching the layout-time path.
4394    #[test]
4395    fn aspect_basis_is_clamped_before_deriving() {
4396        // Fill width in 400-wide column, but max_width=100 → basis=100.
4397        // Aspect(0.5) → height=50, not 200.
4398        // Align::Stretch (default) so Fill claims the column's cross
4399        // extent; with Align::Start a Fill child would shrink to its
4400        // intrinsic (0 for a bare Group), defeating the test.
4401        let mut root = column([El::new(Kind::Group)
4402            .width(Size::Fill(1.0))
4403            .height(Size::Aspect(0.5))
4404            .max_width(100.0)])
4405        .width(Size::Fixed(400.0))
4406        .height(Size::Fixed(400.0));
4407        let mut state = UiState::new();
4408        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4409        let img = state.rect(&root.children[0].computed_id);
4410        assert!(
4411            (img.w - 100.0).abs() < 0.5,
4412            "max_width should cap Fill width at 100, got {}",
4413            img.w,
4414        );
4415        assert!(
4416            (img.h - 50.0).abs() < 0.5,
4417            "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
4418            img.h,
4419        );
4420    }
4421
4422    /// Regression: when a `Fill+Aspect` child sits inside a Hug column,
4423    /// the hugging column must size itself to the Aspect-derived height
4424    /// derived against the *parent's* available width, not the El's
4425    /// own natural intrinsic. Otherwise the column hugs too small and
4426    /// the child overflows downward at paint.
4427    #[test]
4428    fn hug_column_around_fill_aspect_child_does_not_overflow() {
4429        // Outer column is Fixed(400, 400) — the available width handed
4430        // down. Middle column hugs (the panel/card surrogate); inner
4431        // image has width=Fill, height=Aspect(0.5). At layout time the
4432        // image should be (400, 200), so the hugging panel must also
4433        // hug to (400, 200) — not to (nat_w, nat_w * 0.5) which would
4434        // be (1, 0.5) for a default-pixel-less El.
4435        let mut root = column([column([El::new(Kind::Group)
4436            .width(Size::Fill(1.0))
4437            .height(Size::Aspect(0.5))])
4438        .width(Size::Hug)
4439        .height(Size::Hug)])
4440        .width(Size::Fixed(400.0))
4441        .height(Size::Fixed(400.0));
4442        let mut state = UiState::new();
4443        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4444        let panel = state.rect(&root.children[0].computed_id);
4445        let img = state.rect(&root.children[0].children[0].computed_id);
4446        assert!(
4447            (panel.h - 200.0).abs() < 0.5,
4448            "hugging panel should hug to aspect-derived height 200, got {}",
4449            panel.h,
4450        );
4451        assert!(
4452            (img.h - 200.0).abs() < 0.5,
4453            "image should layout to height 200, got {}",
4454            img.h,
4455        );
4456        assert!(
4457            img.bottom() <= panel.bottom() + 0.5,
4458            "image (bottom={}) must fit within hugging panel (bottom={})",
4459            img.bottom(),
4460            panel.bottom(),
4461        );
4462    }
4463
4464    /// When a parent hugs to its child and the child has `Aspect`, the
4465    /// hugging parent reports a size that matches what the child will
4466    /// actually paint at — the intrinsic post-step ensures consistency.
4467    #[test]
4468    fn hugging_parent_sees_aspect_corrected_intrinsic() {
4469        // Inner El has width=Fixed(80), height=Aspect(0.5) → intrinsic
4470        // height should derive to 40. The hugging column wrapping it
4471        // should size to (80, 40), not (80, 0) or (80, natural).
4472        let mut root = column([column([El::new(Kind::Group)
4473            .width(Size::Fixed(80.0))
4474            .height(Size::Aspect(0.5))])
4475        .width(Size::Hug)
4476        .height(Size::Hug)])
4477        .width(Size::Fixed(400.0))
4478        .height(Size::Fixed(400.0))
4479        .align(Align::Start);
4480        let mut state = UiState::new();
4481        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4482        let hugger = state.rect(&root.children[0].computed_id);
4483        assert!(
4484            (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
4485            "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
4486            hugger.w,
4487            hugger.h,
4488        );
4489    }
4490
4491    /// `max_height` caps a `Hug` overlay child below its intrinsic.
4492    #[test]
4493    fn max_height_caps_overlay_child_below_intrinsic() {
4494        // Overlay parent sized 600x600; child Hug column whose intrinsic
4495        // height is 300 (single 300-tall fixed leaf), capped at 100.
4496        let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
4497            .width(Size::Fixed(40.0))
4498            .height(Size::Fixed(300.0))])
4499        .width(Size::Hug)
4500        .height(Size::Hug)
4501        .max_height(100.0)])
4502        .width(Size::Fixed(600.0))
4503        .height(Size::Fixed(600.0));
4504        let mut state = UiState::new();
4505        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
4506        let child_rect = state.rect(&root.children[0].computed_id);
4507        assert!(
4508            (child_rect.h - 100.0).abs() < 0.5,
4509            "expected child height capped at 100, got h={}",
4510            child_rect.h,
4511        );
4512    }
4513}