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
34// Lock in full per-item documentation for this module (issue #73).
35#![warn(missing_docs)]
36
37use std::cell::RefCell;
38use std::sync::Arc;
39
40use rustc_hash::FxHashSet;
41
42use crate::scroll::{ScrollAlignment, ScrollRequest};
43use crate::state::dyn_height::DynHeightIndex;
44use crate::state::{ScrollAnchor, UiState, VirtualAnchor};
45use crate::text::metrics as text_metrics;
46use crate::tree::*;
47
48/// Second escape hatch: author-supplied layout function.
49///
50/// When set on a node via [`El::layout`], the layout pass calls this
51/// function instead of running the column/row/overlay distribution for
52/// that node's direct children. The function returns one [`Rect`] per
53/// child (in source order), positioned anywhere inside the container.
54/// The library still recurses into each child (so descendants lay out
55/// normally) and still drives hit-test, focus, animation, scroll —
56/// those all read from `UiState`'s computed-rect side map, which receives the
57/// rects this function produces.
58///
59/// Authors typically write a free `fn(LayoutCtx) -> Vec<Rect>` and
60/// pass it directly: `column(children).layout(my_layout)`.
61///
62/// ## What you get
63///
64/// - [`LayoutCtx::container`] — the rect available for placement
65///   (parent rect minus this node's padding).
66/// - [`LayoutCtx::children`] — read-only slice of the node's children;
67///   index here matches the index in your returned `Vec<Rect>`.
68/// - [`LayoutCtx::measure`] — call to get a child's intrinsic
69///   `(width, height)` if you need it for sizing decisions.
70///
71/// ## Scope limits (will panic)
72///
73/// - The custom-layout node itself must size with [`Size::Fixed`] or
74///   [`Size::Fill`] on both axes. `Size::Hug` would require a separate
75///   intrinsic callback and is not yet supported.
76/// - The returned `Vec<Rect>` length must equal `children.len()`.
77#[derive(Clone)]
78pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
79
80impl LayoutFn {
81    /// Wrap a closure as a [`LayoutFn`]. Equivalent to passing a free
82    /// `fn` directly to [`El::layout`].
83    pub fn new<F>(f: F) -> Self
84    where
85        F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
86    {
87        LayoutFn(Arc::new(f))
88    }
89}
90
91impl std::fmt::Debug for LayoutFn {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.write_str("LayoutFn(<fn>)")
94    }
95}
96
97/// Sizing-pass counters. The per-frame intrinsic-measurement *cache*
98/// is gone — the sizing pass visits each node exactly once, so there
99/// is nothing left to cache — but the counter type and
100/// [`take_intrinsic_cache_stats`] survive so host diagnostics keep
101/// their shape: `misses` now counts sizing-pass node visits (expect
102/// ≈ the tree's node count, once per frame) and `hits` is always 0.
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub struct LayoutIntrinsicCacheStats {
105    /// Always 0 — retained for diagnostic-field compatibility.
106    pub hits: u64,
107    /// Nodes visited by the sizing pass (one visit per node per frame).
108    pub misses: u64,
109}
110
111/// Counters for the scroll-layout prune optimization, which skips
112/// recursing into scroll-container children that lie far outside the
113/// visible range. Recorded during each layout pass; retrieve the latest
114/// pass's numbers with [`take_prune_stats`].
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
116pub struct LayoutPruneStats {
117    /// Off-screen child subtrees whose recursion was skipped.
118    pub subtrees: u64,
119    /// Total descendant nodes inside those skipped subtrees.
120    pub nodes: u64,
121}
122
123thread_local! {
124    /// Sizing-pass visit counter for the layout pass currently running
125    /// on this thread; drained into `LAST_SIZING_STATS` at the end of
126    /// `layout_post_assign` and read back via
127    /// [`take_intrinsic_cache_stats`].
128    static SIZING_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
129    static LAST_SIZING_STATS: RefCell<LayoutIntrinsicCacheStats> =
130        const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
131    static PRUNE_STATS: RefCell<LayoutPruneStats> =
132        const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
133    static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
134        const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
135}
136
137/// Take (and reset) the sizing-pass stats recorded by this thread's
138/// most recent layout pass. The runtime drains this once per frame into
139/// its timing diagnostics. (Name kept from the retired per-frame
140/// intrinsic cache; see [`LayoutIntrinsicCacheStats`].)
141pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
142    LAST_SIZING_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
143}
144
145/// Take (and reset) the scroll-prune stats recorded by this thread's
146/// most recent layout pass. The runtime drains this once per frame into
147/// its timing diagnostics.
148pub fn take_prune_stats() -> LayoutPruneStats {
149    LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
150}
151
152/// Virtualized list state attached to a [`Kind::VirtualList`] node.
153/// Holds the row count, the row-height policy, and the closure that
154/// realizes a row by global index. Set via [`crate::virtual_list`] or
155/// [`crate::virtual_list_dyn`]; the layout pass calls `build_row(i)`
156/// only for indices whose rect intersects the viewport.
157///
158/// ## Row-height policies
159///
160/// - [`VirtualMode::Fixed`] — every row is the same logical-pixel
161///   height. Scroll → visible-range is O(1).
162/// - [`VirtualMode::Dynamic`] — rows vary in height. The library uses
163///   `estimated_row_height` as a placeholder for unmeasured rows,
164///   measures visible rows at the current layout width, and preserves a
165///   row anchor on screen while estimates become measurements.
166///
167/// ## Other current scope
168///
169/// - **Vertical only** — feed/chat-log-shaped lists are the target.
170///   A horizontal variant can come later.
171/// - **No row pooling** — visible rows are rebuilt from scratch each
172///   layout pass. Fine for thousands of items; if it bottlenecks we
173///   add a pool keyed by stable row keys.
174#[derive(Clone, Debug)]
175pub enum VirtualMode {
176    /// Every row is exactly `row_height` logical pixels tall.
177    Fixed {
178        /// Uniform row height in logical pixels. Must be > 0.
179        row_height: f32,
180    },
181    /// Rows have variable heights. `estimated_row_height` seeds the
182    /// content-height total and the visible-range walk for rows that
183    /// haven't been measured yet.
184    Dynamic {
185        /// Placeholder height (logical pixels) for not-yet-measured
186        /// rows. Must be > 0.
187        estimated_row_height: f32,
188        /// Opt-in contract: across frames the row sequence changes
189        /// **only** by appending rows at the tail and/or dropping a
190        /// contiguous prefix at the head (an append-at-bottom feed or a
191        /// capped ring buffer). When set, layout maintains an
192        /// incremental per-row height index so the per-frame cost is
193        /// O(visible) instead of several O(n) walks — the difference
194        /// that lets a 100k-row chat log stay inside frame budget while
195        /// scrolling (issue #107). Reordering, mid-list insertion, or
196        /// re-keying a surviving row violates the contract: debug builds
197        /// assert, release self-heals with a one-frame O(n) rebuild.
198        /// Set via [`VirtualItems::append_only`] /
199        /// [`crate::tree::El::append_only`].
200        append_only: bool,
201    },
202}
203
204/// Policy used to pick the next dynamic virtual-list anchor after each
205/// layout pass. The previous anchor solves the current frame; this
206/// policy rebases the next frame onto a coherent in-viewport row point.
207#[derive(Clone, Copy, Debug, PartialEq)]
208pub enum VirtualAnchorPolicy {
209    /// Pick the row point nearest `y_fraction` through the viewport.
210    /// `0.0` is the top, `1.0` is the bottom. Good default for feeds.
211    ViewportFraction {
212        /// Fraction of the viewport height (clamped to `0.0..=1.0`)
213        /// where the anchor point is sampled.
214        y_fraction: f32,
215    },
216    /// Prefer the first fully visible row; fall back to the first
217    /// partially visible row.
218    FirstVisible,
219    /// Prefer the last fully visible row; fall back to the last
220    /// partially visible row.
221    LastVisible,
222}
223
224impl Default for VirtualAnchorPolicy {
225    fn default() -> Self {
226        Self::ViewportFraction { y_fraction: 0.25 }
227    }
228}
229
230/// Virtualized list state attached to a [`Kind::VirtualList`] node:
231/// row count, row-height policy, and the closures that identify and
232/// realize rows. Built via [`Self::new`] / [`Self::new_dyn`] or,
233/// more commonly, the [`crate::virtual_list`] /
234/// [`crate::virtual_list_dyn`] builders. See [`VirtualMode`] for the
235/// scope and policy discussion.
236#[derive(Clone)]
237#[non_exhaustive]
238pub struct VirtualItems {
239    /// Total number of rows in the list (realized or not).
240    pub count: usize,
241    /// Row-height policy — see [`VirtualMode`].
242    pub mode: VirtualMode,
243    /// How the next frame's scroll anchor is chosen after each layout
244    /// pass (dynamic mode only).
245    pub anchor_policy: VirtualAnchorPolicy,
246    /// Stable identity for a row by global index. Keys measured heights
247    /// and [`ScrollRequest::ToRowKey`] targets across reorders /
248    /// insertions; defaults to the index itself in fixed mode.
249    pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
250    /// Realize the row at a global index. Called only for rows whose
251    /// rect intersects the viewport.
252    pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
253}
254
255impl VirtualItems {
256    /// Fixed-height list: every row is `row_height` logical pixels.
257    /// Row keys default to the stringified index. Panics if
258    /// `row_height <= 0.0`. Prefer the [`crate::virtual_list`] builder.
259    pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
260    where
261        F: Fn(usize) -> El + Send + Sync + 'static,
262    {
263        assert!(
264            row_height > 0.0,
265            "VirtualItems::new requires row_height > 0.0 (got {row_height})"
266        );
267        VirtualItems {
268            count,
269            mode: VirtualMode::Fixed { row_height },
270            anchor_policy: VirtualAnchorPolicy::default(),
271            row_key: Arc::new(|i| i.to_string()),
272            build_row: Arc::new(build_row),
273        }
274    }
275
276    /// Variable-height list: rows start at `estimated_row_height`
277    /// logical pixels and are replaced by real measurements as they
278    /// become visible. `row_key` must give each row a stable identity.
279    /// Panics if `estimated_row_height <= 0.0`. Prefer the
280    /// [`crate::virtual_list_dyn`] builder.
281    pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
282    where
283        K: Fn(usize) -> String + Send + Sync + 'static,
284        F: Fn(usize) -> El + Send + Sync + 'static,
285    {
286        assert!(
287            estimated_row_height > 0.0,
288            "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
289        );
290        VirtualItems {
291            count,
292            mode: VirtualMode::Dynamic {
293                estimated_row_height,
294                append_only: false,
295            },
296            anchor_policy: VirtualAnchorPolicy::default(),
297            row_key: Arc::new(row_key),
298            build_row: Arc::new(build_row),
299        }
300    }
301
302    /// Declare the append-only contract on a dynamic list — see
303    /// [`VirtualMode::Dynamic::append_only`]. No-op (and harmless) on a
304    /// fixed list, whose range math is already O(1).
305    pub fn append_only(mut self) -> Self {
306        if let VirtualMode::Dynamic {
307            ref mut append_only,
308            ..
309        } = self.mode
310        {
311            *append_only = true;
312        }
313        self
314    }
315
316    /// Replace the default anchor policy
317    /// ([`VirtualAnchorPolicy::ViewportFraction`] at 0.25).
318    pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
319        self.anchor_policy = policy;
320        self
321    }
322}
323
324impl std::fmt::Debug for VirtualItems {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("VirtualItems")
327            .field("count", &self.count)
328            .field("mode", &self.mode)
329            .field("anchor_policy", &self.anchor_policy)
330            .field("row_key", &"<fn>")
331            .field("build_row", &"<fn>")
332            .finish()
333    }
334}
335
336/// Context handed to a [`LayoutFn`]. Marked `#[non_exhaustive]` so
337/// future fields (intrinsic-at-width, scroll context, …) can be added
338/// without breaking author code that currently reads `container` /
339/// `children` / `measure`.
340#[non_exhaustive]
341pub struct LayoutCtx<'a> {
342    /// Inner rect of the parent (after padding) — the area available
343    /// for child placement. Children may be positioned anywhere; the
344    /// library does not clamp returned rects to this region.
345    pub container: Rect,
346    /// Direct children of the node, in source order. Read-only — return
347    /// positions through your `Vec<Rect>`.
348    pub children: &'a [El],
349    /// Intrinsic `(width, height)` for any child. Wrapped text returns
350    /// its unwrapped width here; if you need width-dependent wrapping
351    /// you'll need to size the child with `Fixed` / `Fill` instead.
352    pub measure: &'a dyn Fn(&El) -> (f32, f32),
353    /// Look up any keyed node's laid-out rect. Returns `None` when the
354    /// key is absent from the tree, when the node hasn't been laid out
355    /// yet (siblings later in source order), or when the key was used
356    /// on a node without a recorded rect. Used by widgets like
357    /// [`crate::widgets::popover::popover`] to position children
358    /// relative to elements outside their own subtree.
359    pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
360    /// Look up a **keyed** node's laid-out rect by its `computed_id`
361    /// (the root resolves too, via `"root"`). Same semantics as
362    /// [`Self::rect_of_key`] but skips the `key → computed_id`
363    /// translation — useful for runtime-synthesized layers (tooltips,
364    /// focus rings) that anchor to a node the library already knows by
365    /// id; those anchors are always keyed (hover / focus targets
366    /// require keys). Unkeyed nodes have no id-indexed entry — their
367    /// rects live only on the nodes themselves
368    /// ([`crate::tree::El::computed_rect`]).
369    pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
370}
371
372/// Lay out the whole tree into the given viewport rect. Assigns
373/// `computed_id`s, rebuilds the key index, and runs the layout walk.
374///
375/// Hosts that drive their own pipeline (the Damascene runtime does this in
376/// [`crate::runtime::RunnerCore::prepare_layout`]) typically call
377/// [`assign_ids`] before synthesizing floating layers (tooltips,
378/// toasts), then route the laid-out call through
379/// [`layout_post_assign`] so the id walk doesn't run twice per frame.
380pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
381    {
382        crate::profile_span!("layout::assign_ids");
383        assign_ids(root);
384    }
385    layout_post_assign(root, ui_state, viewport);
386}
387
388/// Like [`layout`], but skips the recursive `assign_id` walk. Callers
389/// are responsible for ensuring every node's `computed_id` is already
390/// set — typically by invoking [`assign_ids`] earlier in the pipeline,
391/// then having any per-frame floating-layer synthesis pass call
392/// [`assign_id_appended`] on its newly pushed layer.
393pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
394    SIZING_VISITS.with(|c| c.set(0));
395    PRUNE_STATS.with(|s| *s.borrow_mut() = LayoutPruneStats::default());
396    {
397        crate::profile_span!("layout::root_setup");
398        root.computed_rect = viewport;
399        // The root always gets a keyed-map entry (whether or not it
400        // carries a key): custom layouts anchor to it via
401        // `rect_of_id("root")` (toast layer, diagnostics overlay).
402        ui_state
403            .layout
404            .keyed_rects
405            .insert(root.computed_id.clone(), viewport);
406        rebuild_key_index(root, ui_state);
407        // Per-scrollable scratch is rebuilt every layout — entries for
408        // scrollables that disappeared mid-frame must not leave stale
409        // thumb rects behind for hit-test or paint to find.
410        ui_state.scroll.metrics.clear();
411        ui_state.scroll.thumb_rects.clear();
412        ui_state.scroll.thumb_tracks.clear();
413        ui_state.scroll.visible_ranges.clear();
414        ui_state.resize.bands.clear();
415        // One fused pre-walk (traversal count dominates on large
416        // trees): rewrite `.user_resizable()` panes' user-dragged
417        // sizes to ordinary `Fixed`, and resolve `Size::Ch(n)`
418        // (the CSS `ch` unit) to `Fixed(n · digit-advance)`
419        // against each node's own font — so every downstream
420        // sizing path sees plain `Fixed` values.
421        apply_size_rewrites(root, ui_state, None);
422    }
423    {
424        // The single sizing recursion: every node's measured size at
425        // the available width its tree position implies, stored
426        // in-node. Placement below never re-measures — the per-frame
427        // intrinsic cache this replaced existed only because the old
428        // place walk re-entered the measure recursion from every
429        // ancestor level at shifting widths.
430        crate::profile_span!("layout::size");
431        size_tree(root, Some(viewport.w));
432    }
433    {
434        crate::profile_span!("layout::children");
435        layout_children(root, viewport, ui_state);
436    }
437    // Band geometry needs final rects, so it runs as a post-pass.
438    publish_resize_bands(root, ui_state);
439    LAST_SIZING_STATS.with(|s| {
440        *s.borrow_mut() = LayoutIntrinsicCacheStats {
441            hits: 0,
442            misses: SIZING_VISITS.with(|c| c.get()),
443        };
444    });
445    LAST_PRUNE_STATS.with(|last| *last.borrow_mut() = PRUNE_STATS.with(|s| *s.borrow()));
446}
447
448/// Drag clamp for a `.user_resizable()` pane along its parent's
449/// `axis`, from the pane's CSS-shaped min/max fields. Missing bounds
450/// fall back to `[0, ∞)`; a min above max resolves to the lower bound,
451/// matching the documented `min_width`/`max_width` conflict rule.
452fn resize_clamp(child: &El, axis: Axis) -> (f32, f32) {
453    let (min, max) = match axis {
454        Axis::Column => (child.min_height, child.max_height),
455        _ => (child.min_width, child.max_width),
456    };
457    let min = min.unwrap_or(0.0).max(0.0);
458    (min, max.unwrap_or(f32::INFINITY).max(min))
459}
460
461/// Pre-pass for `.user_resizable()` panes (issue #106): where the user
462/// has dragged a size, rewrite the pane's declared main-axis size to
463/// `Fixed(override)` — clamped to the pane's min/max — before the
464/// layout walk. The declared size remains the default until the first
465/// drag writes an override.
466/// Fused pre-layout size rewrites — resize overrides + `ch` units in
467/// one traversal. `parent_axis` is `None` at the root (the root pane
468/// is never user-resized; only children along a parent's axis are).
469fn apply_size_rewrites(node: &mut El, ui_state: &UiState, parent_axis: Option<Axis>) {
470    if node.user_resizable
471        && let Some(axis) = parent_axis
472        && !matches!(axis, Axis::Overlay)
473    {
474        let id = node.key.as_deref().unwrap_or(&node.computed_id);
475        if let Some(&px) = ui_state.resize.overrides.get(id) {
476            let (min, max) = resize_clamp(node, axis);
477            let px = px.clamp(min, max);
478            match axis {
479                Axis::Column => node.height = Size::Fixed(px),
480                _ => node.width = Size::Fixed(px),
481            }
482        }
483    }
484    if let Size::Ch(n) = node.width {
485        node.width = Size::Fixed((n * ch_unit(node)).max(0.0));
486    }
487    if let Size::Ch(n) = node.height {
488        node.height = Size::Fixed((n * ch_unit(node)).max(0.0));
489    }
490    let axis = node.axis;
491    for child in &mut node.children {
492        apply_size_rewrites(child, ui_state, Some(axis));
493    }
494}
495
496/// The node's `0`-digit advance in its own resolved font — the CSS `ch`
497/// length. Uses the node's tabular-numerals setting so a reserved field
498/// (`.tabular_numerals().width(Size::Ch(n))`) is sized to the actual digit
499/// slot. Cheap: the text-metrics layout cache keys on these inputs.
500fn ch_unit(node: &El) -> f32 {
501    text_metrics::layout_text_with_family(
502        "0",
503        node.font_size,
504        node.font_family,
505        node.font_weight,
506        node.font_mono,
507        node.text_tabular_numerals,
508        TextWrap::NoWrap,
509        None,
510    )
511    .width
512    .max(0.0)
513}
514
515/// Post-pass for `.user_resizable()` panes: publish each pane's grab
516/// band — an invisible [`crate::state::resize::RESIZE_BAND_THICKNESS`]
517/// strip straddling the pane's seam edge — into
518/// `ui_state.resize.bands` for the runtime's pointer pre-emption and
519/// cursor resolution. The band sits on the trailing edge along the
520/// parent's axis when a sibling follows, and on the leading edge when
521/// the pane is the last child (a right- or bottom-anchored pane).
522fn publish_resize_bands(node: &El, ui_state: &mut UiState) {
523    use crate::state::resize::{RESIZE_BAND_THICKNESS as T, ResizeBand};
524    let axis = node.axis;
525    if !matches!(axis, Axis::Overlay) && node.children.iter().any(|c| c.user_resizable) {
526        let parent_rect = node.computed_rect;
527        let inner_main = match axis {
528            Axis::Column => parent_rect.h - node.padding.top - node.padding.bottom,
529            _ => parent_rect.w - node.padding.left - node.padding.right,
530        };
531        let count = node.children.len();
532        for (idx, child) in node.children.iter().enumerate() {
533            if !child.user_resizable {
534                continue;
535            }
536            let rect = child.computed_rect;
537            // Trailing edge when a sibling follows (or the pane is
538            // alone); leading edge for a last child with siblings
539            // before it.
540            let trailing = idx + 1 < count || count == 1;
541            let band = match (axis, trailing) {
542                (Axis::Column, true) => Rect::new(rect.x, rect.y + rect.h - T / 2.0, rect.w, T),
543                (Axis::Column, false) => Rect::new(rect.x, rect.y - T / 2.0, rect.w, T),
544                (_, true) => Rect::new(rect.x + rect.w - T / 2.0, rect.y, T, rect.h),
545                (_, false) => Rect::new(rect.x - T / 2.0, rect.y, T, rect.h),
546            };
547            let (min, max) = resize_clamp(child, axis);
548            // The seam must stay inside the parent: cap the drag at
549            // the parent's inner extent so the band can't be pushed
550            // out of reach.
551            let max = max.min(inner_main.max(min));
552            ui_state.resize.bands.push(ResizeBand {
553                id: child
554                    .key
555                    .clone()
556                    .unwrap_or_else(|| child.computed_id.to_string()),
557                key: child.key.clone(),
558                container_id: node.computed_id.to_string(),
559                band,
560                axis,
561                sign: if trailing { 1.0 } else { -1.0 },
562                current: match axis {
563                    Axis::Column => rect.h,
564                    _ => rect.w,
565                },
566                min,
567                max,
568            });
569        }
570    }
571    for child in &node.children {
572        publish_resize_bands(child, ui_state);
573    }
574}
575
576/// Assign `computed_id`s to a child that was just appended to an
577/// already-id-assigned `parent`. Companion to [`layout_post_assign`]:
578/// floating-layer synthesis (tooltip, toast) pushes one new child onto
579/// the root and uses this to give the new subtree the same path-style
580/// ids the recursive `assign_id` would have, without re-walking the
581/// rest of the tree.
582pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
583    let mut path = String::with_capacity(parent_id.len() + 24);
584    path.push_str(parent_id);
585    push_id_suffix(&mut path, child, child_index);
586    assign_id(child, &mut path);
587}
588
589/// Walk the tree once and refresh `ui_state.layout.key_index` so
590/// `LayoutCtx::rect_of_key` can resolve `key → computed_id` without
591/// re-scanning the tree per lookup. First key wins — duplicate keys
592/// are an author bug, but we don't want to crash layout over it.
593fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
594    ui_state.layout.key_index.clear();
595    // Count computed_ids in the same walk so the duplicate-id check
596    // (issue #64) costs no extra traversal. Two same-role siblings
597    // sharing a key collide on computed_id (`assign_id` produces
598    // `parent.role[key]` for both) — the second then overwrites the
599    // first's keyed-rect entry (both resolve `rect_of_key` to one
600    // rect) and the intrinsic cache returns one sibling's measurement
601    // for the other. The failure is silent and non-local.
602    let mut id_counts: rustc_hash::FxHashMap<&str, u32> = Default::default();
603    fn visit<'a>(
604        node: &'a El,
605        index: &mut rustc_hash::FxHashMap<String, std::sync::Arc<str>>,
606        id_counts: &mut rustc_hash::FxHashMap<&'a str, u32>,
607    ) {
608        if let Some(key) = &node.key {
609            index
610                .entry(key.clone())
611                .or_insert_with(|| node.computed_id.clone());
612        }
613        *id_counts.entry(node.computed_id.as_ref()).or_insert(0) += 1;
614        for c in &node.children {
615            visit(c, index, id_counts);
616        }
617    }
618    visit(root, &mut ui_state.layout.key_index, &mut id_counts);
619    warn_duplicate_ids(&id_counts, &mut ui_state.layout.warned_duplicate_ids);
620}
621
622/// Emit a once-per-id warning for every colliding `computed_id`. The
623/// bundle lint already catches this as [`crate::bundle::lint::
624/// FindingKind::DuplicateId`], but runtime-only apps never run it — so
625/// this reuses the same finding vocabulary at log-warn level. Deduped
626/// via the persistent `warned` set so a standing duplicate warns once,
627/// not every layout (issue #64).
628fn warn_duplicate_ids(
629    id_counts: &rustc_hash::FxHashMap<&str, u32>,
630    warned: &mut rustc_hash::FxHashSet<String>,
631) {
632    for (&id, &n) in id_counts {
633        if n > 1 && warned.insert(id.to_string()) {
634            log::warn!(
635                "DuplicateId: {n} nodes share id {id} — duplicate sibling key; \
636                 their rects and intrinsic-cache entries collide silently (issue #64)"
637            );
638        }
639    }
640}
641
642/// Assign every node's `computed_id` without positioning anything else.
643/// Useful when callers need to read or seed side-map entries (e.g.,
644/// scroll offsets) before `layout` runs.
645pub fn assign_ids(root: &mut El) {
646    let mut path = String::with_capacity(128);
647    path.push_str("root");
648    assign_id(root, &mut path);
649}
650
651/// Append one child's id segment (`.role[key]` / `.role.index`) to the
652/// shared path buffer.
653fn push_id_suffix(path: &mut String, child: &El, child_index: usize) {
654    use std::fmt::Write;
655    path.push('.');
656    path.push_str(role_token(&child.kind));
657    match &child.key {
658        Some(k) => {
659            path.push('[');
660            path.push_str(k);
661            path.push(']');
662        }
663        None => {
664            let _ = write!(path, ".{child_index}");
665        }
666    }
667}
668
669/// Walk with a single reused path buffer (push segment / recurse /
670/// truncate): one `Arc<str>` allocation per node — the id itself —
671/// instead of the former three intermediate `format!` strings.
672fn assign_id(node: &mut El, path: &mut String) {
673    node.computed_id = std::sync::Arc::from(path.as_str());
674    for (i, c) in node.children.iter_mut().enumerate() {
675        let len = path.len();
676        push_id_suffix(path, c, i);
677        assign_id(c, path);
678        path.truncate(len);
679    }
680}
681
682fn role_token(k: &Kind) -> &'static str {
683    match k {
684        Kind::Group => "group",
685        Kind::Card => "card",
686        Kind::Button => "button",
687        Kind::Badge => "badge",
688        Kind::Text => "text",
689        Kind::Heading => "heading",
690        Kind::Spacer => "spacer",
691        Kind::Divider => "divider",
692        Kind::Overlay => "overlay",
693        Kind::Scrim => "scrim",
694        Kind::Modal => "modal",
695        Kind::Scroll => "scroll",
696        Kind::VirtualList => "virtual_list",
697        Kind::Inlines => "inlines",
698        Kind::HardBreak => "hard_break",
699        Kind::Math => "math",
700        Kind::Image => "image",
701        Kind::Surface => "surface",
702        Kind::Vector => "vector",
703        Kind::Scene3D => "scene3d",
704        Kind::Plot => "plot",
705        Kind::Viewport => "viewport",
706        Kind::Custom(name) => name,
707    }
708}
709
710/// Record `rect` as `node`'s layout output: on the node itself
711/// ([`El::computed_rect`], the full-tree store every per-node consumer
712/// reads), and additionally in the keyed side map when the node
713/// carries an author key, so `rect_of_key` / custom-layout anchoring
714/// can resolve it without the `El` in hand.
715#[inline]
716fn set_rect(node: &mut El, rect: Rect, ui_state: &mut UiState) {
717    node.computed_rect = rect;
718    if node.key.is_some() {
719        ui_state
720            .layout
721            .keyed_rects
722            .insert(node.computed_id.clone(), rect);
723    }
724}
725
726/// Resolve a descendant's rect by its path-shaped `computed_id`,
727/// descending only into the child whose id prefixes the target — the
728/// id namespace mirrors the tree path, so this costs O(depth ×
729/// siblings), not a subtree walk. Used for scroll-anchor resolution,
730/// where the anchor id was recorded on a previous frame and may name
731/// any (unkeyed) descendant. Returns `None` when the id no longer
732/// names a node in this subtree.
733fn find_descendant_rect(node: &El, target_id: &str) -> Option<Rect> {
734    for c in &node.children {
735        if &*c.computed_id == target_id {
736            return Some(c.computed_rect);
737        }
738        if target_id
739            .strip_prefix(&*c.computed_id)
740            .is_some_and(|rest| rest.starts_with('.'))
741        {
742            return find_descendant_rect(c, target_id);
743        }
744    }
745    None
746}
747
748fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
749    if matches!(node.kind, Kind::Inlines) {
750        // The paragraph paints as a single AttributedText DrawOp;
751        // child Text/HardBreak nodes are aggregated by draw_ops::
752        // push_node and don't paint independently. Give each child a
753        // zero-size rect so the rest of the engine (hit-test, focus,
754        // animation, lint) treats them as non-paint pseudo-nodes. The
755        // paragraph's hit-test target is the Inlines node itself,
756        // sized by node_rect.
757        for c in &mut node.children {
758            set_rect(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
759            // Recurse so descendants of Text/HardBreak nodes (rare —
760            // these are leaves in practice — but keeping the invariant
761            // simple) still get their rects assigned.
762            layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
763        }
764        return;
765    }
766    if let Some(items) = node.virtual_items.as_deref().cloned() {
767        layout_virtual(node, node_rect, items, ui_state);
768        return;
769    }
770    if let Some(layout_fn) = node.layout_override.clone() {
771        layout_custom(node, node_rect, layout_fn, ui_state);
772        if node.scrollable {
773            apply_scroll_offset(node, node_rect, ui_state);
774        }
775        if node.viewport.is_some() {
776            apply_viewport_transform(node, node_rect, ui_state);
777        }
778        return;
779    }
780    match node.axis {
781        Axis::Overlay => {
782            let inner = node_rect.inset(node.padding);
783            // A `viewport()` lets its content size to full intrinsic — the
784            // pan/zoom transform reveals what extends past the frame.
785            // Modals and other overlays still clamp to the frame.
786            let clamp_to_parent = node.viewport.is_none();
787            for c in &mut node.children {
788                let c_rect = overlay_rect(c, inner, node.align, node.justify, clamp_to_parent);
789                resize_if_width_diverged(c, c_rect.w);
790                set_rect(c, c_rect, ui_state);
791                layout_children(c, c_rect, ui_state);
792            }
793        }
794        Axis::Column => layout_axis(node, node_rect, true, ui_state),
795        Axis::Row => layout_axis(node, node_rect, false, ui_state),
796    }
797    if node.scrollable {
798        apply_scroll_offset(node, node_rect, ui_state);
799    }
800    if node.viewport.is_some() {
801        apply_viewport_transform(node, node_rect, ui_state);
802    }
803}
804
805fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
806    let inner = node_rect.inset(node.padding);
807    // `size_tree` measured custom-layout children unconstrained, so
808    // `measure` reads the stored naturals — same values the old
809    // on-demand `intrinsic(c)` produced, without the subtree walk.
810    let measure = |c: &El| c.measured_size;
811    // Split-borrow `ui_state` so the `rect_of_key` closure reads the
812    // key index + keyed rects while the surrounding function still
813    // holds the mutable borrow needed to write this node's children's
814    // rects afterwards. Both closures resolve keyed nodes (plus the
815    // root) — anchors must carry a key, which every anchor producer
816    // (popover trigger, tooltip hover target) already guarantees.
817    let key_index = &ui_state.layout.key_index;
818    let keyed_rects = &ui_state.layout.keyed_rects;
819    let rect_of_key = |key: &str| -> Option<Rect> {
820        let id = key_index.get(key)?;
821        keyed_rects.get(id).copied()
822    };
823    let rect_of_id = |id: &str| -> Option<Rect> { keyed_rects.get(id).copied() };
824    let rects = (layout_fn.0)(LayoutCtx {
825        container: inner,
826        children: &node.children,
827        measure: &measure,
828        rect_of_key: &rect_of_key,
829        rect_of_id: &rect_of_id,
830    });
831    assert_eq!(
832        rects.len(),
833        node.children.len(),
834        "LayoutFn for {:?} returned {} rects for {} children",
835        node.computed_id,
836        rects.len(),
837        node.children.len(),
838    );
839    for (c, c_rect) in node.children.iter_mut().zip(rects) {
840        // A custom layout may assign a width far from the child's
841        // natural (sized unconstrained by `size_tree`); wrap-text
842        // descendants must re-measure at the width they will paint at.
843        resize_if_width_diverged(c, c_rect.w);
844        set_rect(c, c_rect, ui_state);
845        layout_children(c, c_rect, ui_state);
846    }
847}
848
849/// Virtualized list realization. Dispatches by [`VirtualMode`] —
850/// `Fixed` uses an O(1) division to find the visible range; `Dynamic`
851/// walks measured-or-estimated heights, measures each visible row's
852/// natural intrinsic height, and writes the result back to the height
853/// cache on `UiState` so subsequent frames have it available.
854fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
855    let inner = node_rect.inset(node.padding);
856    match items.mode {
857        VirtualMode::Fixed { row_height } => layout_virtual_fixed(
858            node,
859            inner,
860            items.count,
861            row_height,
862            items.build_row,
863            ui_state,
864        ),
865        VirtualMode::Dynamic {
866            estimated_row_height,
867            append_only,
868        } => {
869            let fns = DynamicVirtualFns {
870                anchor_policy: items.anchor_policy,
871                row_key: items.row_key,
872                build_row: items.build_row,
873            };
874            if append_only {
875                layout_virtual_dynamic_incremental(
876                    node,
877                    inner,
878                    items.count,
879                    estimated_row_height,
880                    fns,
881                    ui_state,
882                );
883            } else {
884                layout_virtual_dynamic(
885                    node,
886                    inner,
887                    items.count,
888                    estimated_row_height,
889                    fns,
890                    ui_state,
891                );
892            }
893        }
894    }
895}
896
897/// Consume any pending [`ScrollRequest`]s targeting this list's `key`,
898/// resolving each into a target offset using the live viewport rect and
899/// the caller-supplied row-extent function. Writes the resolved offset
900/// directly into `scroll.offsets`; the immediately-following
901/// `write_virtual_scroll_state` call clamps it to `[0, max_offset]`.
902///
903/// Requests for other lists are left in the queue for sibling lists in
904/// the same layout pass. Anything still queued after layout completes is
905/// dropped by the runtime (see `prepare_layout`).
906fn resolve_scroll_requests<F, K>(
907    node: &El,
908    inner: Rect,
909    count: usize,
910    row_extent: F,
911    row_for_key: K,
912    ui_state: &mut UiState,
913) -> bool
914where
915    F: Fn(usize) -> (f32, f32),
916    K: Fn(&str) -> Option<usize>,
917{
918    if ui_state.scroll.pending_requests.is_empty() {
919        return false;
920    }
921    let Some(key) = node.key.as_deref() else {
922        return false;
923    };
924    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
925    let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
926        pending.into_iter().partition(|req| match req {
927            ScrollRequest::ToRow { list_key, .. } => list_key == key,
928            ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
929            // EnsureVisible isn't a virtual-list-row request; let the
930            // non-virtual scroll resolver pick it up downstream.
931            ScrollRequest::EnsureVisible { .. } => false,
932        });
933    ui_state.scroll.pending_requests = remaining;
934
935    let mut wrote = false;
936    for req in matched {
937        let (row, align) = match req {
938            ScrollRequest::ToRow { row, align, .. } => (row, align),
939            ScrollRequest::ToRowKey { row_key, align, .. } => {
940                let Some(row) = row_for_key(&row_key) else {
941                    continue;
942                };
943                (row, align)
944            }
945            ScrollRequest::EnsureVisible { .. } => continue,
946        };
947        if row >= count {
948            continue;
949        }
950        let (row_top, row_h) = row_extent(row);
951        let row_bottom = row_top + row_h;
952        let viewport_h = inner.h;
953        let current = ui_state
954            .scroll
955            .offsets
956            .get(&*node.computed_id)
957            .copied()
958            .unwrap_or(0.0);
959        let new_offset = match align {
960            ScrollAlignment::Start => row_top,
961            ScrollAlignment::End => row_bottom - viewport_h,
962            ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
963            ScrollAlignment::Visible => {
964                if row_top < current {
965                    row_top
966                } else if row_bottom > current + viewport_h {
967                    row_bottom - viewport_h
968                } else {
969                    continue;
970                }
971            }
972        };
973        ui_state
974            .scroll
975            .offsets
976            .insert(node.computed_id.to_string(), new_offset);
977        wrote = true;
978    }
979    wrote
980}
981
982/// Clamp the stored scroll offset, write the metrics + thumb rect, and
983/// return the clamped offset. Shared scaffold for both virtual modes.
984fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
985    let max_offset = (total_h - inner.h).max(0.0);
986    let stored = ui_state
987        .scroll
988        .offsets
989        .get(&*node.computed_id)
990        .copied()
991        .unwrap_or(0.0);
992    let stored = resolve_pin(node, stored, max_offset, ui_state);
993    let offset = stored.clamp(0.0, max_offset);
994    ui_state
995        .scroll
996        .offsets
997        .insert(node.computed_id.to_string(), offset);
998    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
999    offset
1000}
1001
1002fn write_virtual_scroll_metrics(
1003    node: &El,
1004    inner: Rect,
1005    total_h: f32,
1006    max_offset: f32,
1007    offset: f32,
1008    ui_state: &mut UiState,
1009) {
1010    ui_state.scroll.metrics.insert(
1011        node.computed_id.to_string(),
1012        crate::state::ScrollMetrics {
1013            viewport_h: inner.h,
1014            content_h: total_h,
1015            max_offset,
1016        },
1017    );
1018    write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
1019}
1020
1021/// Assign the realized row a path-style `computed_id` matching the
1022/// regular tree's role/key/index convention so hit-test, focus, and
1023/// state lookups remain stable across scrolls.
1024fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
1025    let role = role_token(&child.kind);
1026    let mut path = String::with_capacity(parent_id.len() + 24);
1027    path.push_str(parent_id);
1028    path.push('.');
1029    path.push_str(role);
1030    match &child.key {
1031        Some(k) => {
1032            path.push('[');
1033            path.push_str(k);
1034            path.push(']');
1035        }
1036        None => {
1037            use std::fmt::Write;
1038            let _ = write!(path, ".{global_i}");
1039        }
1040    }
1041    assign_id(child, &mut path);
1042}
1043
1044fn layout_virtual_fixed(
1045    node: &mut El,
1046    inner: Rect,
1047    count: usize,
1048    row_height: f32,
1049    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1050    ui_state: &mut UiState,
1051) {
1052    let gap = node.gap.max(0.0);
1053    let pitch = row_height + gap;
1054    let total_h = virtual_total_height(count, count as f32 * row_height, gap);
1055    resolve_scroll_requests(
1056        node,
1057        inner,
1058        count,
1059        |i| (i as f32 * pitch, row_height),
1060        |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
1061        ui_state,
1062    );
1063    let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
1064
1065    if count == 0 {
1066        node.children.clear();
1067        return;
1068    }
1069
1070    // Visible index range — `start` floors, `end` ceils, both clamped.
1071    // Include one extra candidate because a large gap can make the
1072    // pitch-based ceil land on the gap before the next visible row.
1073    let start = (offset / pitch).floor() as usize;
1074    let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
1075
1076    let mut realized: Vec<El> = Vec::new();
1077    let mut realized_range: Option<(usize, usize)> = None;
1078    for global_i in start..end {
1079        let row_top = global_i as f32 * pitch;
1080        if row_top >= offset + inner.h || row_top + row_height <= offset {
1081            continue;
1082        }
1083        let mut child = (build_row)(global_i);
1084        assign_virtual_row_id(&mut child, &node.computed_id, global_i);
1085        // Rows are realized after the tree-wide sizing pass ran, so
1086        // size each fresh subtree here at the list's inner width.
1087        size_tree(&mut child, Some(inner.w));
1088
1089        let row_y = inner.y + row_top - offset;
1090        let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
1091        set_rect(&mut child, c_rect, ui_state);
1092        layout_children(&mut child, c_rect, ui_state);
1093        realized.push(child);
1094        realized_range = Some(match realized_range {
1095            None => (global_i, global_i + 1),
1096            Some((s, _)) => (s, global_i + 1),
1097        });
1098    }
1099    if let Some((s, e)) = realized_range {
1100        ui_state
1101            .scroll
1102            .visible_ranges
1103            .insert(node.computed_id.to_string(), (s, e));
1104    }
1105    node.children = realized;
1106}
1107
1108/// Append-only fast path for `VirtualMode::Dynamic { append_only: true }`.
1109/// Behaviourally identical to [`layout_virtual_dynamic`] (anchoring,
1110/// pinning, scroll-request resolution, measurement, the offset-correction
1111/// pass) — it reuses the same semantic helpers — but it never
1112/// materializes the full `Vec` of keys or heights. A persistent
1113/// [`DynHeightIndex`] holds the per-row heights across frames; this pass
1114/// reconciles it trim-then-append and answers every height / row-top /
1115/// visible-range query off the index, so per-frame cost is O(visible)
1116/// (plus O(√n) range queries) instead of the general path's several O(n)
1117/// walks (issue #107). See the dispatch in [`layout_virtual`] and the
1118/// contract on [`VirtualMode::Dynamic::append_only`].
1119fn layout_virtual_dynamic_incremental(
1120    node: &mut El,
1121    inner: Rect,
1122    count: usize,
1123    estimated_row_height: f32,
1124    fns: DynamicVirtualFns,
1125    ui_state: &mut UiState,
1126) {
1127    let gap = node.gap.max(0.0);
1128    let width_bucket = virtual_width_bucket(inner.w);
1129
1130    if count == 0 {
1131        ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1132        ui_state.scroll.dyn_height_index.remove(&*node.computed_id);
1133        let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1134        debug_assert_eq!(offset, 0.0);
1135        node.children.clear();
1136        return;
1137    }
1138
1139    // Pull the persistent height index out of `ui_state` so it is an
1140    // owned local for the rest of the pass (sidestepping the borrow
1141    // against `&mut ui_state` the helpers need). Reconcile it against
1142    // this frame as trim-then-append, or cold-rebuild when that's not
1143    // possible (first frame, width change, or a contract violation —
1144    // self-healing to correct geometry at an O(n) cost for that frame).
1145    let existing = ui_state.scroll.dyn_height_index.remove(&*node.computed_id);
1146    let head_key = (fns.row_key)(0);
1147    let mut trimmed_keys: Vec<String> = Vec::new();
1148    let reconciled = existing.and_then(|mut ix| {
1149        let ok = ix.reconcile(
1150            width_bucket,
1151            estimated_row_height,
1152            count,
1153            &head_key,
1154            |i| (fns.row_key)(i),
1155            |_i, key| {
1156                cached_row_height(
1157                    ui_state,
1158                    &node.computed_id,
1159                    key,
1160                    width_bucket,
1161                    estimated_row_height,
1162                )
1163            },
1164            &mut trimmed_keys,
1165        );
1166        ok.then_some(ix)
1167    });
1168    let mut index = match reconciled {
1169        Some(ix) => ix,
1170        None => DynHeightIndex::build(width_bucket, estimated_row_height, count, |i| {
1171            let key = (fns.row_key)(i);
1172            let h = cached_row_height(
1173                ui_state,
1174                &node.computed_id,
1175                &key,
1176                width_bucket,
1177                estimated_row_height,
1178            );
1179            (key, h)
1180        }),
1181    };
1182    // Evict measurement-cache entries for rows trimmed off the head, so
1183    // the cache stays bounded over a long ring-buffer feed. This is the
1184    // incremental analogue of the general path's per-frame O(n)
1185    // `prune_dynamic_measurements`; here it's O(trimmed).
1186    if !trimmed_keys.is_empty() {
1187        if let Some(measured) = ui_state
1188            .scroll
1189            .measured_row_heights
1190            .get_mut(&*node.computed_id)
1191        {
1192            for key in &trimmed_keys {
1193                measured.remove(key);
1194            }
1195            if measured.is_empty() {
1196                ui_state
1197                    .scroll
1198                    .measured_row_heights
1199                    .remove(&*node.computed_id);
1200            }
1201        }
1202    }
1203
1204    // Skip the cache snapshot entirely when nothing in the queue targets
1205    // this list (see the general path for the rationale).
1206    let has_request = node.key.as_deref().is_some_and(|k| {
1207        ui_state.scroll.pending_requests.iter().any(|r| match r {
1208            ScrollRequest::ToRow { list_key, .. } => list_key == k,
1209            ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1210            ScrollRequest::EnsureVisible { .. } => false,
1211        })
1212    });
1213    let mut request_wrote = false;
1214    if has_request {
1215        request_wrote = resolve_scroll_requests(
1216            node,
1217            inner,
1218            count,
1219            |target| (index.row_top(target, gap), index.height(target)),
1220            |row_key| index.index_for_key(row_key),
1221            ui_state,
1222        );
1223    }
1224
1225    let total_h = virtual_total_height(count, index.heights_sum(), gap);
1226    let max_offset = (total_h - inner.h).max(0.0);
1227    let stored = ui_state
1228        .scroll
1229        .offsets
1230        .get(&*node.computed_id)
1231        .copied()
1232        .unwrap_or(0.0);
1233    let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1234    let provisional_offset = if pin_active {
1235        match node.pin_policy {
1236            crate::tree::PinPolicy::End => max_offset,
1237            crate::tree::PinPolicy::Start => 0.0,
1238            crate::tree::PinPolicy::None => unreachable!(),
1239        }
1240    } else if request_wrote {
1241        stored
1242    } else {
1243        dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1244    }
1245    .clamp(0.0, max_offset);
1246
1247    let (measure_start, _, measure_end) = index.visible_range(gap, provisional_offset, inner.h);
1248    measure_dynamic_range(
1249        node,
1250        DynamicRangeCtx {
1251            inner,
1252            keys: KeySource::Func(&*fns.row_key),
1253            width_bucket,
1254            build_row: &fns.build_row,
1255        },
1256        measure_start,
1257        measure_end,
1258        ui_state,
1259    );
1260    refresh_index_range(
1261        &mut index,
1262        &node.computed_id,
1263        width_bucket,
1264        measure_start,
1265        measure_end,
1266        &*fns.row_key,
1267        estimated_row_height,
1268        ui_state,
1269    );
1270
1271    let total_h = virtual_total_height(count, index.heights_sum(), gap);
1272    let max_offset = (total_h - inner.h).max(0.0);
1273    let stored = ui_state
1274        .scroll
1275        .offsets
1276        .get(&*node.computed_id)
1277        .copied()
1278        .unwrap_or(0.0);
1279    let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1280    let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1281        && ui_state
1282            .scroll
1283            .pin_active
1284            .get(&*node.computed_id)
1285            .copied()
1286            .unwrap_or(false);
1287    let mut offset = if pin_active {
1288        pin_resolved
1289    } else if request_wrote {
1290        stored
1291    } else {
1292        dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1293    }
1294    .clamp(0.0, max_offset);
1295
1296    ui_state
1297        .scroll
1298        .offsets
1299        .insert(node.computed_id.to_string(), offset);
1300
1301    let (start, start_y, end) = index.visible_range(gap, offset, inner.h);
1302    let mut realized_rows = layout_dynamic_range(
1303        node,
1304        DynamicRangeCtx {
1305            inner,
1306            keys: KeySource::Func(&*fns.row_key),
1307            width_bucket,
1308            build_row: &fns.build_row,
1309        },
1310        offset,
1311        start,
1312        start_y,
1313        end,
1314        ui_state,
1315    );
1316    refresh_index_range(
1317        &mut index,
1318        &node.computed_id,
1319        width_bucket,
1320        start,
1321        end,
1322        &*fns.row_key,
1323        estimated_row_height,
1324        ui_state,
1325    );
1326
1327    let total_h = virtual_total_height(count, index.heights_sum(), gap);
1328    let max_offset = (total_h - inner.h).max(0.0);
1329    let corrected_offset = if pin_active {
1330        match node.pin_policy {
1331            crate::tree::PinPolicy::End => max_offset,
1332            crate::tree::PinPolicy::Start => 0.0,
1333            crate::tree::PinPolicy::None => unreachable!(),
1334        }
1335    } else if request_wrote {
1336        offset
1337    } else {
1338        dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(offset)
1339    }
1340    .clamp(0.0, max_offset);
1341    if (corrected_offset - offset).abs() > 0.01 {
1342        let dy = offset - corrected_offset;
1343        for child in &mut node.children {
1344            shift_subtree_y(child, dy, ui_state);
1345        }
1346        for row in &mut realized_rows {
1347            row.rect.y += dy;
1348        }
1349        offset = corrected_offset;
1350        ui_state
1351            .scroll
1352            .offsets
1353            .insert(node.computed_id.to_string(), offset);
1354    }
1355    if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1356        ui_state
1357            .scroll
1358            .pin_prev_max
1359            .insert(node.computed_id.to_string(), max_offset);
1360    }
1361    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1362
1363    if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1364        ui_state
1365            .scroll
1366            .virtual_anchors
1367            .insert(node.computed_id.to_string(), anchor);
1368    } else {
1369        ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1370    }
1371
1372    ui_state
1373        .scroll
1374        .dyn_height_index
1375        .insert(node.computed_id.to_string(), index);
1376}
1377
1378/// Measured-or-estimated height for one row, read from the persistent
1379/// measurement cache exactly as the general path's `dynamic_row_heights`
1380/// does (keyed by list id → row key → width bucket).
1381fn cached_row_height(
1382    ui_state: &UiState,
1383    id: &str,
1384    key: &str,
1385    width_bucket: u32,
1386    estimated_row_height: f32,
1387) -> f32 {
1388    ui_state
1389        .scroll
1390        .measured_row_heights
1391        .get(id)
1392        .and_then(|m| m.get(key))
1393        .and_then(|by_width| by_width.get(&width_bucket))
1394        .copied()
1395        .unwrap_or(estimated_row_height)
1396}
1397
1398/// Fold the freshly measured heights for the realized window `[start,
1399/// end)` back into the height index, so the next query sees real
1400/// measurements instead of the estimate. O(visible).
1401#[allow(clippy::too_many_arguments)]
1402fn refresh_index_range(
1403    index: &mut DynHeightIndex,
1404    id: &str,
1405    width_bucket: u32,
1406    start: usize,
1407    end: usize,
1408    row_key: &(dyn Fn(usize) -> String + Send + Sync),
1409    estimated_row_height: f32,
1410    ui_state: &UiState,
1411) {
1412    for idx in start..end {
1413        let key = row_key(idx);
1414        let h = cached_row_height(ui_state, id, &key, width_bucket, estimated_row_height);
1415        index.set_height(idx, h);
1416    }
1417}
1418
1419/// Index-backed equivalent of [`dynamic_anchor_offset`]: resolve the
1420/// stored anchor's row through the height index (O(1) key→index, O(√n)
1421/// row-top) instead of a linear scan over a materialized key slice.
1422fn dynamic_anchor_offset_indexed(
1423    node: &El,
1424    index: &DynHeightIndex,
1425    gap: f32,
1426    stored: f32,
1427    ui_state: &UiState,
1428) -> Option<f32> {
1429    let anchor = ui_state.scroll.virtual_anchors.get(&*node.computed_id)?;
1430    let idx = index.index_for_key(&anchor.row_key)?;
1431    let row_h = index.height(idx).max(0.0);
1432    let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1433    let scroll_delta = stored - anchor.resolved_offset;
1434    let viewport_y = anchor.viewport_y - scroll_delta;
1435    Some(index.row_top(idx, gap) + row_point - viewport_y)
1436}
1437
1438fn layout_virtual_dynamic(
1439    node: &mut El,
1440    inner: Rect,
1441    count: usize,
1442    estimated_row_height: f32,
1443    fns: DynamicVirtualFns,
1444    ui_state: &mut UiState,
1445) {
1446    let gap = node.gap.max(0.0);
1447    let width_bucket = virtual_width_bucket(inner.w);
1448    let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
1449    prune_dynamic_measurements(node, &row_keys, ui_state);
1450
1451    if count == 0 {
1452        ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1453        let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1454        debug_assert_eq!(offset, 0.0);
1455        node.children.clear();
1456        return;
1457    }
1458
1459    let mut row_heights = dynamic_row_heights(
1460        node,
1461        &row_keys,
1462        width_bucket,
1463        estimated_row_height,
1464        ui_state,
1465    );
1466
1467    // Skip the cache snapshot entirely when nothing in the queue
1468    // targets this list — a hot path on dynamic lists with warm
1469    // caches (potentially thousands of entries) that would otherwise
1470    // pay a per-frame HashMap clone for an operation that fires
1471    // maybe once a minute.
1472    let has_request = node.key.as_deref().is_some_and(|k| {
1473        ui_state.scroll.pending_requests.iter().any(|r| match r {
1474            ScrollRequest::ToRow { list_key, .. } => list_key == k,
1475            ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1476            ScrollRequest::EnsureVisible { .. } => false,
1477        })
1478    });
1479    let mut request_wrote = false;
1480    if has_request {
1481        request_wrote = resolve_scroll_requests(
1482            node,
1483            inner,
1484            count,
1485            |target| {
1486                (
1487                    dynamic_row_top(&row_heights, gap, target),
1488                    row_heights[target],
1489                )
1490            },
1491            |row_key| row_keys.iter().position(|key| key == row_key),
1492            ui_state,
1493        );
1494    }
1495
1496    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1497    let max_offset = (total_h - inner.h).max(0.0);
1498    let stored = ui_state
1499        .scroll
1500        .offsets
1501        .get(&*node.computed_id)
1502        .copied()
1503        .unwrap_or(0.0);
1504    let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1505    let provisional_offset = if pin_active {
1506        match node.pin_policy {
1507            crate::tree::PinPolicy::End => max_offset,
1508            crate::tree::PinPolicy::Start => 0.0,
1509            crate::tree::PinPolicy::None => unreachable!(),
1510        }
1511    } else if request_wrote {
1512        stored
1513    } else {
1514        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1515            .unwrap_or(stored)
1516    }
1517    .clamp(0.0, max_offset);
1518
1519    let (measure_start, _, measure_end) =
1520        dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
1521    measure_dynamic_range(
1522        node,
1523        DynamicRangeCtx {
1524            inner,
1525            keys: KeySource::Slice(&row_keys),
1526            width_bucket,
1527            build_row: &fns.build_row,
1528        },
1529        measure_start,
1530        measure_end,
1531        ui_state,
1532    );
1533
1534    row_heights = dynamic_row_heights(
1535        node,
1536        &row_keys,
1537        width_bucket,
1538        estimated_row_height,
1539        ui_state,
1540    );
1541    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1542    let max_offset = (total_h - inner.h).max(0.0);
1543    let stored = ui_state
1544        .scroll
1545        .offsets
1546        .get(&*node.computed_id)
1547        .copied()
1548        .unwrap_or(0.0);
1549    let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1550    let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1551        && ui_state
1552            .scroll
1553            .pin_active
1554            .get(&*node.computed_id)
1555            .copied()
1556            .unwrap_or(false);
1557    let mut offset = if pin_active {
1558        pin_resolved
1559    } else if request_wrote {
1560        stored
1561    } else {
1562        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1563            .unwrap_or(stored)
1564    }
1565    .clamp(0.0, max_offset);
1566
1567    ui_state
1568        .scroll
1569        .offsets
1570        .insert(node.computed_id.to_string(), offset);
1571
1572    let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
1573    let mut realized_rows = layout_dynamic_range(
1574        node,
1575        DynamicRangeCtx {
1576            inner,
1577            keys: KeySource::Slice(&row_keys),
1578            width_bucket,
1579            build_row: &fns.build_row,
1580        },
1581        offset,
1582        start,
1583        start_y,
1584        end,
1585        ui_state,
1586    );
1587
1588    row_heights = dynamic_row_heights(
1589        node,
1590        &row_keys,
1591        width_bucket,
1592        estimated_row_height,
1593        ui_state,
1594    );
1595    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1596    let max_offset = (total_h - inner.h).max(0.0);
1597    let corrected_offset = if pin_active {
1598        match node.pin_policy {
1599            crate::tree::PinPolicy::End => max_offset,
1600            crate::tree::PinPolicy::Start => 0.0,
1601            crate::tree::PinPolicy::None => unreachable!(),
1602        }
1603    } else if request_wrote {
1604        offset
1605    } else {
1606        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1607            .unwrap_or(offset)
1608    }
1609    .clamp(0.0, max_offset);
1610    if (corrected_offset - offset).abs() > 0.01 {
1611        let dy = offset - corrected_offset;
1612        for child in &mut node.children {
1613            shift_subtree_y(child, dy, ui_state);
1614        }
1615        for row in &mut realized_rows {
1616            row.rect.y += dy;
1617        }
1618        offset = corrected_offset;
1619        ui_state
1620            .scroll
1621            .offsets
1622            .insert(node.computed_id.to_string(), offset);
1623    }
1624    if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1625        ui_state
1626            .scroll
1627            .pin_prev_max
1628            .insert(node.computed_id.to_string(), max_offset);
1629    }
1630    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1631
1632    if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1633        ui_state
1634            .scroll
1635            .virtual_anchors
1636            .insert(node.computed_id.to_string(), anchor);
1637    } else {
1638        ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1639    }
1640}
1641
1642struct DynamicVirtualFns {
1643    anchor_policy: VirtualAnchorPolicy,
1644    row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
1645    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1646}
1647
1648#[derive(Clone, Copy)]
1649struct DynamicRangeCtx<'a> {
1650    inner: Rect,
1651    keys: KeySource<'a>,
1652    width_bucket: u32,
1653    build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
1654}
1655
1656/// Source of stable row keys over a realized range. The general dynamic
1657/// path already has every key materialized as a slice; the append-only
1658/// incremental path resolves keys on demand for the visible window only
1659/// (it never materializes all `n`). Both feed the same range
1660/// realization code (`measure_dynamic_range` / `layout_dynamic_range`).
1661#[derive(Clone, Copy)]
1662enum KeySource<'a> {
1663    Slice(&'a [String]),
1664    Func(&'a (dyn Fn(usize) -> String + Send + Sync)),
1665}
1666
1667impl KeySource<'_> {
1668    fn key(&self, idx: usize) -> String {
1669        match self {
1670            KeySource::Slice(keys) => keys[idx].clone(),
1671            KeySource::Func(f) => f(idx),
1672        }
1673    }
1674}
1675
1676fn virtual_width_bucket(width: f32) -> u32 {
1677    width.max(0.0).round().min(u32::MAX as f32) as u32
1678}
1679
1680fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
1681    let Some(measurements) = ui_state
1682        .scroll
1683        .measured_row_heights
1684        .get_mut(&*node.computed_id)
1685    else {
1686        return;
1687    };
1688    let live_keys = row_keys
1689        .iter()
1690        .map(String::as_str)
1691        .collect::<FxHashSet<_>>();
1692    measurements.retain(|key, widths| {
1693        let live = live_keys.contains(key.as_str());
1694        if live {
1695            widths.retain(|_, h| h.is_finite() && *h >= 0.0);
1696        }
1697        live && !widths.is_empty()
1698    });
1699    if measurements.is_empty() {
1700        ui_state
1701            .scroll
1702            .measured_row_heights
1703            .remove(&*node.computed_id);
1704    }
1705}
1706
1707fn dynamic_row_heights(
1708    node: &El,
1709    row_keys: &[String],
1710    width_bucket: u32,
1711    estimated_row_height: f32,
1712    ui_state: &UiState,
1713) -> Vec<f32> {
1714    let measurements = ui_state.scroll.measured_row_heights.get(&*node.computed_id);
1715    row_keys
1716        .iter()
1717        .map(|key| {
1718            measurements
1719                .and_then(|m| m.get(key))
1720                .and_then(|by_width| by_width.get(&width_bucket))
1721                .copied()
1722                .unwrap_or(estimated_row_height)
1723        })
1724        .collect()
1725}
1726
1727fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1728    row_heights
1729        .iter()
1730        .take(target)
1731        .fold(0.0, |y, h| y + *h + gap)
1732}
1733
1734fn dynamic_visible_range(
1735    row_heights: &[f32],
1736    gap: f32,
1737    offset: f32,
1738    viewport_h: f32,
1739) -> (usize, f32, usize) {
1740    let count = row_heights.len();
1741    let mut start = 0;
1742    let mut y = 0.0_f32;
1743    while start < count {
1744        let h = row_heights[start];
1745        if y + h > offset {
1746            break;
1747        }
1748        y += h + gap;
1749        start += 1;
1750    }
1751
1752    let mut end = start;
1753    let mut cursor = y;
1754    let viewport_bottom = offset + viewport_h;
1755    while end < count && cursor < viewport_bottom {
1756        let h = row_heights[end];
1757        end += 1;
1758        cursor += h + gap;
1759    }
1760    (start, y, end)
1761}
1762
1763fn dynamic_anchor_offset(
1764    node: &El,
1765    row_keys: &[String],
1766    row_heights: &[f32],
1767    gap: f32,
1768    stored: f32,
1769    ui_state: &UiState,
1770) -> Option<f32> {
1771    let anchor = ui_state.scroll.virtual_anchors.get(&*node.computed_id)?;
1772    let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1773        anchor.row_index
1774    } else {
1775        row_keys.iter().position(|key| key == &anchor.row_key)?
1776    };
1777    let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1778    let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1779    let scroll_delta = stored - anchor.resolved_offset;
1780    let viewport_y = anchor.viewport_y - scroll_delta;
1781    Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1782}
1783
1784fn measure_dynamic_range(
1785    node: &El,
1786    ctx: DynamicRangeCtx<'_>,
1787    start: usize,
1788    end: usize,
1789    ui_state: &mut UiState,
1790) {
1791    if start >= end {
1792        return;
1793    }
1794    let mut new_measurements = Vec::new();
1795    for idx in start..end {
1796        let key = ctx.keys.key(idx);
1797        let mut child = (ctx.build_row)(idx);
1798        assign_virtual_row_id(&mut child, &node.computed_id, idx);
1799        // This realization exists only to measure: the height lands in
1800        // the persistent per-row height store (`measured_row_heights`),
1801        // which is what keeps re-measures rare across frames now that
1802        // the per-pass intrinsic cache is gone (issue #59's dedup
1803        // concern is handled by that store, not by an in-frame cache).
1804        size_tree(&mut child, Some(ctx.inner.w));
1805        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1806        new_measurements.push((key, actual_h));
1807    }
1808    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1809}
1810
1811fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1812    match child.height {
1813        Size::Fixed(v) => v.max(0.0),
1814        Size::Ch(n) => (n * ch_unit(child)).max(0.0),
1815        // The caller ran `size_tree(child, Some(width))` just before —
1816        // the stored measure IS the height-at-list-width.
1817        Size::Hug => child.measured_size.1.max(0.0),
1818        Size::Aspect(r) => (width * r).max(0.0),
1819        Size::Fill(_) => panic!(
1820            "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1821             or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1822             virtualization",
1823            node.computed_id,
1824        ),
1825    }
1826}
1827
1828/// Width buckets retained per measured row. Buckets are 1-logical-px
1829/// granular ([`virtual_width_bucket`]), so a user dragging a window
1830/// edge sweeps through hundreds of distinct buckets; without a cap
1831/// every one is retained for the list's lifetime (issue #57). Only the
1832/// buckets nearest the current width matter — heights at distant
1833/// widths are stale guesses anyway once content rewraps.
1834const MAX_WIDTH_BUCKETS_PER_ROW: usize = 8;
1835
1836fn store_dynamic_measurements(
1837    node: &El,
1838    width_bucket: u32,
1839    measurements: Vec<(String, f32)>,
1840    ui_state: &mut UiState,
1841) {
1842    if measurements.is_empty() {
1843        return;
1844    }
1845    let entry = ui_state
1846        .scroll
1847        .measured_row_heights
1848        .entry(node.computed_id.to_string())
1849        .or_default();
1850    for (row_key, h) in measurements {
1851        let buckets = entry.entry(row_key).or_default();
1852        buckets.insert(width_bucket, h);
1853        if buckets.len() > MAX_WIDTH_BUCKETS_PER_ROW {
1854            // Keep the buckets closest to the width we're laying out
1855            // at; drop the farthest.
1856            let mut widths: Vec<u32> = buckets.keys().copied().collect();
1857            widths.sort_unstable_by_key(|w| (i64::from(*w) - i64::from(width_bucket)).abs());
1858            for w in widths.drain(MAX_WIDTH_BUCKETS_PER_ROW..) {
1859                buckets.remove(&w);
1860            }
1861        }
1862    }
1863}
1864
1865#[derive(Clone, Debug)]
1866struct DynamicRealizedRow {
1867    index: usize,
1868    key: String,
1869    rect: Rect,
1870}
1871
1872fn layout_dynamic_range(
1873    node: &mut El,
1874    ctx: DynamicRangeCtx<'_>,
1875    offset: f32,
1876    start: usize,
1877    start_y: f32,
1878    end: usize,
1879    ui_state: &mut UiState,
1880) -> Vec<DynamicRealizedRow> {
1881    let gap = node.gap.max(0.0);
1882    let mut cursor_y = start_y;
1883    let mut realized = Vec::new();
1884    let mut realized_rows = Vec::new();
1885    let mut new_measurements = Vec::new();
1886
1887    for idx in start..end {
1888        let key = ctx.keys.key(idx);
1889        let mut child = (ctx.build_row)(idx);
1890        assign_virtual_row_id(&mut child, &node.computed_id, idx);
1891        // Realized after the tree-wide sizing pass — size the fresh
1892        // subtree at the list's inner width so `measure_dynamic_row`
1893        // and the placement below read stored measures.
1894        size_tree(&mut child, Some(ctx.inner.w));
1895        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1896        new_measurements.push((key.clone(), actual_h));
1897
1898        let row_y = ctx.inner.y + cursor_y - offset;
1899        let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1900        set_rect(&mut child, c_rect, ui_state);
1901        layout_children(&mut child, c_rect, ui_state);
1902
1903        realized_rows.push(DynamicRealizedRow {
1904            index: idx,
1905            key,
1906            rect: c_rect,
1907        });
1908        realized.push(child);
1909        cursor_y += actual_h + gap;
1910    }
1911
1912    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1913    if let (Some(first), Some(last)) = (realized_rows.first(), realized_rows.last()) {
1914        ui_state
1915            .scroll
1916            .visible_ranges
1917            .insert(node.computed_id.to_string(), (first.index, last.index + 1));
1918    }
1919    node.children = realized;
1920    realized_rows
1921}
1922
1923fn choose_dynamic_anchor(
1924    policy: VirtualAnchorPolicy,
1925    inner: Rect,
1926    offset: f32,
1927    rows: &[DynamicRealizedRow],
1928) -> Option<VirtualAnchor> {
1929    let visible = rows
1930        .iter()
1931        .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1932        .collect::<Vec<_>>();
1933    if visible.is_empty() {
1934        return None;
1935    }
1936
1937    let chosen = match policy {
1938        VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1939            let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1940            visible
1941                .iter()
1942                .min_by(|a, b| {
1943                    let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1944                    let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1945                    ad.total_cmp(&bd)
1946                })
1947                .copied()
1948                .map(|row| {
1949                    let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1950                    (row.clone(), anchor_y)
1951                })
1952        }
1953        VirtualAnchorPolicy::FirstVisible => {
1954            let row = visible
1955                .iter()
1956                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1957                .or_else(|| visible.first())
1958                .copied()?;
1959            let anchor_y = row.rect.y.max(inner.y);
1960            Some((row.clone(), anchor_y))
1961        }
1962        VirtualAnchorPolicy::LastVisible => {
1963            let row = visible
1964                .iter()
1965                .rev()
1966                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1967                .or_else(|| visible.last())
1968                .copied()?;
1969            let anchor_y = row.rect.bottom().min(inner.bottom());
1970            Some((row.clone(), anchor_y))
1971        }
1972    }?;
1973
1974    let (row, anchor_y) = chosen;
1975    let row_h = row.rect.h.max(0.0);
1976    let row_fraction = if row_h > 0.0 {
1977        ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1978    } else {
1979        0.0
1980    };
1981    Some(VirtualAnchor {
1982        row_key: row.key.clone(),
1983        row_index: row.index,
1984        row_fraction,
1985        viewport_y: anchor_y - inner.y,
1986        resolved_offset: offset,
1987    })
1988}
1989
1990fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1991    if y < top {
1992        top - y
1993    } else if y > bottom {
1994        y - bottom
1995    } else {
1996        0.0
1997    }
1998}
1999
2000fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
2001    if count == 0 {
2002        0.0
2003    } else {
2004        row_sum + gap * count.saturating_sub(1) as f32
2005    }
2006}
2007
2008/// Scrollable post-pass: measure content height from the laid-out
2009/// children's stored rects, clamp the scroll offset to the available
2010/// range, and shift every descendant rect by `-offset`.
2011///
2012/// Children should size with `Hug` or `Fixed` on the main axis —
2013/// `Fill` children would absorb the viewport's height and there would
2014/// be nothing to scroll.
2015fn apply_scroll_offset(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
2016    let inner = node_rect.inset(node.padding);
2017    if node.children.is_empty() {
2018        ui_state
2019            .scroll
2020            .offsets
2021            .insert(node.computed_id.to_string(), 0.0);
2022        ui_state.scroll.scroll_anchors.remove(&*node.computed_id);
2023        ui_state.scroll.metrics.insert(
2024            node.computed_id.to_string(),
2025            crate::state::ScrollMetrics {
2026                viewport_h: inner.h,
2027                content_h: 0.0,
2028                max_offset: 0.0,
2029            },
2030        );
2031        return;
2032    }
2033    let content_bottom = node
2034        .children
2035        .iter()
2036        .map(|c| c.computed_rect.bottom())
2037        .fold(f32::NEG_INFINITY, f32::max);
2038    let content_h = (content_bottom - inner.y).max(0.0);
2039    let max_offset = (content_h - inner.h).max(0.0);
2040
2041    // Resolve any matching `ScrollRequest::EnsureVisible` against
2042    // this scroll BEFORE reading the stored offset, so the request's
2043    // chosen offset wins (and gets clamped below, just like
2044    // wheel-driven offsets do). A request matches when the node
2045    // keyed `container_key` is an ancestor of this scroll —
2046    // `key_index` resolves the key to a computed_id and a
2047    // prefix-match on `node.computed_id` tells us we're inside.
2048    let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
2049
2050    let stored = ui_state
2051        .scroll
2052        .offsets
2053        .get(&*node.computed_id)
2054        .copied()
2055        .unwrap_or(0.0);
2056    let stored = resolve_pin(node, stored, max_offset, ui_state);
2057    let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
2058        && ui_state
2059            .scroll
2060            .pin_active
2061            .get(&*node.computed_id)
2062            .copied()
2063            .unwrap_or(false);
2064    let stored = if pin_active || request_wrote {
2065        stored
2066    } else {
2067        scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
2068    };
2069    let clamped = stored.clamp(0.0, max_offset);
2070    if clamped > 0.0 {
2071        for c in &mut node.children {
2072            shift_subtree_y(c, -clamped, ui_state);
2073        }
2074    }
2075    ui_state
2076        .scroll
2077        .offsets
2078        .insert(node.computed_id.to_string(), clamped);
2079    ui_state.scroll.metrics.insert(
2080        node.computed_id.to_string(),
2081        crate::state::ScrollMetrics {
2082            viewport_h: inner.h,
2083            content_h,
2084            max_offset,
2085        },
2086    );
2087
2088    write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
2089
2090    if let Some(anchor) = choose_scroll_anchor(node, inner, clamped) {
2091        ui_state
2092            .scroll
2093            .scroll_anchors
2094            .insert(node.computed_id.to_string(), anchor);
2095    } else {
2096        ui_state.scroll.scroll_anchors.remove(&*node.computed_id);
2097    }
2098}
2099
2100/// Resolve a [`viewport()`](crate::tree::viewport)'s pan/zoom and bake it
2101/// into its descendants' rects — the 2D pan + uniform-zoom analogue of
2102/// [`apply_scroll_offset`]. Runs after the viewport's children have been
2103/// laid out in un-transformed *content space*; measures the content
2104/// bounding box, consumes any matching
2105/// [`ViewportRequest`](crate::viewport::ViewportRequest), clamps the
2106/// result, then maps every descendant rect through the transform. Because
2107/// the transform lands in each descendant's `computed_rect`, hit-test /
2108/// links / selection follow it automatically (paint additionally scales
2109/// the descendants' scalar visuals; see `draw_ops`).
2110fn apply_viewport_transform(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
2111    use crate::viewport::ViewportView;
2112    let cfg = node
2113        .viewport
2114        .as_deref()
2115        .copied()
2116        .expect("apply_viewport_transform called on a non-viewport node");
2117    let inner = node_rect.inset(node.padding);
2118    let origin = (inner.x, inner.y);
2119    let content = viewport_content_bbox(node);
2120
2121    // Start from the stored view, then fold in any programmatic requests
2122    // that name this viewport's key (consuming them).
2123    let mut view = ui_state
2124        .viewport
2125        .views
2126        .get(&*node.computed_id)
2127        .copied()
2128        .unwrap_or_default();
2129    if let Some(key) = node.key.as_deref() {
2130        let mut i = 0;
2131        while i < ui_state.viewport.pending_requests.len() {
2132            if ui_state.viewport.pending_requests[i].key() == key {
2133                let req = ui_state.viewport.pending_requests.remove(i);
2134                // Fit / reset restore the home framing (re-arming a
2135                // `FitPolicy::Contain`); `CenterOn` deliberately steers
2136                // away from it.
2137                match &req {
2138                    crate::viewport::ViewportRequest::FitContent { .. }
2139                    | crate::viewport::ViewportRequest::ResetView { .. } => {
2140                        ui_state.viewport.taken_over.remove(&*node.computed_id);
2141                    }
2142                    crate::viewport::ViewportRequest::CenterOn { .. } => {
2143                        ui_state
2144                            .viewport
2145                            .taken_over
2146                            .insert(node.computed_id.to_string());
2147                    }
2148                }
2149                view = apply_viewport_request(req, cfg, inner, origin, content, view);
2150            } else {
2151                i += 1;
2152            }
2153        }
2154    }
2155
2156    // Maintain the declarative fit policy: `Lock` re-fits every pass;
2157    // `Contain` re-fits until the user (or a `CenterOn`) takes the view
2158    // over. Recomputing the fit each pass — rather than diffing rects —
2159    // makes the framing track container resizes *and* content-extent
2160    // changes for free; with unchanged inputs the fit is bit-identical,
2161    // so a settled viewport stays put.
2162    match cfg.fit {
2163        crate::viewport::FitPolicy::Manual => {}
2164        crate::viewport::FitPolicy::Contain { padding } => {
2165            if !ui_state.viewport.taken_over.contains(&*node.computed_id) {
2166                view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2167            }
2168        }
2169        crate::viewport::FitPolicy::Lock { padding } => {
2170            // A locked viewport is at its home framing by construction —
2171            // clear any stray takeover (a `CenterOn` or seeded view that
2172            // raced the lock) so the at-home readback stays truthful.
2173            ui_state.viewport.taken_over.remove(&*node.computed_id);
2174            view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2175        }
2176    }
2177
2178    // Clamp zoom to the configured range, then clamp pan per the
2179    // configured bounds policy so the content can't drift past it.
2180    view.zoom = view.zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2181    if let Some(c) = content {
2182        clamp_viewport_pan(&mut view, cfg.pan_bounds, inner, origin, c);
2183    }
2184
2185    // Bake into descendant rects. Identity is a no-op — skip the walk.
2186    if view != ViewportView::default() {
2187        transform_viewport_subtree(node, view, origin, ui_state);
2188    }
2189
2190    ui_state
2191        .viewport
2192        .views
2193        .insert(node.computed_id.to_string(), view);
2194    ui_state.viewport.metrics.insert(
2195        node.computed_id.to_string(),
2196        crate::state::ViewportMetrics {
2197            inner,
2198            content,
2199            cfg,
2200        },
2201    );
2202}
2203
2204/// Bounding box of all of `node`'s descendant rects, in content space
2205/// (the rects as laid out before the viewport transform). `None` when the
2206/// viewport has no children with rects.
2207fn viewport_content_bbox(node: &El) -> Option<Rect> {
2208    let mut acc: Option<Rect> = None;
2209    for c in &node.children {
2210        let r = c.computed_rect;
2211        acc = Some(acc.map_or(r, |a| union_rect(a, r)));
2212        if let Some(bb) = viewport_content_bbox(c) {
2213            acc = Some(acc.map_or(bb, |a| union_rect(a, bb)));
2214        }
2215    }
2216    acc
2217}
2218
2219/// Smallest rect covering both inputs.
2220fn union_rect(a: Rect, b: Rect) -> Rect {
2221    let x = a.x.min(b.x);
2222    let y = a.y.min(b.y);
2223    let r = a.right().max(b.right());
2224    let bot = a.bottom().max(b.bottom());
2225    Rect::new(x, y, r - x, bot - y)
2226}
2227
2228/// Map every descendant rect of a viewport through `view` about `origin`.
2229/// The viewport node's own rect is left untouched (it is the window).
2230fn transform_viewport_subtree(
2231    node: &mut El,
2232    view: crate::viewport::ViewportView,
2233    origin: (f32, f32),
2234    ui_state: &mut UiState,
2235) {
2236    for c in &mut node.children {
2237        let rect = c.computed_rect;
2238        let (nx, ny) = view.project((rect.x, rect.y), origin);
2239        c.computed_rect = Rect::new(nx, ny, rect.w * view.zoom, rect.h * view.zoom);
2240        // `get_mut`, not insert: every keyed node in this subtree went
2241        // through `set_rect` earlier in the same pass, so the entry
2242        // exists — this walk only runs as a post-pass over freshly
2243        // laid-out children (same invariant as `shift_subtree_y`).
2244        if c.key.is_some()
2245            && let Some(r) = ui_state.layout.keyed_rects.get_mut(&c.computed_id)
2246        {
2247            *r = c.computed_rect;
2248        }
2249        transform_viewport_subtree(c, view, origin, ui_state);
2250    }
2251}
2252
2253/// Apply one programmatic request to a viewport view, given the live
2254/// inner rect / origin / content bbox. `current` carries the zoom for
2255/// `CenterOn` and is the fallback when `FitContent` has nothing to frame.
2256fn apply_viewport_request(
2257    req: crate::viewport::ViewportRequest,
2258    cfg: crate::viewport::ViewportConfig,
2259    inner: Rect,
2260    origin: (f32, f32),
2261    content: Option<Rect>,
2262    current: crate::viewport::ViewportView,
2263) -> crate::viewport::ViewportView {
2264    use crate::viewport::{ViewportRequest, ViewportView};
2265    match req {
2266        ViewportRequest::ResetView { .. } => ViewportView::default(),
2267        ViewportRequest::CenterOn { point, .. } => {
2268            viewport_center_on(inner, origin, current.zoom, point)
2269        }
2270        ViewportRequest::FitContent { padding, .. } => {
2271            viewport_fit_view(cfg, inner, origin, content, current, padding)
2272        }
2273    }
2274}
2275
2276/// The fit-to-content framing: the largest zoom (within the configured
2277/// range) that fits the content bbox inside `inner` with `padding` px of
2278/// margin, centered. `current` when there is nothing measurable to
2279/// frame. Shared by [`ViewportRequest::FitContent`] and the sustained
2280/// [`FitPolicy`](crate::viewport::FitPolicy) framings.
2281fn viewport_fit_view(
2282    cfg: crate::viewport::ViewportConfig,
2283    inner: Rect,
2284    origin: (f32, f32),
2285    content: Option<Rect>,
2286    current: crate::viewport::ViewportView,
2287    padding: f32,
2288) -> crate::viewport::ViewportView {
2289    match content {
2290        Some(c) if c.w > 0.0 || c.h > 0.0 => {
2291            let avail_w = (inner.w - 2.0 * padding).max(1.0);
2292            let avail_h = (inner.h - 2.0 * padding).max(1.0);
2293            let mut zoom = f32::INFINITY;
2294            if c.w > 0.0 {
2295                zoom = zoom.min(avail_w / c.w);
2296            }
2297            if c.h > 0.0 {
2298                zoom = zoom.min(avail_h / c.h);
2299            }
2300            if !zoom.is_finite() {
2301                return current;
2302            }
2303            let zoom = zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2304            viewport_center_on(inner, origin, zoom, (c.center_x(), c.center_y()))
2305        }
2306        _ => current,
2307    }
2308}
2309
2310/// A view at `zoom` whose pan places content-space `point` at the center
2311/// of the viewport `inner` rect.
2312fn viewport_center_on(
2313    inner: Rect,
2314    origin: (f32, f32),
2315    zoom: f32,
2316    point: (f32, f32),
2317) -> crate::viewport::ViewportView {
2318    crate::viewport::ViewportView {
2319        pan: (
2320            inner.center_x() - origin.0 - zoom * (point.0 - origin.0),
2321            inner.center_y() - origin.1 - zoom * (point.1 - origin.1),
2322        ),
2323        zoom,
2324    }
2325}
2326
2327/// Clamp `view.pan` against the transformed content bbox per the
2328/// configured [`PanBounds`] policy. See [`clamp_axis_delta`] for the
2329/// per-axis rule each policy applies.
2330fn clamp_viewport_pan(
2331    view: &mut crate::viewport::ViewportView,
2332    bounds: crate::viewport::PanBounds,
2333    inner: Rect,
2334    origin: (f32, f32),
2335    content: Rect,
2336) {
2337    if matches!(bounds, crate::viewport::PanBounds::Free) {
2338        return;
2339    }
2340    let (lx, ty) = view.project((content.x, content.y), origin);
2341    let w = content.w * view.zoom;
2342    let h = content.h * view.zoom;
2343    view.pan.0 += clamp_axis_delta(bounds, lx, lx + w, inner.x, inner.right(), w, inner.w);
2344    view.pan.1 += clamp_axis_delta(bounds, ty, ty + h, inner.y, inner.bottom(), h, inner.h);
2345}
2346
2347/// Pan delta on one axis that brings the content span `[lo, hi]` into the
2348/// allowed relation with the viewport span `[vlo, vhi]` under `bounds`.
2349/// Returns `0.0` when already valid.
2350fn clamp_axis_delta(
2351    bounds: crate::viewport::PanBounds,
2352    lo: f32,
2353    hi: f32,
2354    vlo: f32,
2355    vhi: f32,
2356    size: f32,
2357    vsize: f32,
2358) -> f32 {
2359    use crate::viewport::PanBounds;
2360    match bounds {
2361        // Caller short-circuits Free before reaching here; no-op for safety.
2362        PanBounds::Free => 0.0,
2363        // Keep the content bbox overlapping the viewport center, so any
2364        // content point is reachable to mid-frame.
2365        PanBounds::Center => {
2366            let vc = 0.5 * (vlo + vhi);
2367            if lo > vc {
2368                vc - lo
2369            } else if hi < vc {
2370                vc - hi
2371            } else {
2372                0.0
2373            }
2374        }
2375        PanBounds::Contain => {
2376            if size <= vsize {
2377                // Content fits: keep it inside the viewport.
2378                if lo < vlo {
2379                    vlo - lo
2380                } else if hi > vhi {
2381                    vhi - hi
2382                } else {
2383                    0.0
2384                }
2385            } else {
2386                // Content overflows: don't allow a gutter past either edge.
2387                if lo > vlo {
2388                    vlo - lo
2389                } else if hi < vhi {
2390                    vhi - hi
2391                } else {
2392                    0.0
2393                }
2394            }
2395        }
2396    }
2397}
2398
2399fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
2400    let anchor = ui_state.scroll.scroll_anchors.get(&*node.computed_id)?;
2401    // The anchor names an (often unkeyed) descendant by the id recorded
2402    // last frame; resolve its freshly laid-out rect by path descent. A
2403    // `None` (the node left the tree) falls back to the stored offset.
2404    let rect = &find_descendant_rect(node, anchor.node_id.as_str())?;
2405    if rect.h <= 0.0 {
2406        return None;
2407    }
2408    let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
2409    let scroll_delta = stored - anchor.resolved_offset;
2410    let viewport_y = anchor.viewport_y - scroll_delta;
2411    Some(rect.y - inner.y + rect_point - viewport_y)
2412}
2413
2414fn choose_scroll_anchor(node: &El, inner: Rect, offset: f32) -> Option<ScrollAnchor> {
2415    if inner.h <= 0.0 {
2416        return None;
2417    }
2418    let target_y = inner.y + inner.h * 0.25;
2419    let mut best = None;
2420    for child in &node.children {
2421        choose_scroll_anchor_in_subtree(child, inner, target_y, 1, &mut best);
2422    }
2423    let candidate = best?;
2424    let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
2425    let rect_fraction = if candidate.rect.h > 0.0 {
2426        ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
2427    } else {
2428        0.0
2429    };
2430    Some(ScrollAnchor {
2431        node_id: candidate.node_id,
2432        rect_fraction,
2433        viewport_y: anchor_y - inner.y,
2434        resolved_offset: offset,
2435    })
2436}
2437
2438#[derive(Clone, Debug)]
2439struct ScrollAnchorCandidate {
2440    node_id: String,
2441    rect: Rect,
2442    distance: f32,
2443    depth: usize,
2444}
2445
2446fn choose_scroll_anchor_in_subtree(
2447    node: &El,
2448    inner: Rect,
2449    target_y: f32,
2450    depth: usize,
2451    best: &mut Option<ScrollAnchorCandidate>,
2452) {
2453    let rect = node.computed_rect;
2454    if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
2455        let distance = distance_to_interval(target_y, rect.y, rect.bottom());
2456        let candidate = ScrollAnchorCandidate {
2457            node_id: node.computed_id.clone().to_string(),
2458            rect,
2459            distance,
2460            depth,
2461        };
2462        let replace = best.as_ref().is_none_or(|current| {
2463            candidate.distance < current.distance
2464                || (candidate.distance == current.distance && candidate.depth > current.depth)
2465                || (candidate.distance == current.distance
2466                    && candidate.depth == current.depth
2467                    && candidate.rect.h < current.rect.h)
2468        });
2469        if replace {
2470            *best = Some(candidate);
2471        }
2472    }
2473
2474    if node.scrollable {
2475        return;
2476    }
2477    for child in &node.children {
2478        choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, best);
2479    }
2480}
2481
2482/// Stored offset within this much of the pinned edge counts as "at
2483/// the edge" for [`crate::tree::PinPolicy`] activation. Wheel deltas
2484/// are integer pixels, so a half-pixel slack absorbs floating-point
2485/// rounding without admitting any deliberate user scroll.
2486const PIN_EPSILON: f32 = 0.5;
2487
2488/// Decide whether the pin should be engaged this frame, given the
2489/// previous frame's active flag and reference offset.
2490///
2491/// `policy` selects the edge: `End` references the previous frame's
2492/// `max_offset` (stored in `scroll.pin_prev_max`) and engages when
2493/// `stored ≈ prev_max`; `Start` references `0` and engages when
2494/// `stored ≈ 0`. `None` returns `None` so callers can short-circuit.
2495fn pin_would_be_active(
2496    node: &El,
2497    stored: f32,
2498    _max_offset: f32,
2499    ui_state: &UiState,
2500) -> Option<bool> {
2501    let prev_active = ui_state.scroll.pin_active.get(&*node.computed_id).copied();
2502    match node.pin_policy {
2503        crate::tree::PinPolicy::None => None,
2504        crate::tree::PinPolicy::End => {
2505            let prev_max = ui_state
2506                .scroll
2507                .pin_prev_max
2508                .get(&*node.computed_id)
2509                .copied();
2510            Some(match prev_active {
2511                None => true,
2512                Some(prev) => {
2513                    let prev_max = prev_max.unwrap_or(0.0);
2514                    if prev && stored < prev_max - PIN_EPSILON {
2515                        false
2516                    } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
2517                        true
2518                    } else {
2519                        prev
2520                    }
2521                }
2522            })
2523        }
2524        crate::tree::PinPolicy::Start => Some(match prev_active {
2525            None => true,
2526            Some(prev) => {
2527                if prev && stored > PIN_EPSILON {
2528                    false
2529                } else if !prev && stored <= PIN_EPSILON {
2530                    true
2531                } else {
2532                    prev
2533                }
2534            }
2535        }),
2536    }
2537}
2538
2539/// Apply this node's [`crate::tree::PinPolicy`] to `stored`. Reads the
2540/// previous frame's bookkeeping from `scroll.pin_active` /
2541/// `scroll.pin_prev_max` to decide whether the stored offset has moved
2542/// off the pinned edge since last frame (user wheel / drag /
2543/// programmatic write), and updates those maps accordingly. Returns
2544/// the offset that should be clamped + written downstream — the
2545/// pinned edge (`0` for `Start`, `max_offset` for `End`) when engaged,
2546/// the input `stored` otherwise.
2547///
2548/// First frame for an opted-in container starts pinned, so a freshly
2549/// mounted `scroll([...]).pin_end()` paints with its tail visible and
2550/// `scroll([...]).pin_start()` paints (still) with its head visible.
2551fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
2552    if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2553        ui_state.scroll.pin_active.remove(&*node.computed_id);
2554        ui_state.scroll.pin_prev_max.remove(&*node.computed_id);
2555        return stored;
2556    }
2557    let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
2558    ui_state
2559        .scroll
2560        .pin_active
2561        .insert(node.computed_id.to_string(), active);
2562    match node.pin_policy {
2563        crate::tree::PinPolicy::End => {
2564            ui_state
2565                .scroll
2566                .pin_prev_max
2567                .insert(node.computed_id.to_string(), max_offset);
2568            if active { max_offset } else { stored }
2569        }
2570        crate::tree::PinPolicy::Start => {
2571            // `pin_prev_max` is unused for Start — drop any stale entry
2572            // (e.g. if the policy was just flipped from End to Start
2573            // for the same computed_id).
2574            ui_state.scroll.pin_prev_max.remove(&*node.computed_id);
2575            if active { 0.0 } else { stored }
2576        }
2577        crate::tree::PinPolicy::None => unreachable!(),
2578    }
2579}
2580
2581/// Walk pending `ScrollRequest::EnsureVisible` requests and pop any
2582/// whose `container_key` resolves to an ancestor of `node`. For each
2583/// match, write a stored offset that brings the request's content-
2584/// space `y..y+h` range into the viewport using minimal-displacement
2585/// semantics (top edge if above, bottom edge if below, leave alone if
2586/// already inside). The clamp + shift downstream of this call ensures
2587/// the resulting offset stays inside `[0, max_offset]`.
2588///
2589/// Matching is by computed-id prefix on the keyed ancestor — a
2590/// scroll is "inside" the keyed widget when its id starts with the
2591/// ancestor's id followed by `.`, the same rule used by
2592/// [`crate::state::query::target_in_subtree`].
2593fn resolve_ensure_visible_for_scroll(
2594    node: &El,
2595    inner: Rect,
2596    content_h: f32,
2597    ui_state: &mut UiState,
2598) -> bool {
2599    if ui_state.scroll.pending_requests.is_empty() {
2600        return false;
2601    }
2602    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
2603    let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
2604    let mut wrote = false;
2605    for req in pending {
2606        let ScrollRequest::EnsureVisible {
2607            container_key,
2608            y,
2609            h,
2610        } = &req
2611        else {
2612            remaining.push(req);
2613            continue;
2614        };
2615        let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
2616            // Container hasn't been laid out yet (or its key isn't
2617            // in this tree). Keep the request for a future frame —
2618            // dropped at end-of-frame like row requests for
2619            // missing lists.
2620            remaining.push(req);
2621            continue;
2622        };
2623        // Match this scroll only if it sits inside the keyed widget.
2624        // Same prefix rule as `target_in_subtree`.
2625        let inside = node.computed_id == *ancestor_id
2626            || node
2627                .computed_id
2628                .strip_prefix(ancestor_id.as_ref())
2629                .is_some_and(|rest| rest.starts_with('.'));
2630        if !inside {
2631            remaining.push(req);
2632            continue;
2633        }
2634        let current = ui_state
2635            .scroll
2636            .offsets
2637            .get(&*node.computed_id)
2638            .copied()
2639            .unwrap_or(0.0);
2640        let target_top = *y;
2641        let target_bottom = *y + *h;
2642        let viewport_h = inner.h;
2643        // Minimal-displacement: if the range is fully visible, no
2644        // change. If it's above the viewport top, scroll up to it.
2645        // If it's below the viewport bottom, scroll just enough to
2646        // expose the bottom edge — but never less than 0 or more
2647        // than `content_h - viewport_h` (the clamp downstream will
2648        // do that anyway).
2649        let new_offset = if target_top < current {
2650            target_top
2651        } else if target_bottom > current + viewport_h {
2652            target_bottom - viewport_h
2653        } else {
2654            // Already visible: don't override an in-progress
2655            // manual scroll just because the caret happens to be
2656            // mid-viewport. Skip this request without disturbing
2657            // the offset.
2658            continue;
2659        };
2660        // Clamp against the live content extent so we don't write
2661        // a wildly-out-of-range offset when the request races a
2662        // layout pass that hasn't yet measured all rows.
2663        let max = (content_h - viewport_h).max(0.0);
2664        let new_offset = new_offset.clamp(0.0, max);
2665        ui_state
2666            .scroll
2667            .offsets
2668            .insert(node.computed_id.to_string(), new_offset);
2669        wrote = true;
2670    }
2671    ui_state.scroll.pending_requests = remaining;
2672    wrote
2673}
2674
2675/// Compute and store the scrollbar thumb + track rects for `node`
2676/// when the author opted into a visible scrollbar AND content
2677/// overflows. Both rects are anchored to the right edge of `inner`.
2678/// The visible thumb is `SCROLLBAR_THUMB_WIDTH` wide and tracks the
2679/// scroll offset; the track is `SCROLLBAR_HITBOX_WIDTH` wide and
2680/// covers the full inner height so a press above/below the thumb
2681/// can page-scroll.
2682fn write_thumb_rect(
2683    node: &El,
2684    inner: Rect,
2685    content_h: f32,
2686    max_offset: f32,
2687    offset: f32,
2688    ui_state: &mut UiState,
2689) {
2690    if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
2691        return;
2692    }
2693    let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
2694    let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
2695    let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
2696    let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
2697    let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
2698    let track_remaining = (inner.h - thumb_h).max(0.0);
2699    let thumb_y = inner.y + track_remaining * (offset / max_offset);
2700    // `scrollbar_gutter` reserved a content-free band on the right via
2701    // extra padding (metrics pass). The thumb belongs *inside* that
2702    // band, not at the now-narrower content edge — anchor it where the
2703    // content edge would be without the gutter, i.e. the band's right.
2704    let edge = if node.scrollbar_gutter {
2705        inner.right() + crate::tokens::SCROLLBAR_GUTTER
2706    } else {
2707        inner.right()
2708    };
2709    let thumb_x = edge - thumb_w - track_inset;
2710    let track_x = edge - track_w - track_inset;
2711    ui_state.scroll.thumb_rects.insert(
2712        node.computed_id.to_string(),
2713        Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
2714    );
2715    ui_state.scroll.thumb_tracks.insert(
2716        node.computed_id.to_string(),
2717        Rect::new(track_x, inner.y, track_w, inner.h),
2718    );
2719}
2720
2721fn shift_subtree_y(node: &mut El, dy: f32, ui_state: &mut UiState) {
2722    node.computed_rect.y += dy;
2723    if node.key.is_some()
2724        && let Some(rect) = ui_state.layout.keyed_rects.get_mut(&node.computed_id)
2725    {
2726        rect.y += dy;
2727    }
2728    // Thumb/track rects exist only for nodes that opted into a visible
2729    // scrollbar — gate the probes so the common node pays no hashing.
2730    if node.scrollbar {
2731        if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&*node.computed_id) {
2732            thumb.y += dy;
2733        }
2734        if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&*node.computed_id) {
2735            track.y += dy;
2736        }
2737    }
2738    for c in &mut node.children {
2739        shift_subtree_y(c, dy, ui_state);
2740    }
2741}
2742
2743/// Reusable per-invocation buffers for [`layout_axis`]'s distribution
2744/// loops and [`size_tree`]'s row two-pass. Both recursions re-enter
2745/// themselves, so the buffers are pooled (one entry per active
2746/// recursion level) rather than being a single thread-local set.
2747/// Before this, every container heap-allocated fresh Vecs per frame
2748/// (~tens of thousands of allocations at large node counts).
2749#[derive(Default)]
2750struct AxisScratch {
2751    main_sizes: Vec<f32>,
2752    fill_weights: Vec<Option<f32>>,
2753    row_slots: Vec<Option<(f32, f32)>>,
2754}
2755
2756impl AxisScratch {
2757    fn take() -> Self {
2758        AXIS_SCRATCH_POOL
2759            .with_borrow_mut(|pool| pool.pop())
2760            .unwrap_or_default()
2761    }
2762
2763    fn release(mut self) {
2764        self.main_sizes.clear();
2765        self.fill_weights.clear();
2766        self.row_slots.clear();
2767        AXIS_SCRATCH_POOL.with_borrow_mut(|pool| {
2768            // Depth-bounded in practice; the cap only guards pathology.
2769            if pool.len() < 256 {
2770                pool.push(self);
2771            }
2772        });
2773    }
2774}
2775
2776thread_local! {
2777    static AXIS_SCRATCH_POOL: std::cell::RefCell<Vec<AxisScratch>> =
2778        const { std::cell::RefCell::new(Vec::new()) };
2779}
2780
2781fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
2782    let inner = node_rect.inset(node.padding);
2783    let n = node.children.len();
2784    if n == 0 {
2785        return;
2786    }
2787    let mut scratch = AxisScratch::take();
2788
2789    let total_gap = node.gap * n.saturating_sub(1) as f32;
2790    let main_extent = if vertical { inner.h } else { inner.w };
2791    let cross_extent = if vertical { inner.w } else { inner.h };
2792
2793    // `main_size_of` resolves main-axis size directly from each child's
2794    // own sizing intent and its intrinsic. `Size::Aspect` on the main
2795    // axis is the one case that needs more context: it derives main
2796    // from the *resolved cross size*, which means we have to compute
2797    // cross first for that child only (an inversion of the normal
2798    // main-then-cross ordering). Cross resolution doesn't depend on
2799    // main except when cross is also Aspect — that's the degenerate
2800    // both-Aspect case and falls back to intrinsic via main_size_of.
2801    let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
2802        let main_intent = if vertical { c.height } else { c.width };
2803        if let Size::Aspect(r) = main_intent {
2804            let cross_intent = if vertical { c.width } else { c.height };
2805            if !matches!(cross_intent, Size::Aspect(_)) {
2806                let cross_intrinsic = if vertical { iw } else { ih };
2807                let cross_size = match cross_intent {
2808                    Size::Fixed(v) => v,
2809                    Size::Ch(n) => n * ch_unit(c),
2810                    Size::Hug | Size::Fill(_) => match node.align {
2811                        Align::Stretch => cross_extent,
2812                        Align::Start | Align::Center | Align::End => cross_intrinsic,
2813                    },
2814                    Size::Aspect(_) => unreachable!(),
2815                };
2816                let cross_size = if vertical {
2817                    clamp_w(c, cross_size)
2818                } else {
2819                    clamp_h(c, cross_size)
2820                };
2821                let main = cross_size * r.max(0.0);
2822                let clamped = if vertical {
2823                    clamp_h(c, main)
2824                } else {
2825                    clamp_w(c, main)
2826                };
2827                return MainSize::Resolved(clamped);
2828            }
2829        }
2830        main_size_of(c, iw, ih, vertical)
2831    };
2832
2833    // Resolve every child's main size up front. Fill children share
2834    // the free space by weight, with CSS-flex parity for min/max
2835    // clamps: a fill whose share violates its clamp is frozen at the
2836    // clamped size, and the space it freed (max) or stole (min) is
2837    // re-distributed among the still-flexible fills, iterating until
2838    // a distribution sticks. Each round computes every hypothetical
2839    // share against the same `remaining` / weight total, then freezes
2840    // all of that round's violators at once — freezing as it scans
2841    // would let an early max-freeze spuriously min-freeze a later
2842    // sibling against a half-updated distribution.
2843    let AxisScratch {
2844        main_sizes,
2845        fill_weights,
2846        ..
2847    } = &mut scratch;
2848    let mut consumed = 0.0;
2849    for c in node.children.iter() {
2850        // Sizing-pass output: the child's measured size at the width
2851        // this container implies (see `size_tree`).
2852        let (iw, ih) = c.measured_size;
2853        match resolve_main(c, iw, ih) {
2854            MainSize::Resolved(v) => {
2855                consumed += v;
2856                main_sizes.push(v);
2857                fill_weights.push(None);
2858            }
2859            MainSize::Fill(w) => {
2860                main_sizes.push(0.0);
2861                fill_weights.push(Some(w.max(0.001)));
2862            }
2863        }
2864    }
2865    let mut remaining = (main_extent - consumed - total_gap).max(0.0);
2866    loop {
2867        let flexible_weight: f32 = fill_weights.iter().flatten().sum();
2868        if flexible_weight == 0.0 {
2869            break;
2870        }
2871        let mut frozen_any = false;
2872        let mut newly_frozen = 0.0;
2873        for (i, c) in node.children.iter().enumerate() {
2874            let Some(w) = fill_weights[i] else { continue };
2875            let raw = remaining * w / flexible_weight;
2876            let clamped = if vertical {
2877                clamp_h(c, raw)
2878            } else {
2879                clamp_w(c, raw)
2880            };
2881            main_sizes[i] = clamped;
2882            if clamped != raw {
2883                fill_weights[i] = None;
2884                frozen_any = true;
2885                newly_frozen += clamped;
2886            }
2887        }
2888        if !frozen_any {
2889            // The flexible fills absorbed all the free space.
2890            remaining = 0.0;
2891            break;
2892        }
2893        remaining = (remaining - newly_frozen).max(0.0);
2894    }
2895
2896    // Free space after children + gaps that the fills could not absorb
2897    // (no fills, or every fill frozen at its max). This is what
2898    // justify distributes — mirroring flexbox's leftover free space.
2899    let free_after_used = remaining;
2900    let mut cursor = match node.justify {
2901        Justify::Start => 0.0,
2902        Justify::Center => free_after_used * 0.5,
2903        Justify::End => free_after_used,
2904        Justify::SpaceBetween => 0.0,
2905    };
2906    let between_extra = if matches!(node.justify, Justify::SpaceBetween) && n > 1 {
2907        free_after_used / (n - 1) as f32
2908    } else {
2909        0.0
2910    };
2911    let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
2912
2913    crate::profile_span!("layout::axis::place");
2914    for (i, c) in node.children.iter_mut().enumerate() {
2915        let (iw, ih) = c.measured_size;
2916        let main_size = main_sizes[i];
2917
2918        let cross_intent = if vertical { c.width } else { c.height };
2919        let cross_intrinsic = if vertical { iw } else { ih };
2920        // CSS-flex parity for cross-axis sizing: `Size::Fixed` is an
2921        // explicit author override and always wins. Otherwise the
2922        // parent's `Align` decides — `Stretch` (the column default)
2923        // stretches non-fixed children to the container, `Center` /
2924        // `Start` / `End` shrink to intrinsic so the alignment can
2925        // actually position them. This collapses Hug and Fill on the
2926        // cross axis (both are "follow align-items"), the same way
2927        // CSS flex doesn't distinguish between them on the cross axis.
2928        // `Aspect` derives cross from the already-resolved main. The
2929        // symmetric case (Aspect on main) is handled by `resolve_main`
2930        // above, which inverts the ordering for that child only.
2931        let cross_size = match cross_intent {
2932            Size::Fixed(v) => v,
2933            Size::Ch(n) => n * ch_unit(c),
2934            Size::Aspect(r) => main_size * r,
2935            Size::Hug | Size::Fill(_) => match node.align {
2936                Align::Stretch => cross_extent,
2937                Align::Start | Align::Center | Align::End => cross_intrinsic,
2938            },
2939        };
2940        let cross_size = if vertical {
2941            clamp_w(c, cross_size)
2942        } else {
2943            clamp_h(c, cross_size)
2944        };
2945
2946        let cross_off = match node.align {
2947            Align::Start | Align::Stretch => 0.0,
2948            Align::Center => (cross_extent - cross_size) * 0.5,
2949            Align::End => cross_extent - cross_size,
2950        };
2951
2952        let c_rect = if vertical {
2953            Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
2954        } else {
2955            Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
2956        };
2957        set_rect(c, c_rect, ui_state);
2958        if can_prune_scroll_child(c, c_rect, scroll_visible) {
2959            let nodes = zero_descendant_rects(c, c_rect, ui_state);
2960            record_pruned_subtree(nodes);
2961        } else {
2962            resize_if_width_diverged(c, c_rect.w);
2963            layout_children(c, c_rect, ui_state);
2964        }
2965
2966        cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
2967    }
2968    scratch.release();
2969}
2970
2971const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
2972
2973fn scroll_visible_content_rect(
2974    node: &El,
2975    inner: Rect,
2976    vertical: bool,
2977    ui_state: &UiState,
2978) -> Option<Rect> {
2979    if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2980        return None;
2981    }
2982    let offset = ui_state
2983        .scroll
2984        .offsets
2985        .get(&*node.computed_id)
2986        .copied()
2987        .unwrap_or(0.0)
2988        .max(0.0);
2989    Some(Rect::new(
2990        inner.x,
2991        inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
2992        inner.w,
2993        inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
2994    ))
2995}
2996
2997fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
2998    let Some(visible) = visible else {
2999        return false;
3000    };
3001    child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
3002}
3003
3004/// Also used by draw-op emission's sub-pixel subtree gate: a confined
3005/// subtree cannot paint outside its layout rect.
3006pub(crate) fn subtree_is_layout_confined(node: &El) -> bool {
3007    if node.translate != (0.0, 0.0)
3008        || node.scale != 1.0
3009        || node.shadow > 0.0
3010        || node.paint_overflow != Sides::zero()
3011        || node.hit_overflow != Sides::zero()
3012        || node.layout_override.is_some()
3013        || node.virtual_items.is_some()
3014    {
3015        return false;
3016    }
3017    node.children.iter().all(subtree_is_layout_confined)
3018}
3019
3020fn zero_descendant_rects(node: &mut El, rect: Rect, ui_state: &mut UiState) -> u64 {
3021    let mut count = 0;
3022    let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
3023    for child in &mut node.children {
3024        set_rect(child, zero, ui_state);
3025        count += 1 + zero_descendant_rects(child, zero, ui_state);
3026    }
3027    count
3028}
3029
3030fn record_pruned_subtree(nodes: u64) {
3031    PRUNE_STATS.with(|stats| {
3032        let mut stats = stats.borrow_mut();
3033        stats.subtrees += 1;
3034        stats.nodes += nodes;
3035    });
3036}
3037
3038enum MainSize {
3039    Resolved(f32),
3040    Fill(f32),
3041}
3042
3043fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
3044    let s = if vertical { c.height } else { c.width };
3045    let intr = if vertical { ih } else { iw };
3046    let clamp = |v: f32| {
3047        if vertical {
3048            clamp_h(c, v)
3049        } else {
3050            clamp_w(c, v)
3051        }
3052    };
3053    match s {
3054        Size::Fixed(v) => MainSize::Resolved(clamp(v)),
3055        Size::Ch(n) => MainSize::Resolved(clamp(n * ch_unit(c))),
3056        Size::Hug => MainSize::Resolved(clamp(intr)),
3057        Size::Fill(w) => MainSize::Fill(w),
3058        // Main-axis Aspect needs the resolved cross size to compute
3059        // `cross * r`. That requires inverting the normal main-then-
3060        // cross order and is handled by `layout_axis`'s `resolve_main`
3061        // closure. If we ever reach this arm (e.g. from a path that
3062        // bypasses `resolve_main`), fall back to intrinsic so the El
3063        // still has a finite measure.
3064        Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
3065    }
3066}
3067
3068/// Resolve an overlay child's rect within `parent`.
3069///
3070/// `clamp_to_parent` caps a `Hug` axis at the parent's extent
3071/// (`intrinsic.min(parent)`) — correct for a centered modal, which
3072/// shouldn't exceed the screen. A `viewport()` passes `false`: its
3073/// content sizes to full intrinsic and the pan/zoom transform reveals
3074/// the part past the frame (issue #112). Either way the node's own
3075/// `clamp_w`/`clamp_h` (explicit min/max) still apply.
3076fn overlay_rect(
3077    c: &El,
3078    parent: Rect,
3079    align: Align,
3080    justify: Justify,
3081    clamp_to_parent: bool,
3082) -> Rect {
3083    // On a `Hug` axis, full intrinsic unless the caller wants it capped
3084    // at the parent rect.
3085    let hug_w = |iw: f32| {
3086        if clamp_to_parent {
3087            iw.min(parent.w)
3088        } else {
3089            iw
3090        }
3091    };
3092    let hug_h = |ih: f32| {
3093        if clamp_to_parent {
3094            ih.min(parent.h)
3095        } else {
3096            ih
3097        }
3098    };
3099    // Sizing-pass output: `size_tree` measured this child at the
3100    // overlay's inner width, so wrap-text descendants already reserved
3101    // the height they will paint at (the Fixed-width modal-paragraph
3102    // shape the old per-child constrained re-measure handled).
3103    let (iw, ih) = c.measured_size;
3104    // Overlay isn't main/cross-asymmetric, so Aspect can resolve on
3105    // either axis here. Resolve the non-Aspect axis first, then derive
3106    // the Aspect axis. If both are Aspect (degenerate), fall back to
3107    // intrinsic for both.
3108    let (w, h) = match (c.width, c.height) {
3109        (Size::Aspect(_), Size::Aspect(_)) => (hug_w(iw), hug_h(ih)),
3110        (Size::Aspect(r), _) => {
3111            let h = match c.height {
3112                Size::Fixed(v) => v,
3113                Size::Ch(n) => n * ch_unit(c),
3114                Size::Hug => hug_h(ih),
3115                Size::Fill(_) => parent.h,
3116                Size::Aspect(_) => unreachable!(),
3117            };
3118            (h * r, h)
3119        }
3120        (_, Size::Aspect(r)) => {
3121            let w = match c.width {
3122                Size::Fixed(v) => v,
3123                Size::Ch(n) => n * ch_unit(c),
3124                Size::Hug => hug_w(iw),
3125                Size::Fill(_) => parent.w,
3126                Size::Aspect(_) => unreachable!(),
3127            };
3128            (w, w * r)
3129        }
3130        _ => {
3131            let w = match c.width {
3132                Size::Fixed(v) => v,
3133                Size::Ch(n) => n * ch_unit(c),
3134                Size::Hug => hug_w(iw),
3135                Size::Fill(_) => parent.w,
3136                Size::Aspect(_) => unreachable!(),
3137            };
3138            let h = match c.height {
3139                Size::Fixed(v) => v,
3140                Size::Ch(n) => n * ch_unit(c),
3141                Size::Hug => hug_h(ih),
3142                Size::Fill(_) => parent.h,
3143                Size::Aspect(_) => unreachable!(),
3144            };
3145            (w, h)
3146        }
3147    };
3148    let w = clamp_w(c, w);
3149    let h = clamp_h(c, h);
3150    let x = match align {
3151        Align::Start | Align::Stretch => parent.x,
3152        Align::Center => parent.x + (parent.w - w) * 0.5,
3153        Align::End => parent.right() - w,
3154    };
3155    let y = match justify {
3156        Justify::Start | Justify::SpaceBetween => parent.y,
3157        Justify::Center => parent.y + (parent.h - h) * 0.5,
3158        Justify::End => parent.bottom() - h,
3159    };
3160    Rect::new(x, y, w, h)
3161}
3162
3163/// Intrinsic (width, height) for hugging layouts. On-demand measure
3164/// for app code and [`LayoutCtx::measure`]; the layout pass itself
3165/// sizes the whole tree in one recursion ([`size_tree`]) and never
3166/// calls this.
3167pub fn intrinsic(c: &El) -> (f32, f32) {
3168    intrinsic_constrained(c, None)
3169}
3170
3171fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
3172    // A node's own Fixed width beats whatever the ancestor chain has
3173    // available: layout will resolve the node at exactly that width,
3174    // so measuring at any other width makes Hug ancestors disagree
3175    // with the final wrap of text descendants (issue #47).
3176    let available_width = match c.width {
3177        Size::Fixed(v) => Some(v),
3178        _ => available_width,
3179    };
3180    apply_aspect(
3181        c,
3182        available_width,
3183        intrinsic_constrained_uncached(c, available_width),
3184    )
3185}
3186
3187/// The layout pass's single sizing recursion: measure `node` under
3188/// `available_width` and store the result in [`El::measured_size`] for
3189/// the placement pass to consume — for the node and its whole subtree,
3190/// visiting each node exactly once per frame.
3191///
3192/// Semantics mirror [`intrinsic_constrained`] (same leaf rules, same
3193/// per-axis derivation of child constraints), with two intentional
3194/// differences: results are written in-node, and `layout_override`
3195/// children are sized unconstrained so `LayoutCtx::measure` and the
3196/// place pass have naturals to read (`layout_custom` re-sizes a child
3197/// whose assigned rect width diverges from its natural).
3198///
3199/// This replaces the retired per-frame intrinsic cache: the old place
3200/// walk re-entered the measure recursion from every ancestor level,
3201/// and each Hug/Fill boundary re-measured its whole subtree at a new
3202/// width (~1.2–3 full measures per node on card-heavy trees).
3203fn size_tree(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3204    SIZING_VISITS.with(|c| c.set(c.get() + 1));
3205    // Own Fixed width beats available — same rule as
3206    // `intrinsic_constrained` (issue #47). `Ch` resolves the same way
3207    // (the deleted place-side `child_intrinsic` did this); the main
3208    // tree has Ch rewritten to Fixed by `apply_size_rewrites`, but
3209    // virtual-list rows realized mid-place never pass through that
3210    // rewrite.
3211    let available_width = match node.width {
3212        Size::Fixed(v) => Some(v),
3213        Size::Ch(n) => Some(n * ch_unit(node)),
3214        _ => available_width,
3215    };
3216    let inner = size_tree_inner(node, available_width);
3217    let size = apply_aspect(node, available_width, inner);
3218    node.measured_size = size;
3219    // An unconstrained subtree's measures assume its own natural
3220    // width — placement at exactly that width needs no re-size.
3221    node.sized_at_width = available_width.unwrap_or(size.0);
3222    // Does any measure below here depend on the available width?
3223    // Wrap text and inline paragraphs re-flow; Aspect derives one
3224    // axis from constraint-dependent inputs; custom layouts and
3225    // virtual lists are opaque. Everything else (nowrap text, icons,
3226    // images, math, fixed boxes) measures identically at any width,
3227    // so a placed-width divergence can skip the re-size.
3228    node.width_sensitive = (node.text.is_some() && matches!(node.text_wrap, TextWrap::Wrap))
3229        || matches!(node.kind, Kind::Inlines)
3230        || node.layout_override.is_some()
3231        || node.virtual_items.is_some()
3232        || matches!(node.width, Size::Aspect(_))
3233        || matches!(node.height, Size::Aspect(_))
3234        || node.children.iter().any(|c| c.width_sensitive);
3235    size
3236}
3237
3238/// Re-run the sizing pass on a placed child whose resolved rect width
3239/// diverged from the width its stored measures assume — min/max clamp
3240/// freezing in the fill distribution, a stretched-then-clamped cross
3241/// size, a custom layout assigning a non-natural rect. The old place
3242/// walk re-measured every subtree implicitly; this hook confines that
3243/// adaptation to the boundaries where widths actually moved. Childless
3244/// leaves skip it: their stored measure isn't re-read after placement
3245/// (paint wraps text at the final rect itself).
3246#[inline]
3247fn resize_if_width_diverged(c: &mut El, final_w: f32) {
3248    if c.width_sensitive && !c.children.is_empty() && (final_w - c.sized_at_width).abs() > 0.5 {
3249        size_tree(c, Some(final_w));
3250    }
3251}
3252
3253fn size_tree_inner(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3254    if let Some(size) = leaf_intrinsic(node, available_width) {
3255        if node.layout_override.is_some() {
3256            // Custom-layout children measure unconstrained — the same
3257            // naturals the old on-demand `intrinsic(child)` produced
3258            // for `LayoutCtx::measure`. `layout_custom` re-sizes any
3259            // child whose assigned rect width differs.
3260            for ch in &mut node.children {
3261                size_tree(ch, None);
3262            }
3263        } else if !node.children.is_empty()
3264            && node.virtual_items.is_none()
3265            && !matches!(node.kind, Kind::Inlines)
3266        {
3267            // Content-bearing Els (text / icon / image / math) can
3268            // still carry children: their own measure ignores them,
3269            // but placement lays them out inside the node's rect per
3270            // its axis like any container. Size them at the node's
3271            // measured width. (Inlines children are zero-rect
3272            // pseudo-nodes; virtual rows are realized and sized during
3273            // placement.)
3274            size_tree_children(node, Some(size.0));
3275        }
3276        return size;
3277    }
3278    size_tree_children(node, available_width)
3279}
3280
3281/// The container branches of the sizing recursion: size every child
3282/// and aggregate this node's own measure from theirs. Content-bearing
3283/// leaves reuse it for its child-sizing side effect only.
3284fn size_tree_children(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3285    match node.axis {
3286        Axis::Overlay => {
3287            let child_available =
3288                available_width.map(|w| (w - node.padding.left - node.padding.right).max(0.0));
3289            let mut w: f32 = 0.0;
3290            let mut h: f32 = 0.0;
3291            for ch in &mut node.children {
3292                // Width derives from height for an Aspect child — the
3293                // old place-side `overlay_rect` deliberately measured
3294                // those unconstrained so text wrap isn't pre-capped at
3295                // the overlay width; `apply_aspect` overrides the
3296                // derived axis regardless.
3297                let ca = if matches!(ch.width, Size::Aspect(_)) {
3298                    None
3299                } else {
3300                    child_available
3301                };
3302                let (cw, chh) = size_tree(ch, ca);
3303                w = w.max(cw);
3304                h = h.max(chh);
3305            }
3306            apply_min(
3307                node,
3308                w + node.padding.left + node.padding.right,
3309                h + node.padding.top + node.padding.bottom,
3310            )
3311        }
3312        Axis::Column => {
3313            let mut w: f32 = 0.0;
3314            let mut h: f32 = node.padding.top + node.padding.bottom;
3315            let n = node.children.len();
3316            let child_available =
3317                available_width.map(|w| (w - node.padding.left - node.padding.right).max(0.0));
3318            for (i, ch) in node.children.iter_mut().enumerate() {
3319                let (cw, chh) = size_tree(ch, child_available);
3320                w = w.max(cw);
3321                h += chh;
3322                if i + 1 < n {
3323                    h += node.gap;
3324                }
3325            }
3326            apply_min(node, w + node.padding.left + node.padding.right, h)
3327        }
3328        Axis::Row => {
3329            // Two-pass measurement so that wrappable Fill children see
3330            // the width they will actually be laid out at (the
3331            // `Overflow B=N` shape): Fixed and Hug children measure
3332            // unconstrained first, then the leftover distributes among
3333            // Fill children by weight and each measures at its share.
3334            // The shares are the same simple weight split the place
3335            // pass starts from; min/max clamp freezing there can move
3336            // a clamped fill's final rect off its measured width — a
3337            // pre-existing approximation kept as-is.
3338            let n = node.children.len();
3339            let total_gap = node.gap * n.saturating_sub(1) as f32;
3340            let inner_available = available_width
3341                .map(|w| (w - node.padding.left - node.padding.right - total_gap).max(0.0));
3342
3343            let mut scratch = AxisScratch::take();
3344            let mut consumed: f32 = 0.0;
3345            let mut fill_weight_total: f32 = 0.0;
3346            for ch in &mut node.children {
3347                match ch.width {
3348                    Size::Fill(w) => {
3349                        fill_weight_total += w.max(0.001);
3350                        scratch.row_slots.push(None);
3351                    }
3352                    _ => {
3353                        let (cw, chh) = size_tree(ch, None);
3354                        consumed += cw;
3355                        scratch.row_slots.push(Some((cw, chh)));
3356                    }
3357                }
3358            }
3359
3360            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3361            let mut w_total: f32 = node.padding.left + node.padding.right;
3362            let mut h_max: f32 = 0.0;
3363            for (i, ch) in node.children.iter_mut().enumerate() {
3364                let (cw, chh) = match scratch.row_slots[i] {
3365                    Some(rc) => rc,
3366                    None => match (fill_remaining, fill_weight_total > 0.0) {
3367                        (Some(av), true) => {
3368                            let weight = match ch.width {
3369                                Size::Fill(w) => w.max(0.001),
3370                                _ => 1.0,
3371                            };
3372                            size_tree(ch, Some(av * weight / fill_weight_total))
3373                        }
3374                        _ => size_tree(ch, None),
3375                    },
3376                };
3377                w_total += cw;
3378                if i + 1 < n {
3379                    w_total += node.gap;
3380                }
3381                h_max = h_max.max(chh);
3382            }
3383            scratch.release();
3384            apply_min(
3385                node,
3386                w_total,
3387                h_max + node.padding.top + node.padding.bottom,
3388            )
3389        }
3390    }
3391}
3392
3393/// Apply `Size::Aspect` to a freshly-measured intrinsic by deriving the
3394/// aspect-locked axis from the other axis. Runs after the inner intrinsic
3395/// pass so it composes with any content type (image, text, container).
3396///
3397/// When the *other* axis is `Fill`, the layout-time size of that axis is
3398/// the parent's available extent, not the El's inner intrinsic. Using the
3399/// inner intrinsic would let a hugging parent under-size and the Aspect-
3400/// derived axis would then overflow at paint. Prefer `available_width`
3401/// for Fill width; we don't currently plumb available_height, so a Fill
3402/// height + Aspect width pairing falls back to inner intrinsic.
3403///
3404/// Both axes Aspect is degenerate — fall back to the inner intrinsic so
3405/// the El still has a finite measure. Negative ratios are clamped to zero
3406/// for the same reason.
3407fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
3408    match (c.width, c.height) {
3409        (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
3410        (Size::Aspect(r), _) => {
3411            // Basis axis is height; ih is already clamped by apply_min.
3412            // Clamp the derived width against the El's own min/max so
3413            // a hugging parent sees the intrinsic that layout will
3414            // actually paint at.
3415            (clamp_w(c, ih * r.max(0.0)), ih)
3416        }
3417        (_, Size::Aspect(r)) => {
3418            let raw_basis = match c.width {
3419                Size::Fixed(v) => v,
3420                Size::Ch(n) => n * ch_unit(c),
3421                Size::Fill(_) => available_width.unwrap_or(iw),
3422                Size::Hug | Size::Aspect(_) => iw,
3423            };
3424            // Mirror the layout-time ordering in `resolve_main`: clamp
3425            // the basis by the *basis* axis's min/max first, then derive
3426            // the other axis and clamp by its own min/max.
3427            let basis = clamp_w(c, raw_basis);
3428            (iw, clamp_h(c, basis * r.max(0.0)))
3429        }
3430        _ => (iw, ih),
3431    }
3432}
3433
3434fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
3435    if let Some(size) = leaf_intrinsic(c, available_width) {
3436        return size;
3437    }
3438    container_intrinsic(c, available_width)
3439}
3440
3441/// Measurement for every non-container case — custom layouts, virtual
3442/// lists, inline paragraphs, math, icons, images, text. Returns `None`
3443/// for plain containers (column/row/overlay), whose measurement
3444/// recurses. Shared verbatim between the on-demand
3445/// [`intrinsic_constrained`] recursion and the layout pass's
3446/// [`size_tree`] recursion so leaf semantics cannot drift apart.
3447fn leaf_intrinsic(c: &El, available_width: Option<f32>) -> Option<(f32, f32)> {
3448    if c.layout_override.is_some() {
3449        // Custom-layout nodes don't define an intrinsic. Authors must
3450        // size them with `Fixed` or `Fill` on both axes; the returned
3451        // (0.0, 0.0) is replaced by `apply_min` for `Fixed` and is
3452        // unread for `Fill` (parent's distribution decides).
3453        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3454            panic!(
3455                "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
3456                 Size::Hug is not supported for custom layouts",
3457                c.computed_id,
3458            );
3459        }
3460        return Some(apply_min(c, 0.0, 0.0));
3461    }
3462    if c.virtual_items.is_some() {
3463        // VirtualList sizes the whole viewport (the parent decides) and
3464        // realizes only on-screen rows. Hug-sizing it would mean
3465        // "shrink to fit all rows", defeating virtualization. Same
3466        // shape as the layout_override guard.
3467        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3468            panic!(
3469                "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
3470                 Size::Hug would defeat virtualization",
3471                c.computed_id,
3472            );
3473        }
3474        return Some(apply_min(c, 0.0, 0.0));
3475    }
3476    if matches!(c.kind, Kind::Inlines) {
3477        return Some(inline_paragraph_intrinsic(c, available_width));
3478    }
3479    if matches!(c.kind, Kind::HardBreak) {
3480        // HardBreak is meaningful only inside Inlines (where draw_ops
3481        // encodes it as `\n` in the attributed text). Outside Inlines
3482        // it's a no-op layout-wise.
3483        return Some(apply_min(c, 0.0, 0.0));
3484    }
3485    if matches!(c.kind, Kind::Math) {
3486        if let Some(expr) = &c.math {
3487            let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
3488            return Some(apply_min(
3489                c,
3490                layout.width + c.padding.left + c.padding.right,
3491                layout.height() + c.padding.top + c.padding.bottom,
3492            ));
3493        }
3494        return Some(apply_min(c, 0.0, 0.0));
3495    }
3496    if c.icon.is_some() {
3497        return Some(apply_min(
3498            c,
3499            c.font_size + c.padding.left + c.padding.right,
3500            c.font_size + c.padding.top + c.padding.bottom,
3501        ));
3502    }
3503    if let Some(img) = &c.image {
3504        // Natural pixel size as a logical-pixel intrinsic. Authors who
3505        // want a different sized box set `.width()` / `.height()`;
3506        // the projection inside that box is decided by `image_fit`.
3507        let w = img.width() as f32 + c.padding.left + c.padding.right;
3508        let h = img.height() as f32 + c.padding.top + c.padding.bottom;
3509        return Some(apply_min(c, w, h));
3510    }
3511    if let Some(text) = &c.text {
3512        let content_available = match c.text_wrap {
3513            TextWrap::NoWrap => None,
3514            TextWrap::Wrap => available_width
3515                .or(match c.width {
3516                    Size::Fixed(v) => Some(v),
3517                    Size::Ch(n) => Some(n * ch_unit(c)),
3518                    // Aspect-on-text would be circular (text height
3519                    // depends on wrap width which would depend on
3520                    // text height). Treat like Hug — no wrap cap.
3521                    Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3522                })
3523                .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3524        };
3525        let display = display_text_for_measure(c, text, content_available);
3526        let layout = text_metrics::layout_text_with_line_height_and_family(
3527            &display,
3528            c.font_size,
3529            c.line_height,
3530            c.font_family,
3531            c.font_weight,
3532            c.font_mono,
3533            c.text_tabular_numerals,
3534            c.text_wrap,
3535            content_available,
3536        );
3537        let w = match (content_available, c.width) {
3538            (Some(available), Size::Hug | Size::Aspect(_)) => {
3539                let unwrapped = text_metrics::layout_text_with_family(
3540                    text,
3541                    c.font_size,
3542                    c.font_family,
3543                    c.font_weight,
3544                    c.font_mono,
3545                    c.text_tabular_numerals,
3546                    TextWrap::NoWrap,
3547                    None,
3548                );
3549                unwrapped.width.min(available) + c.padding.left + c.padding.right
3550            }
3551            (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3552                available + c.padding.left + c.padding.right
3553            }
3554            (None, _) => layout.width + c.padding.left + c.padding.right,
3555        };
3556        let h = layout.height + c.padding.top + c.padding.bottom;
3557        return Some(apply_min(c, w, h));
3558    }
3559    None
3560}
3561
3562/// On-demand container measurement for [`intrinsic_constrained`] —
3563/// recurses immutably; the layout pass uses [`size_tree_inner`]'s
3564/// storing twin of these branches instead.
3565fn container_intrinsic(c: &El, available_width: Option<f32>) -> (f32, f32) {
3566    match c.axis {
3567        Axis::Overlay => {
3568            let mut w: f32 = 0.0;
3569            let mut h: f32 = 0.0;
3570            for ch in &c.children {
3571                let child_available =
3572                    available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3573                let (cw, chh) = intrinsic_constrained(ch, child_available);
3574                w = w.max(cw);
3575                h = h.max(chh);
3576            }
3577            apply_min(
3578                c,
3579                w + c.padding.left + c.padding.right,
3580                h + c.padding.top + c.padding.bottom,
3581            )
3582        }
3583        Axis::Column => {
3584            let mut w: f32 = 0.0;
3585            let mut h: f32 = c.padding.top + c.padding.bottom;
3586            let n = c.children.len();
3587            let child_available =
3588                available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3589            for (i, ch) in c.children.iter().enumerate() {
3590                let (cw, chh) = intrinsic_constrained(ch, child_available);
3591                w = w.max(cw);
3592                h += chh;
3593                if i + 1 < n {
3594                    h += c.gap;
3595                }
3596            }
3597            apply_min(c, w + c.padding.left + c.padding.right, h)
3598        }
3599        Axis::Row => {
3600            // Two-pass measurement so that wrappable Fill children see
3601            // the width they will actually be laid out at. Without
3602            // this, a `Size::Fill` paragraph inside a row falls through
3603            // `inline_paragraph_intrinsic`'s `available_width` fallback
3604            // with `None` and reports its unwrapped single-line height
3605            // — the row then under-reserves vertical space and the
3606            // wrapped text overflows downward into the next row. This
3607            // mirrors how `layout_axis` (the runtime pass) already
3608            // splits Resolved vs. Fill main-axis sizing.
3609            let n = c.children.len();
3610            let total_gap = c.gap * n.saturating_sub(1) as f32;
3611            let inner_available = available_width
3612                .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
3613
3614            // First pass: Fixed and Hug children measure unconstrained.
3615            // Fixed-width wrappable children self-resolve their wrap
3616            // width via `inline_paragraph_intrinsic`'s own Fixed
3617            // fallback; Hug children take their natural width. We only
3618            // need to feed an explicit available width to Fill.
3619            let mut consumed: f32 = 0.0;
3620            let mut fill_weight_total: f32 = 0.0;
3621            let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
3622            for ch in &c.children {
3623                match ch.width {
3624                    Size::Fill(w) => {
3625                        fill_weight_total += w.max(0.001);
3626                        sizes.push(None);
3627                    }
3628                    _ => {
3629                        let (cw, chh) = intrinsic(ch);
3630                        consumed += cw;
3631                        sizes.push(Some((cw, chh)));
3632                    }
3633                }
3634            }
3635
3636            // Second pass: distribute the leftover among Fill children
3637            // by weight and remeasure each with its share. Without an
3638            // available_width hint (row inside a Hug ancestor with no
3639            // outer constraint) we fall back to unconstrained
3640            // measurement — same lossy shape as the prior code, but
3641            // limited to the case where there's genuinely no width to
3642            // distribute.
3643            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3644            let mut w_total: f32 = c.padding.left + c.padding.right;
3645            let mut h_max: f32 = 0.0;
3646            for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
3647                let (cw, chh) = match slot {
3648                    Some(rc) => rc,
3649                    None => match (fill_remaining, fill_weight_total > 0.0) {
3650                        (Some(av), true) => {
3651                            let weight = match ch.width {
3652                                Size::Fill(w) => w.max(0.001),
3653                                _ => 1.0,
3654                            };
3655                            intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
3656                        }
3657                        _ => intrinsic(ch),
3658                    },
3659                };
3660                w_total += cw;
3661                if i + 1 < n {
3662                    w_total += c.gap;
3663                }
3664                h_max = h_max.max(chh);
3665            }
3666            apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
3667        }
3668    }
3669}
3670
3671pub(crate) fn text_layout(
3672    c: &El,
3673    available_width: Option<f32>,
3674) -> Option<text_metrics::TextLayout> {
3675    let text = c.text.as_ref()?;
3676    let content_available = match c.text_wrap {
3677        TextWrap::NoWrap => None,
3678        TextWrap::Wrap => available_width
3679            .or(match c.width {
3680                Size::Fixed(v) => Some(v),
3681                Size::Ch(n) => Some(n * ch_unit(c)),
3682                Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3683            })
3684            .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3685    };
3686    let display = display_text_for_measure(c, text, content_available);
3687    Some(text_metrics::layout_text_with_line_height_and_family(
3688        &display,
3689        c.font_size,
3690        c.line_height,
3691        c.font_family,
3692        c.font_weight,
3693        c.font_mono,
3694        c.text_tabular_numerals,
3695        c.text_wrap,
3696        content_available,
3697    ))
3698}
3699
3700fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
3701    if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
3702        (c.text_wrap, c.text_max_lines, available_width)
3703    {
3704        text_metrics::clamp_text_to_lines_with_family(
3705            text,
3706            c.font_size,
3707            c.font_family,
3708            c.font_weight,
3709            c.font_mono,
3710            width,
3711            max_lines.get() as usize,
3712        )
3713    } else {
3714        text.to_string()
3715    }
3716}
3717
3718fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
3719    if let Size::Fixed(v) = c.width {
3720        w = v;
3721    }
3722    if let Size::Fixed(v) = c.height {
3723        h = v;
3724    }
3725    (clamp_w(c, w), clamp_h(c, h))
3726}
3727
3728/// Apply [`El::min_width`] / [`El::max_width`] to a resolved width,
3729/// matching CSS's `min-width` over `max-width` precedence (when both
3730/// constraints conflict, the lower bound wins). Also clamps to a
3731/// non-negative result so a zero-padding Hug never reports a negative
3732/// intrinsic.
3733pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
3734    if let Some(max_w) = c.max_width {
3735        w = w.min(max_w);
3736    }
3737    if let Some(min_w) = c.min_width {
3738        w = w.max(min_w);
3739    }
3740    w.max(0.0)
3741}
3742
3743/// Height-axis companion to [`clamp_w`].
3744pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
3745    if let Some(max_h) = c.max_height {
3746        h = h.min(max_h);
3747    }
3748    if let Some(min_h) = c.min_height {
3749        h = h.max(min_h);
3750    }
3751    h.max(0.0)
3752}
3753
3754/// Approximate intrinsic measurement for `Kind::Inlines` paragraphs.
3755///
3756/// The paragraph paints through cosmic-text's rich-text shaping (which
3757/// resolves bold/italic/mono runs against fontdb), but layout needs a
3758/// width and height *before* we get to the renderer. We concatenate
3759/// the runs' text into one string and call `text_metrics::layout_text`
3760/// at the dominant font size — same approximation the lint pass uses
3761/// for single-style text. Bold/italic widths are slightly different
3762/// from regular; for body-text paragraphs that difference is well
3763/// under one wrap-line and we accept it. If a fixture wraps within
3764/// 1-2 characters of a boundary the rendered glyphs may straddle the
3765/// laid-out rect by a fraction of a glyph.
3766fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3767    if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
3768        return inline_mixed_intrinsic(node, available_width);
3769    }
3770    let concat = concat_inline_text(&node.children);
3771    let size = inline_paragraph_size(node);
3772    let line_height = inline_paragraph_line_height(node);
3773    let content_available = match node.text_wrap {
3774        TextWrap::NoWrap => None,
3775        TextWrap::Wrap => available_width
3776            .or(match node.width {
3777                Size::Fixed(v) => Some(v),
3778                Size::Ch(n) => Some(n * ch_unit(node)),
3779                Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3780            })
3781            .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
3782    };
3783    let layout = text_metrics::layout_text_with_line_height_and_family(
3784        &concat,
3785        size,
3786        line_height,
3787        node.font_family,
3788        FontWeight::Regular,
3789        false,
3790        false,
3791        node.text_wrap,
3792        content_available,
3793    );
3794    let w = match (content_available, node.width) {
3795        (Some(available), Size::Hug | Size::Aspect(_)) => {
3796            let unwrapped = text_metrics::layout_text_with_line_height_and_family(
3797                &concat,
3798                size,
3799                line_height,
3800                node.font_family,
3801                FontWeight::Regular,
3802                false,
3803                false,
3804                TextWrap::NoWrap,
3805                None,
3806            );
3807            unwrapped.width.min(available) + node.padding.left + node.padding.right
3808        }
3809        (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3810            available + node.padding.left + node.padding.right
3811        }
3812        (None, _) => layout.width + node.padding.left + node.padding.right,
3813    };
3814    let h = layout.height + node.padding.top + node.padding.bottom;
3815    apply_min(node, w, h)
3816}
3817
3818fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3819    let wrap_width = match node.text_wrap {
3820        TextWrap::Wrap => available_width.or(match node.width {
3821            Size::Fixed(v) => Some(v),
3822            Size::Ch(n) => Some(n * ch_unit(node)),
3823            Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3824        }),
3825        TextWrap::NoWrap => None,
3826    }
3827    .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
3828
3829    let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
3830        node.text_wrap,
3831        wrap_width,
3832        node.font_size * 0.82,
3833        node.font_size * 0.22,
3834        node.line_height,
3835    );
3836
3837    for child in &node.children {
3838        match child.kind {
3839            Kind::HardBreak => {
3840                breaker.finish_line();
3841                continue;
3842            }
3843            Kind::Text => {
3844                let text = child.text.as_deref().unwrap_or("");
3845                for chunk in inline_text_chunks(text) {
3846                    let is_space = chunk.chars().all(char::is_whitespace);
3847                    if breaker.skips_leading_space(is_space) {
3848                        continue;
3849                    }
3850                    let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
3851                    if breaker.wraps_before(is_space, w) {
3852                        breaker.finish_line();
3853                    }
3854                    if breaker.skips_overflowing_space(is_space, w) {
3855                        continue;
3856                    }
3857                    breaker.push(w, ascent, descent);
3858                }
3859                continue;
3860            }
3861            _ => {}
3862        }
3863        let (w, ascent, descent) = inline_child_metrics(child);
3864        if breaker.wraps_before(false, w) {
3865            breaker.finish_line();
3866        }
3867        breaker.push(w, ascent, descent);
3868    }
3869    let measurement = breaker.finish();
3870    let w = measurement.width + node.padding.left + node.padding.right;
3871    let h = measurement.height + node.padding.top + node.padding.bottom;
3872    apply_min(node, w, h)
3873}
3874
3875fn inline_text_chunks(text: &str) -> Vec<&str> {
3876    let mut chunks = Vec::new();
3877    let mut start = 0;
3878    let mut last_space = None;
3879    for (i, ch) in text.char_indices() {
3880        let is_space = ch.is_whitespace();
3881        match last_space {
3882            None => last_space = Some(is_space),
3883            Some(prev) if prev != is_space => {
3884                chunks.push(&text[start..i]);
3885                start = i;
3886                last_space = Some(is_space);
3887            }
3888            _ => {}
3889        }
3890    }
3891    if start < text.len() {
3892        chunks.push(&text[start..]);
3893    }
3894    chunks
3895}
3896
3897fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
3898    let layout = text_metrics::layout_text_with_line_height_and_family(
3899        text,
3900        child.font_size,
3901        child.line_height,
3902        child.font_family,
3903        child.font_weight,
3904        child.font_mono,
3905        child.text_tabular_numerals,
3906        TextWrap::NoWrap,
3907        None,
3908    );
3909    (layout.width, child.font_size * 0.82, child.font_size * 0.22)
3910}
3911
3912fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
3913    match child.kind {
3914        Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
3915        Kind::Math => {
3916            if let Some(expr) = &child.math {
3917                let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
3918                (layout.width, layout.ascent, layout.descent)
3919            } else {
3920                (0.0, 0.0, 0.0)
3921            }
3922        }
3923        _ => (0.0, 0.0, 0.0),
3924    }
3925}
3926
3927/// Walk an Inlines paragraph's children and produce the source-order
3928/// concatenation that draw_ops will hand to the atlas. `Kind::Text`
3929/// contributes its `text` field; `Kind::HardBreak` contributes a
3930/// newline; anything else contributes nothing (an unsupported child
3931/// kind inside Inlines is a programmer error elsewhere — measurement
3932/// silently ignores it).
3933fn concat_inline_text(children: &[El]) -> String {
3934    let mut s = String::new();
3935    for c in children {
3936        match c.kind {
3937            Kind::Text => {
3938                if let Some(t) = &c.text {
3939                    s.push_str(t);
3940                }
3941            }
3942            Kind::HardBreak => s.push('\n'),
3943            _ => {}
3944        }
3945    }
3946    s
3947}
3948
3949/// Pick the font size that drives the paragraph's measurement. We use
3950/// the maximum across text children rather than the parent's own
3951/// `font_size`, because builders set sizes on the leaf text nodes.
3952fn inline_paragraph_size(node: &El) -> f32 {
3953    let mut size: f32 = node.font_size;
3954    for c in &node.children {
3955        if matches!(c.kind, Kind::Text) {
3956            size = size.max(c.font_size);
3957        }
3958    }
3959    size
3960}
3961
3962fn inline_paragraph_line_height(node: &El) -> f32 {
3963    let mut line_height: f32 = node.line_height;
3964    let mut max_size: f32 = node.font_size;
3965    for c in &node.children {
3966        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
3967            max_size = c.font_size;
3968            line_height = c.line_height;
3969        }
3970    }
3971    line_height
3972}
3973
3974#[cfg(test)]
3975mod tests {
3976    use super::*;
3977    use crate::state::UiState;
3978
3979    /// Issue #64: two same-role siblings sharing a key collide on
3980    /// `computed_id`. Runtime-only apps never run the bundle lint, so
3981    /// the layout pass flags the collision once per id (reusing the
3982    /// `DuplicateId` finding vocabulary) and dedupes across frames.
3983    #[test]
3984    fn duplicate_sibling_keys_flagged_once() {
3985        let mut root = column([
3986            crate::widgets::text::text("a").key("dup"),
3987            crate::widgets::text::text("b").key("dup"),
3988        ]);
3989        let mut state = UiState::new();
3990        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3991
3992        // Both siblings collapse onto one computed_id...
3993        assert_eq!(root.children[0].computed_id, root.children[1].computed_id);
3994        let id = root.children[0].computed_id.clone();
3995        // ...and that id is flagged.
3996        assert!(state.layout.warned_duplicate_ids.contains(&*id));
3997
3998        // A standing duplicate must not re-flag on the next layout.
3999        let n_before = state.layout.warned_duplicate_ids.len();
4000        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4001        assert_eq!(state.layout.warned_duplicate_ids.len(), n_before);
4002    }
4003
4004    /// A tree with unique sibling keys must not flag anything.
4005    #[test]
4006    fn distinct_sibling_keys_not_flagged() {
4007        let mut root = column([
4008            crate::widgets::text::text("a").key("one"),
4009            crate::widgets::text::text("b").key("two"),
4010        ]);
4011        let mut state = UiState::new();
4012        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4013        assert!(state.layout.warned_duplicate_ids.is_empty());
4014    }
4015
4016    /// CSS-flex parity: a `Size::Fill` child of a column with
4017    /// `align(Center)` should shrink to its intrinsic cross-axis size
4018    /// and be horizontally centered, matching `align-items: center`
4019    /// in CSS flex (which causes flex items to lose their stretch).
4020    #[test]
4021    fn align_center_shrinks_fill_child_to_intrinsic() {
4022        // Column with align(Center). Inner row has the default
4023        // El::new width = Fill(1.0); without Proposal B it would
4024        // claim the full 200px and align would be a no-op.
4025        let mut root = column([crate::row([crate::widgets::text::text("hi")
4026            .width(Size::Fixed(40.0))
4027            .height(Size::Fixed(20.0))])])
4028        .align(Align::Center)
4029        .width(Size::Fixed(200.0))
4030        .height(Size::Fixed(100.0));
4031        let mut state = UiState::new();
4032        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4033        let row_rect = root.children[0].computed_rect;
4034        // Row's intrinsic width = 40 (single fixed child). 200 - 40 = 160
4035        // leftover; centered → row starts at x=80.
4036        assert!(
4037            (row_rect.x - 80.0).abs() < 0.5,
4038            "expected x≈80 (centered), got {}",
4039            row_rect.x
4040        );
4041        assert!(
4042            (row_rect.w - 40.0).abs() < 0.5,
4043            "expected w≈40 (shrunk to intrinsic), got {}",
4044            row_rect.w
4045        );
4046    }
4047
4048    /// CSS-flex parity: when a `Fill` sibling hits its `max_width`,
4049    /// the space it frees is re-distributed to the still-flexible
4050    /// fills instead of becoming trailing dead space.
4051    #[test]
4052    fn max_clamped_fill_frees_space_to_sibling_fills() {
4053        let mut root = crate::row([
4054            El::new(Kind::Group).width(Size::Fill(1.0)).max_width(50.0),
4055            El::new(Kind::Group).width(Size::Fill(1.0)),
4056        ])
4057        .width(Size::Fixed(400.0))
4058        .height(Size::Fixed(100.0));
4059        let mut state = UiState::new();
4060        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4061        let a = root.children[0].computed_rect;
4062        let b = root.children[1].computed_rect;
4063        assert!((a.w - 50.0).abs() < 0.5, "capped fill w={}", a.w);
4064        assert!(
4065            (b.w - 350.0).abs() < 0.5,
4066            "sibling fill should absorb the freed 150px, got w={}",
4067            b.w
4068        );
4069    }
4070
4071    /// A `min_width` violation steals space symmetrically: the frozen
4072    /// fill takes its floor and the flexible sibling absorbs what's
4073    /// left, rather than both overflowing the row.
4074    #[test]
4075    fn min_clamped_fill_steals_space_from_sibling_fills() {
4076        let mut root = crate::row([
4077            El::new(Kind::Group).width(Size::Fill(1.0)).min_width(300.0),
4078            El::new(Kind::Group).width(Size::Fill(1.0)),
4079        ])
4080        .width(Size::Fixed(400.0))
4081        .height(Size::Fixed(100.0));
4082        let mut state = UiState::new();
4083        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4084        let a = root.children[0].computed_rect;
4085        let b = root.children[1].computed_rect;
4086        assert!((a.w - 300.0).abs() < 0.5, "floored fill w={}", a.w);
4087        assert!(
4088            (b.w - 100.0).abs() < 0.5,
4089            "sibling fill should shrink to the remaining 100px, got w={}",
4090            b.w
4091        );
4092    }
4093
4094    /// When every fill freezes at its max, the leftover becomes free
4095    /// space for `justify` — a centered capped column actually centers
4096    /// instead of leaving all the slack trailing.
4097    #[test]
4098    fn justify_distributes_space_left_by_fully_capped_fills() {
4099        let mut root = crate::row([El::new(Kind::Group).width(Size::Fill(1.0)).max_width(100.0)])
4100            .justify(Justify::Center)
4101            .width(Size::Fixed(400.0))
4102            .height(Size::Fixed(100.0));
4103        let mut state = UiState::new();
4104        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4105        let a = root.children[0].computed_rect;
4106        assert!((a.w - 100.0).abs() < 0.5, "capped fill w={}", a.w);
4107        assert!(
4108            (a.x - 150.0).abs() < 0.5,
4109            "capped fill should be centered (x≈150), got x={}",
4110            a.x
4111        );
4112    }
4113
4114    /// `align(Stretch)` (the default) preserves Fill stretching: a
4115    /// Fill-width child still claims the full cross axis.
4116    #[test]
4117    fn align_stretch_preserves_fill_stretch() {
4118        let mut root = column([crate::row([crate::widgets::text::text("hi")
4119            .width(Size::Fixed(40.0))
4120            .height(Size::Fixed(20.0))])])
4121        .align(Align::Stretch)
4122        .width(Size::Fixed(200.0))
4123        .height(Size::Fixed(100.0));
4124        let mut state = UiState::new();
4125        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4126        let row_rect = root.children[0].computed_rect;
4127        assert!(
4128            (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
4129            "expected stretched (x=0, w=200), got x={} w={}",
4130            row_rect.x,
4131            row_rect.w
4132        );
4133    }
4134
4135    /// A content-bearing El (here: one with `.text(...)`) can still
4136    /// carry children, which placement lays out inside its rect like
4137    /// any container. The sizing pass must size those children even
4138    /// though the node's own measure ignores them — the first cut of
4139    /// `size_tree` early-returned at the text branch and Hug children
4140    /// collapsed to zero rects.
4141    #[test]
4142    fn children_of_text_bearing_node_still_get_sized() {
4143        let mut root = column([El::new(Kind::Group)
4144            .text("label".to_string())
4145            .child(
4146                crate::widgets::text::text("nested child")
4147                    .width(Size::Hug)
4148                    .height(Size::Hug),
4149            )
4150            .width(Size::Fixed(300.0))
4151            .height(Size::Fixed(100.0))]);
4152        let mut state = UiState::new();
4153        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 100.0));
4154        let child = root.children[0].children[0].computed_rect;
4155        assert!(
4156            child.w > 10.0 && child.h > 5.0,
4157            "hug child of a text-bearing parent must size from its own \
4158             content, got {child:?}"
4159        );
4160    }
4161
4162    /// When min/max clamp freezing moves a fill child's final width off
4163    /// the share the sizing pass measured it at, the placement pass
4164    /// re-sizes the subtree at the resolved width — otherwise wrap-text
4165    /// descendants keep line breaks (and heights) from the unclamped
4166    /// share. Mirrors the adaptation the old re-measuring place walk
4167    /// did implicitly.
4168    #[test]
4169    fn clamped_fill_resizes_wrap_text_descendants_at_final_width() {
4170        let long = "words that will definitely wrap at two hundred pixels \
4171                    but not at three hundred, repeated for effect and \
4172                    measure, wrapping across several lines";
4173        let make = |max_w: Option<f32>| {
4174            let mut fill = column([crate::widgets::text::text(long).wrap_text()])
4175                .width(Size::Fill(1.0))
4176                .height(Size::Hug);
4177            if let Some(m) = max_w {
4178                fill.max_width = Some(m);
4179            }
4180            crate::row([
4181                crate::tree::spacer()
4182                    .width(Size::Fixed(100.0))
4183                    .height(Size::Fixed(10.0)),
4184                fill,
4185            ])
4186            .width(Size::Fixed(400.0))
4187            .height(Size::Fixed(400.0))
4188        };
4189        // Reference: a fill that genuinely gets 200px (300px leftover,
4190        // clamped vs unclamped must agree once re-sized).
4191        let mut clamped = make(Some(200.0));
4192        let mut state = UiState::new();
4193        layout(&mut clamped, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4194        let col = &clamped.children[1];
4195        let col_rect = col.computed_rect;
4196        assert!(
4197            (col_rect.w - 200.0).abs() < 0.5,
4198            "fill should clamp to 200, got {}",
4199            col_rect.w
4200        );
4201        // The wrapped paragraph inside must occupy the height of text
4202        // wrapped at 200px — several lines — not the two-line height
4203        // of the unclamped 300px share.
4204        let para = col.children[0].computed_rect;
4205        let (_, expected_h) = intrinsic_constrained(&col.children[0], Some(200.0));
4206        assert!(
4207            (para.h - expected_h).abs() < 0.5,
4208            "paragraph should reserve wrap-at-200 height {expected_h}, got {}",
4209            para.h
4210        );
4211    }
4212
4213    /// Issue #47: a Hug ancestor must measure a wrap-text descendant
4214    /// at the width layout will resolve for it — the node's own Fixed
4215    /// width — not at whatever wider width the ancestor chain has
4216    /// available. Inverted precedence here made measure wrap at the
4217    /// card's inner width while layout wrapped at the text's fixed
4218    /// width, leaving every Hug ancestor short by the difference.
4219    #[test]
4220    fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
4221        let long = "The quick brown fox jumps over the lazy dog, then \
4222                    does it again and again until the line is long \
4223                    enough to wrap several times.";
4224        // "card": Fill-wide, Hug-tall → measures its subtree. Inner
4225        // column Fixed(240); wrap text Fixed(200) — measure must use
4226        // 200, not the card's much wider inner width.
4227        let mut root = column([column([column([crate::widgets::text::text(long)
4228            .wrap_text()
4229            .width(Size::Fixed(200.0))])
4230        .width(Size::Fixed(240.0))])
4231        .width(Size::Fill(1.0))]);
4232        let mut state = UiState::new();
4233        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4234        let card = root.children[0].computed_rect;
4235        let text_rect = root.children[0].children[0].children[0].computed_rect;
4236        assert!(
4237            text_rect.h > 25.0,
4238            "text should wrap to multiple lines at 200px, got h={}",
4239            text_rect.h
4240        );
4241        assert!(
4242            (card.h - text_rect.h).abs() < 0.5,
4243            "Hug card height {} must match wrapped text height {}",
4244            card.h,
4245            text_rect.h
4246        );
4247    }
4248
4249    /// When all children are Hug-sized, `Justify::Center` should split
4250    /// the leftover space symmetrically across the main axis.
4251    #[test]
4252    fn justify_center_centers_hug_children() {
4253        let mut root = column([crate::widgets::text::text("hi")
4254            .width(Size::Fixed(40.0))
4255            .height(Size::Fixed(20.0))])
4256        .justify(Justify::Center)
4257        .height(Size::Fill(1.0));
4258        let mut state = UiState::new();
4259        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4260        let child_rect = root.children[0].computed_rect;
4261        // Expected: 100 - 20 = 80 leftover; centered → starts at y=40.
4262        assert!(
4263            (child_rect.y - 40.0).abs() < 0.5,
4264            "expected y≈40, got {}",
4265            child_rect.y
4266        );
4267    }
4268
4269    #[test]
4270    fn justify_end_pushes_to_bottom() {
4271        let mut root = column([crate::widgets::text::text("hi")
4272            .width(Size::Fixed(40.0))
4273            .height(Size::Fixed(20.0))])
4274        .justify(Justify::End)
4275        .height(Size::Fill(1.0));
4276        let mut state = UiState::new();
4277        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4278        let child_rect = root.children[0].computed_rect;
4279        assert!(
4280            (child_rect.y - 80.0).abs() < 0.5,
4281            "expected y≈80, got {}",
4282            child_rect.y
4283        );
4284    }
4285
4286    /// CSS `justify-content: space-between`: when no main-axis Fill
4287    /// children claim the slack, the leftover space is distributed
4288    /// evenly *between* (not around) the children — outer edges flush.
4289    #[test]
4290    fn justify_space_between_distributes_evenly() {
4291        let row_child = || {
4292            crate::widgets::text::text("x")
4293                .width(Size::Fixed(20.0))
4294                .height(Size::Fixed(20.0))
4295        };
4296        let mut root = column([row_child(), row_child(), row_child()])
4297            .justify(Justify::SpaceBetween)
4298            .height(Size::Fixed(200.0));
4299        let mut state = UiState::new();
4300        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
4301        // Used main = 3 * 20 = 60. Leftover = 140 over (n-1) = 2 gaps
4302        // → 70 between. Positions: 0, 90, 180.
4303        let y0 = root.children[0].computed_rect.y;
4304        let y1 = root.children[1].computed_rect.y;
4305        let y2 = root.children[2].computed_rect.y;
4306        assert!(
4307            y0.abs() < 0.5,
4308            "first child should be flush at y=0, got {y0}"
4309        );
4310        assert!(
4311            (y1 - 90.0).abs() < 0.5,
4312            "middle child should be at y≈90, got {y1}"
4313        );
4314        assert!(
4315            (y2 - 180.0).abs() < 0.5,
4316            "last child should be flush at y≈180, got {y2}"
4317        );
4318    }
4319
4320    /// CSS `flex: <weight>`: when multiple `Size::Fill` children share
4321    /// a container, the available space is distributed in proportion
4322    /// to their weights.
4323    #[test]
4324    fn fill_weight_distributes_proportionally() {
4325        let big = crate::widgets::text::text("big")
4326            .width(Size::Fixed(40.0))
4327            .height(Size::Fill(2.0));
4328        let small = crate::widgets::text::text("small")
4329            .width(Size::Fixed(40.0))
4330            .height(Size::Fill(1.0));
4331        let mut root = column([big, small]).height(Size::Fixed(300.0));
4332        let mut state = UiState::new();
4333        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
4334        // Total weight = 3, available = 300. Big = 200, small = 100.
4335        let big_h = root.children[0].computed_rect.h;
4336        let small_h = root.children[1].computed_rect.h;
4337        assert!(
4338            (big_h - 200.0).abs() < 0.5,
4339            "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
4340        );
4341        assert!(
4342            (small_h - 100.0).abs() < 0.5,
4343            "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
4344        );
4345    }
4346
4347    /// `padding` on a `Hug`-sized container is included in the
4348    /// container's intrinsic — matching CSS `box-sizing: content-box`
4349    /// where padding adds to the rendered size.
4350    #[test]
4351    fn padding_on_hug_includes_in_intrinsic() {
4352        let root = column([crate::widgets::text::text("x")
4353            .width(Size::Fixed(40.0))
4354            .height(Size::Fixed(40.0))])
4355        .padding(Sides::all(20.0));
4356        let (w, h) = intrinsic(&root);
4357        // 40 content + 2*20 padding on each axis = 80.
4358        assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
4359        assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
4360    }
4361
4362    /// Cross-axis `Align::End` on a row pins children to the bottom
4363    /// edge — CSS `align-items: flex-end`. Mirror of `justify_end`
4364    /// but on the cross axis instead of the main axis.
4365    #[test]
4366    fn align_end_pins_to_cross_axis_far_edge() {
4367        let mut root = crate::row([crate::widgets::text::text("hi")
4368            .width(Size::Fixed(40.0))
4369            .height(Size::Fixed(20.0))])
4370        .align(Align::End)
4371        .width(Size::Fixed(200.0))
4372        .height(Size::Fixed(100.0));
4373        let mut state = UiState::new();
4374        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4375        let child_rect = root.children[0].computed_rect;
4376        // Row cross axis = height. End → child y = 100 - 20 = 80.
4377        assert!(
4378            (child_rect.y - 80.0).abs() < 0.5,
4379            "expected y≈80 (pinned to bottom), got {}",
4380            child_rect.y
4381        );
4382    }
4383
4384    #[test]
4385    fn overlay_can_center_hug_child() {
4386        let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
4387            .width(Size::Fixed(200.0))
4388            .height(Size::Hug)])
4389        .align(Align::Center)
4390        .justify(Justify::Center);
4391        let mut state = UiState::new();
4392        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
4393        let child_rect = root.children[0].computed_rect;
4394        assert!(
4395            (child_rect.x - 200.0).abs() < 0.5,
4396            "expected x≈200, got {}",
4397            child_rect.x
4398        );
4399        assert!(
4400            child_rect.y > 100.0 && child_rect.y < 200.0,
4401            "expected centered y, got {}",
4402            child_rect.y
4403        );
4404    }
4405
4406    #[test]
4407    fn scroll_offset_translates_children_and_clamps_to_content() {
4408        // Six 50px-tall rows in a 200px-tall scroll viewport.
4409        // Content height = 6 * 50 + 5 * 12 (gap) = 360 px. Visible
4410        // viewport (no padding) = 200 px → max_offset = 160.
4411        let mut root = scroll(
4412            (0..6)
4413                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4414        )
4415        .key("list")
4416        .gap(12.0)
4417        .height(Size::Fixed(200.0));
4418        let mut state = UiState::new();
4419        assign_ids(&mut root);
4420        state
4421            .scroll
4422            .offsets
4423            .insert(root.computed_id.clone().to_string(), 80.0);
4424
4425        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4426
4427        // Offset is in range, applied verbatim.
4428        let stored = state
4429            .scroll
4430            .offsets
4431            .get(&*root.computed_id)
4432            .copied()
4433            .unwrap_or(0.0);
4434        assert!(
4435            (stored - 80.0).abs() < 0.01,
4436            "offset clamped unexpectedly: {stored}"
4437        );
4438        // First child shifted up by 80.
4439        let c0 = root.children[0].computed_rect;
4440        assert!(
4441            (c0.y - (-80.0)).abs() < 0.01,
4442            "child 0 y = {} (expected -80)",
4443            c0.y
4444        );
4445        // Now overshoot — should clamp to max_offset=160.
4446        state
4447            .scroll
4448            .offsets
4449            .insert(root.computed_id.clone().to_string(), 9999.0);
4450        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4451        let stored = state
4452            .scroll
4453            .offsets
4454            .get(&*root.computed_id)
4455            .copied()
4456            .unwrap_or(0.0);
4457        assert!(
4458            (stored - 160.0).abs() < 0.01,
4459            "overshoot clamped to {stored}"
4460        );
4461        // Content fits → offset clamps to 0.
4462        let mut tiny =
4463            scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
4464                .height(Size::Fixed(200.0));
4465        let mut tiny_state = UiState::new();
4466        assign_ids(&mut tiny);
4467        tiny_state
4468            .scroll
4469            .offsets
4470            .insert(tiny.computed_id.clone().to_string(), 50.0);
4471        layout(
4472            &mut tiny,
4473            &mut tiny_state,
4474            Rect::new(0.0, 0.0, 300.0, 200.0),
4475        );
4476        assert_eq!(
4477            tiny_state
4478                .scroll
4479                .offsets
4480                .get(&*tiny.computed_id)
4481                .copied()
4482                .unwrap_or(0.0),
4483            0.0
4484        );
4485    }
4486
4487    #[test]
4488    fn scroll_layout_prunes_far_offscreen_descendants() {
4489        let far = column([crate::widgets::text::text("far row body").key("far-text")])
4490            .height(Size::Fixed(40.0));
4491        let mut root = scroll([
4492            column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
4493            crate::tree::spacer().height(Size::Fixed(400.0)),
4494            far,
4495        ])
4496        .key("list")
4497        .height(Size::Fixed(80.0));
4498        let mut state = UiState::new();
4499        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
4500        let stats = take_prune_stats();
4501
4502        assert!(
4503            stats.subtrees >= 1,
4504            "expected at least one far scroll child to be pruned, got {stats:?}"
4505        );
4506        assert!(
4507            stats.nodes >= 1,
4508            "expected pruned descendants to be zeroed, got {stats:?}"
4509        );
4510        let far_text = state
4511            .rect_of_key("far-text")
4512            .expect("far text keeps a zero rect while pruned");
4513        assert_eq!(far_text.w, 0.0);
4514        assert_eq!(far_text.h, 0.0);
4515    }
4516
4517    #[test]
4518    fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
4519        let make_root = || {
4520            let paragraph_text = "Variable width text wraps into a different number of lines when \
4521                                  the viewport narrows, which used to make a plain scroll box lose \
4522                                  the item the user was reading.";
4523            scroll([column((0..30).map(|i| {
4524                crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
4525                    .key(format!("paragraph-{i}"))
4526            }))
4527            .gap(8.0)])
4528            .key("article")
4529            .height(Size::Fixed(180.0))
4530        };
4531
4532        let mut root = make_root();
4533        let mut state = UiState::new();
4534        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4535
4536        state
4537            .scroll
4538            .offsets
4539            .insert(root.computed_id.clone().to_string(), 520.0);
4540        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4541
4542        let anchor = state
4543            .scroll
4544            .scroll_anchors
4545            .get(&*root.computed_id)
4546            .cloned()
4547            .expect("plain scroll should store a visible descendant anchor");
4548        let before_rect = find_descendant_rect(&root, &anchor.node_id).expect("anchor rect before");
4549        let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
4550        let before_offset = state.scroll_offset(&root.computed_id);
4551
4552        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
4553
4554        let after_rect = find_descendant_rect(&root, &anchor.node_id).expect("anchor rect after");
4555        let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
4556        let after_offset = state.scroll_offset(&root.computed_id);
4557        assert!(
4558            (after_anchor_y - before_anchor_y).abs() < 0.5,
4559            "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
4560        );
4561        assert!(
4562            (after_offset - before_offset).abs() > 20.0,
4563            "offset should absorb height changes above the anchor"
4564        );
4565    }
4566
4567    #[test]
4568    fn scrollbar_thumb_size_and_position_track_overflow() {
4569        // 6 rows x 50px + 5 gaps x 12 = 360 content; 200 viewport.
4570        // viewport/content = 200/360 ≈ 0.555 → thumb_h ≈ 111.1.
4571        let mut root = scroll(
4572            (0..6)
4573                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4574        )
4575        .gap(12.0)
4576        .height(Size::Fixed(200.0));
4577        let mut state = UiState::new();
4578        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4579
4580        let metrics = state
4581            .scroll
4582            .metrics
4583            .get(&*root.computed_id)
4584            .copied()
4585            .expect("scrollable should have metrics");
4586        assert!((metrics.viewport_h - 200.0).abs() < 0.01);
4587        assert!((metrics.content_h - 360.0).abs() < 0.01);
4588        assert!((metrics.max_offset - 160.0).abs() < 0.01);
4589
4590        let thumb = state
4591            .scroll
4592            .thumb_rects
4593            .get(&*root.computed_id)
4594            .copied()
4595            .expect("scrollable with scrollbar() and overflow gets a thumb");
4596        // viewport^2 / content_h = 200^2 / 360 = 111.11..
4597        assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
4598        assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
4599        // At offset 0, thumb sits at the top of the inner rect.
4600        assert!(thumb.y.abs() < 0.01);
4601        // Right-anchored: thumb_x + thumb_w + track_inset == viewport_right.
4602        assert!(
4603            (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
4604            "thumb anchored at {} (expected {})",
4605            thumb.x,
4606            300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
4607        );
4608
4609        // Slide to half — thumb should be at half the track_remaining.
4610        state
4611            .scroll
4612            .offsets
4613            .insert(root.computed_id.clone().to_string(), 80.0);
4614        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4615        let thumb = state
4616            .scroll
4617            .thumb_rects
4618            .get(&*root.computed_id)
4619            .copied()
4620            .unwrap();
4621        let track_remaining = 200.0 - thumb.h;
4622        let expected_y = track_remaining * (80.0 / 160.0);
4623        assert!(
4624            (thumb.y - expected_y).abs() < 0.5,
4625            "thumb at half-scroll y = {} (expected {expected_y})",
4626            thumb.y,
4627        );
4628    }
4629
4630    #[test]
4631    fn scrollbar_track_is_wider_than_thumb_and_full_height() {
4632        // The track is the click hitbox: wider than the visible
4633        // thumb (Fitts's law) and tall enough to detect track
4634        // clicks above and below the thumb for paging.
4635        let mut root = scroll(
4636            (0..6)
4637                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4638        )
4639        .gap(12.0)
4640        .height(Size::Fixed(200.0));
4641        let mut state = UiState::new();
4642        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4643
4644        let thumb = state
4645            .scroll
4646            .thumb_rects
4647            .get(&*root.computed_id)
4648            .copied()
4649            .unwrap();
4650        let track = state
4651            .scroll
4652            .thumb_tracks
4653            .get(&*root.computed_id)
4654            .copied()
4655            .unwrap();
4656        // Track wider than thumb on the same right edge.
4657        assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
4658        assert!(
4659            (track.right() - thumb.right()).abs() < 0.01,
4660            "track and thumb must share the right edge",
4661        );
4662        // Track spans the full inner viewport (so above/below thumb
4663        // are both inside it for click-to-page).
4664        assert!(
4665            (track.h - 200.0).abs() < 0.01,
4666            "track height = {} (expected 200)",
4667            track.h,
4668        );
4669    }
4670
4671    #[test]
4672    fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
4673        // Same scrollable, but author opted out — no thumb_rect.
4674        let mut suppressed = scroll(
4675            (0..6)
4676                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4677        )
4678        .no_scrollbar()
4679        .height(Size::Fixed(200.0));
4680        let mut state = UiState::new();
4681        layout(
4682            &mut suppressed,
4683            &mut state,
4684            Rect::new(0.0, 0.0, 300.0, 200.0),
4685        );
4686        assert!(
4687            !state
4688                .scroll
4689                .thumb_rects
4690                .contains_key(&*suppressed.computed_id)
4691        );
4692
4693        // Same scrollable, content fits → no thumb either.
4694        let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
4695            .height(Size::Fixed(200.0));
4696        let mut tiny_state = UiState::new();
4697        layout(
4698            &mut tiny,
4699            &mut tiny_state,
4700            Rect::new(0.0, 0.0, 300.0, 200.0),
4701        );
4702        assert!(
4703            !tiny_state
4704                .scroll
4705                .thumb_rects
4706                .contains_key(&*tiny.computed_id)
4707        );
4708    }
4709
4710    #[test]
4711    fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
4712        let make_root = || {
4713            scroll([
4714                crate::tree::spacer().height(Size::Fixed(80.0)),
4715                scroll((0..6).map(|i| {
4716                    crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
4717                }))
4718                .key("inner")
4719                .height(Size::Fixed(120.0)),
4720                crate::tree::spacer().height(Size::Fixed(260.0)),
4721            ])
4722            .key("outer")
4723            .height(Size::Fixed(220.0))
4724        };
4725
4726        let mut root = make_root();
4727        let mut state = UiState::new();
4728        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4729        let inner = root
4730            .children
4731            .iter()
4732            .find(|child| child.key.as_deref() == Some("inner"))
4733            .expect("inner scroll");
4734        let inner_id = inner.computed_id.clone();
4735        let inner_rect = state.rect(&inner_id);
4736        let thumb = state
4737            .scroll
4738            .thumb_rects
4739            .get(&*inner_id)
4740            .copied()
4741            .expect("inner scroll should have a thumb");
4742        let track = state
4743            .scroll
4744            .thumb_tracks
4745            .get(&*inner_id)
4746            .copied()
4747            .expect("inner scroll should have a track");
4748        let thumb_rel_y = thumb.y - inner_rect.y;
4749        let track_rel_y = track.y - inner_rect.y;
4750
4751        state
4752            .scroll
4753            .offsets
4754            .insert(root.computed_id.clone().to_string(), 60.0);
4755        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4756        let inner_rect_after = state.rect(&inner_id);
4757        let thumb_after = state.scroll.thumb_rects.get(&*inner_id).copied().unwrap();
4758        let track_after = state.scroll.thumb_tracks.get(&*inner_id).copied().unwrap();
4759
4760        assert!(
4761            (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
4762            "outer scroll should shift the inner viewport"
4763        );
4764        assert!(
4765            (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
4766            "inner thumb should stay fixed relative to its viewport"
4767        );
4768        assert!(
4769            (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
4770            "inner track should stay fixed relative to its viewport"
4771        );
4772    }
4773
4774    #[test]
4775    fn layout_override_places_children_at_returned_rects() {
4776        // A custom layout that just stacks children diagonally inside the container.
4777        let mut root = column((0..3).map(|i| {
4778            crate::widgets::text::text(format!("dot {i}"))
4779                .width(Size::Fixed(20.0))
4780                .height(Size::Fixed(20.0))
4781        }))
4782        .width(Size::Fixed(200.0))
4783        .height(Size::Fixed(200.0))
4784        .layout(|ctx| {
4785            ctx.children
4786                .iter()
4787                .enumerate()
4788                .map(|(i, _)| {
4789                    let off = i as f32 * 30.0;
4790                    Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
4791                })
4792                .collect()
4793        });
4794        let mut state = UiState::new();
4795        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4796        let r0 = root.children[0].computed_rect;
4797        let r1 = root.children[1].computed_rect;
4798        let r2 = root.children[2].computed_rect;
4799        assert_eq!((r0.x, r0.y), (0.0, 0.0));
4800        assert_eq!((r1.x, r1.y), (30.0, 30.0));
4801        assert_eq!((r2.x, r2.y), (60.0, 60.0));
4802    }
4803
4804    #[test]
4805    fn layout_override_rect_of_key_resolves_earlier_sibling() {
4806        // The popover-anchor pattern: a custom-laid-out node positions
4807        // its child by reading another keyed node's rect via the new
4808        // LayoutCtx::rect_of_key callback. The trigger lives in an
4809        // earlier sibling so its rect is already in the keyed-rect
4810        // map by the time the popover layer's layout_override runs.
4811        use crate::tree::stack;
4812        let trigger_x = 40.0;
4813        let trigger_y = 20.0;
4814        let trigger_w = 60.0;
4815        let trigger_h = 30.0;
4816        let mut root = stack([
4817            // Earlier sibling: the trigger.
4818            crate::widgets::button::button("Open")
4819                .key("trig")
4820                .width(Size::Fixed(trigger_w))
4821                .height(Size::Fixed(trigger_h)),
4822            // Later sibling: a custom-laid-out container that reads
4823            // the trigger's rect to position its single child.
4824            stack([crate::widgets::text::text("popover")
4825                .width(Size::Fixed(80.0))
4826                .height(Size::Fixed(20.0))])
4827            .width(Size::Fill(1.0))
4828            .height(Size::Fill(1.0))
4829            .layout(|ctx| {
4830                let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
4831                vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
4832            }),
4833        ])
4834        .padding(Sides::xy(trigger_x, trigger_y));
4835        let mut state = UiState::new();
4836        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4837
4838        let popover_layer = &root.children[1];
4839        let panel_rect = popover_layer.children[0].computed_rect;
4840        // Anchored to (trigger.x, trigger.bottom() + 4.0). With padding
4841        // (40, 20) and trigger height 30 → expect (40, 54).
4842        assert!(
4843            (panel_rect.x - trigger_x).abs() < 0.01,
4844            "popover x = {} (expected {trigger_x})",
4845            panel_rect.x,
4846        );
4847        assert!(
4848            (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
4849            "popover y = {} (expected {})",
4850            panel_rect.y,
4851            trigger_y + trigger_h + 4.0,
4852        );
4853    }
4854
4855    #[test]
4856    fn layout_override_rect_of_key_returns_none_for_missing_key() {
4857        let mut root = column([crate::widgets::text::text("inner")
4858            .width(Size::Fixed(40.0))
4859            .height(Size::Fixed(20.0))])
4860        .width(Size::Fixed(200.0))
4861        .height(Size::Fixed(200.0))
4862        .layout(|ctx| {
4863            assert!((ctx.rect_of_key)("nope").is_none());
4864            vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
4865        });
4866        let mut state = UiState::new();
4867        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4868    }
4869
4870    #[test]
4871    fn layout_override_rect_of_key_returns_none_for_later_sibling() {
4872        // First-frame contract: a custom layout running before its
4873        // target's sibling has been laid out should see `None`, not a
4874        // zero rect or a panic. This is what makes the popover pattern
4875        // (trigger first, popover layer second in source order) the
4876        // supported shape — the reverse direction simply gets `None`.
4877        use crate::tree::stack;
4878        let mut root = stack([
4879            stack([crate::widgets::text::text("panel")
4880                .width(Size::Fixed(40.0))
4881                .height(Size::Fixed(20.0))])
4882            .width(Size::Fill(1.0))
4883            .height(Size::Fill(1.0))
4884            .layout(|ctx| {
4885                assert!(
4886                    (ctx.rect_of_key)("later").is_none(),
4887                    "later sibling's rect must not be available yet"
4888                );
4889                vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
4890            }),
4891            crate::widgets::button::button("after").key("later"),
4892        ]);
4893        let mut state = UiState::new();
4894        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4895    }
4896
4897    #[test]
4898    fn layout_override_measure_returns_intrinsic() {
4899        // The custom layout reads `measure` to size each child.
4900        let mut root = column([crate::widgets::text::text("hi")
4901            .width(Size::Fixed(40.0))
4902            .height(Size::Fixed(20.0))])
4903        .width(Size::Fixed(200.0))
4904        .height(Size::Fixed(200.0))
4905        .layout(|ctx| {
4906            let (w, h) = (ctx.measure)(&ctx.children[0]);
4907            assert!((w - 40.0).abs() < 0.01, "measured width {w}");
4908            assert!((h - 20.0).abs() < 0.01, "measured height {h}");
4909            vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
4910        });
4911        let mut state = UiState::new();
4912        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4913        let r = root.children[0].computed_rect;
4914        assert_eq!((r.w, r.h), (40.0, 20.0));
4915    }
4916
4917    #[test]
4918    #[should_panic(expected = "returned 1 rects for 2 children")]
4919    fn layout_override_length_mismatch_panics() {
4920        let mut root = column([
4921            crate::widgets::text::text("a")
4922                .width(Size::Fixed(10.0))
4923                .height(Size::Fixed(10.0)),
4924            crate::widgets::text::text("b")
4925                .width(Size::Fixed(10.0))
4926                .height(Size::Fixed(10.0)),
4927        ])
4928        .width(Size::Fixed(200.0))
4929        .height(Size::Fixed(200.0))
4930        .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
4931        let mut state = UiState::new();
4932        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4933    }
4934
4935    #[test]
4936    #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
4937    fn layout_override_hug_panics() {
4938        // Hug check fires when the parent's layout pass measures the
4939        // custom-layout child for sizing — i.e. when a layout_override
4940        // node is a child of a column/row, not when it's the root.
4941        let mut root = column([column([crate::widgets::text::text("c")])
4942            .width(Size::Hug)
4943            .height(Size::Fixed(200.0))
4944            .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
4945        .width(Size::Fixed(200.0))
4946        .height(Size::Fixed(200.0));
4947        let mut state = UiState::new();
4948        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4949    }
4950
4951    #[test]
4952    fn fit_contain_letterboxes_and_centers() {
4953        // 16:9 child in a 400×400 container: width binds → 400×225,
4954        // centered vertically.
4955        let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted");
4956        let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
4957            .width(Size::Fixed(400.0))
4958            .height(Size::Fixed(400.0));
4959        let mut state = UiState::new();
4960        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4961
4962        let r = state.rect_of_key("fitted").expect("fitted rect");
4963        assert_eq!((r.w, r.h), (400.0, 225.0));
4964        assert_eq!(r.x, 0.0);
4965        assert!((r.y - 87.5).abs() < 0.01, "centered: y = {}", r.y);
4966
4967        // Tall container, same ratio: height binds → pillarboxed.
4968        let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted2");
4969        let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
4970            .width(Size::Fixed(400.0))
4971            .height(Size::Fixed(100.0));
4972        let mut state = UiState::new();
4973        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4974        let r = state.rect_of_key("fitted2").expect("fitted2 rect");
4975        assert!((r.w - 100.0 * 16.0 / 9.0).abs() < 0.01);
4976        assert_eq!(r.h, 100.0);
4977        assert!((r.x - (400.0 - r.w) / 2.0).abs() < 0.01);
4978    }
4979
4980    #[test]
4981    fn fit_cover_overflows_the_slack_axis_and_clips() {
4982        // 1:1 child covering a 400×200 container: width binds the
4983        // cover → 400×400, vertically centered (overflowing top and
4984        // bottom), and the container clips.
4985        let child = crate::tree::column([crate::widgets::text::text("c")]).key("covered");
4986        let mut root = crate::tree::fit_cover(child, 1.0)
4987            .width(Size::Fixed(400.0))
4988            .height(Size::Fixed(200.0));
4989        assert!(root.clip, "fit_cover must clip its overflow");
4990        let mut state = UiState::new();
4991        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
4992        let r = state.rect_of_key("covered").expect("covered rect");
4993        assert_eq!((r.w, r.h), (400.0, 400.0));
4994        assert!(
4995            (r.y - -100.0).abs() < 0.01,
4996            "centered overflow: y = {}",
4997            r.y
4998        );
4999    }
5000
5001    #[test]
5002    fn fit_contain_intrinsic_uses_the_child_measure() {
5003        // A fixed 100×50 child (2:1) in a 400×400 container → 400×200.
5004        let child = crate::tree::column::<Vec<El>, El>(vec![])
5005            .width(Size::Fixed(100.0))
5006            .height(Size::Fixed(50.0))
5007            .key("intrinsic");
5008        let mut root = crate::tree::fit_contain_intrinsic(child)
5009            .width(Size::Fixed(400.0))
5010            .height(Size::Fixed(400.0));
5011        let mut state = UiState::new();
5012        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5013        let r = state.rect_of_key("intrinsic").expect("intrinsic rect");
5014        assert_eq!((r.w, r.h), (400.0, 200.0));
5015    }
5016
5017    #[test]
5018    fn virtual_list_realizes_only_visible_rows() {
5019        // 100 rows × 50px each in a 200px viewport, offset = 120.
5020        // Visible range: rows whose y in [-50, 200) → start = floor(120/50) = 2,
5021        // end = ceil((120+200)/50) = ceil(6.4) = 7. Five rows realized.
5022        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
5023            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5024        });
5025        let mut state = UiState::new();
5026        assign_ids(&mut root);
5027        state
5028            .scroll
5029            .offsets
5030            .insert(root.computed_id.clone().to_string(), 120.0);
5031        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5032
5033        assert_eq!(
5034            root.children.len(),
5035            5,
5036            "expected 5 realized rows, got {}",
5037            root.children.len()
5038        );
5039        // Identity check: the first realized row should be the row keyed "row-2".
5040        assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
5041        assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
5042        // Position check: first realized row's y = inner.y + 2*50 - 120 = -20.
5043        let r0 = root.children[0].computed_rect;
5044        assert!(
5045            (r0.y - (-20.0)).abs() < 0.5,
5046            "row 2 expected y≈-20, got {}",
5047            r0.y
5048        );
5049    }
5050
5051    #[test]
5052    fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
5053        let mut root = crate::tree::virtual_list(10, 40.0, |i| {
5054            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5055        })
5056        .gap(10.0);
5057        let mut state = UiState::new();
5058        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5059
5060        assert_eq!(
5061            root.children.len(),
5062            3,
5063            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5064        );
5065        let row_1 = root
5066            .children
5067            .iter()
5068            .find(|c| c.key.as_deref() == Some("row-1"))
5069            .expect("row 1 should be realized");
5070        assert!(
5071            (row_1.computed_rect.y - 50.0).abs() < 0.5,
5072            "gap should place row 1 at y=50"
5073        );
5074        let metrics = state
5075            .scroll
5076            .metrics
5077            .get(&*root.computed_id)
5078            .expect("virtual list writes scroll metrics");
5079        assert!(
5080            (metrics.content_h - 490.0).abs() < 0.5,
5081            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5082            metrics.content_h
5083        );
5084    }
5085
5086    #[test]
5087    fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
5088        let make_root = || {
5089            crate::tree::virtual_list(50, 50.0, |i| {
5090                crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5091            })
5092        };
5093
5094        let mut state = UiState::new();
5095        let mut root_a = make_root();
5096        assign_ids(&mut root_a);
5097        // Scroll so row 5 is visible.
5098        state
5099            .scroll
5100            .offsets
5101            .insert(root_a.computed_id.clone().to_string(), 250.0);
5102        layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5103        let id_at_offset_a = root_a
5104            .children
5105            .iter()
5106            .find(|c| c.key.as_deref() == Some("row-5"))
5107            .unwrap()
5108            .computed_id
5109            .clone();
5110
5111        // Re-layout with a different offset — row 5 is still visible.
5112        let mut root_b = make_root();
5113        assign_ids(&mut root_b);
5114        state
5115            .scroll
5116            .offsets
5117            .insert(root_b.computed_id.clone().to_string(), 200.0);
5118        layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5119        let id_at_offset_b = root_b
5120            .children
5121            .iter()
5122            .find(|c| c.key.as_deref() == Some("row-5"))
5123            .unwrap()
5124            .computed_id
5125            .clone();
5126
5127        assert_eq!(
5128            id_at_offset_a, id_at_offset_b,
5129            "row-5's computed_id changed when scroll offset moved"
5130        );
5131    }
5132
5133    #[test]
5134    fn virtual_list_clamps_overshoot_offset() {
5135        // 10 rows × 50 = 500 content height; viewport 200; max offset = 300.
5136        let mut root =
5137            crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
5138        let mut state = UiState::new();
5139        assign_ids(&mut root);
5140        state
5141            .scroll
5142            .offsets
5143            .insert(root.computed_id.clone().to_string(), 9999.0);
5144        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5145        let stored = state
5146            .scroll
5147            .offsets
5148            .get(&*root.computed_id)
5149            .copied()
5150            .unwrap_or(0.0);
5151        assert!(
5152            (stored - 300.0).abs() < 0.01,
5153            "expected clamp to 300, got {stored}"
5154        );
5155    }
5156
5157    #[test]
5158    fn virtual_list_empty_count_realizes_no_children() {
5159        let mut root =
5160            crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
5161        let mut state = UiState::new();
5162        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5163        assert_eq!(root.children.len(), 0);
5164    }
5165
5166    #[test]
5167    #[should_panic(expected = "row_height > 0.0")]
5168    fn virtual_list_zero_row_height_panics() {
5169        let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
5170    }
5171
5172    #[test]
5173    #[should_panic(expected = "Size::Hug would defeat virtualization")]
5174    fn virtual_list_hug_panics() {
5175        let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
5176            crate::widgets::text::text(format!("r{i}"))
5177        })
5178        .height(Size::Hug)])
5179        .width(Size::Fixed(300.0))
5180        .height(Size::Fixed(200.0));
5181        let mut state = UiState::new();
5182        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5183    }
5184
5185    #[test]
5186    fn grid_packs_rows_and_aligns_the_partial_tail() {
5187        // 3 cells in 2 columns at gap 10 inside 210px: two rows; each
5188        // column is (210 - 10) / 2 = 100 wide. The tail row's single
5189        // real cell must align with column 0 above (filler holds col 1).
5190        let cell = |i: usize| {
5191            crate::tree::column::<Vec<El>, El>(vec![])
5192                .key(format!("cell-{i}"))
5193                .height(Size::Fixed(50.0))
5194        };
5195        let mut root = crate::tree::grid(2, 10.0, (0..3).map(cell))
5196            .width(Size::Fixed(210.0))
5197            .height(Size::Fixed(300.0));
5198        assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
5199        let mut state = UiState::new();
5200        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 210.0, 300.0));
5201
5202        let r0 = state.rect_of_key("cell-0").unwrap();
5203        let r1 = state.rect_of_key("cell-1").unwrap();
5204        let r2 = state.rect_of_key("cell-2").unwrap();
5205        assert_eq!((r0.x, r0.w), (0.0, 100.0));
5206        assert_eq!((r1.x, r1.w), (110.0, 100.0));
5207        // Tail cell: same column geometry as cell-0, next row (50 + 10 gap).
5208        assert_eq!((r2.x, r2.w), (0.0, 100.0));
5209        assert_eq!(r2.y, 60.0);
5210    }
5211
5212    #[test]
5213    fn virtual_grid_realizes_rows_of_packed_cells() {
5214        // 10 items, 3 columns, 50px cells, 120px viewport: rows 0..3
5215        // realize (ceil(120/50)+1 candidates, clamped by visibility).
5216        let mut root = crate::tree::virtual_grid(10, 3, 50.0, 0.0, |i| {
5217            crate::widgets::text::text(format!("item {i}")).key(format!("item-{i}"))
5218        })
5219        .key("vgrid");
5220        let mut state = UiState::new();
5221        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5222
5223        assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
5224        // Realized rows are children of the list; cells are children of
5225        // each packed row. (Keys inside virtual rows resolve through
5226        // hit-test/event routing, not the pre-layout key index, so the
5227        // test reads rects by computed_id.)
5228        let rect_of = |row: usize, col: usize| root.children[row].children[col].computed_rect;
5229        // First realized row holds items 0..3 packed across the width.
5230        let i0 = rect_of(0, 0);
5231        let i2 = rect_of(0, 2);
5232        assert_eq!(i0.w, 100.0);
5233        assert_eq!(i2.x, 200.0);
5234        // Second row starts the next item triplet.
5235        let i3 = rect_of(1, 0);
5236        assert_eq!((i3.x, i3.y), (0.0, 50.0));
5237        // Realized row range is queryable for eviction logic.
5238        assert!(state.visible_range("vgrid").is_some());
5239        // Identity check: the first realized cell is item-0.
5240        assert_eq!(root.children[0].children[0].key.as_deref(), Some("item-0"));
5241    }
5242
5243    #[test]
5244    fn visible_range_tracks_realized_rows() {
5245        // Same scenario as `virtual_list_realizes_only_visible_rows`:
5246        // 100 rows x 50px in a 200px viewport at offset 120 realizes
5247        // rows 2..7. The keyed query must report exactly that range.
5248        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
5249            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5250        })
5251        .key("list");
5252        let mut state = UiState::new();
5253        assign_ids(&mut root);
5254        state
5255            .scroll
5256            .offsets
5257            .insert(root.computed_id.clone().to_string(), 120.0);
5258        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5259
5260        assert_eq!(state.visible_range("list"), Some(2..7));
5261        assert_eq!(state.visible_range("not-a-list"), None);
5262
5263        // Dynamic variant: four rows realize at offset 0 (see
5264        // `virtual_list_dyn_respects_per_row_fixed_heights`).
5265        let mut dyn_root = crate::tree::virtual_list_dyn(
5266            20,
5267            50.0,
5268            |i| format!("row-{i}"),
5269            |i| {
5270                let h = if i % 2 == 0 { 40.0 } else { 80.0 };
5271                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5272                    .key(format!("row-{i}"))
5273                    .height(Size::Fixed(h))
5274            },
5275        )
5276        .key("dyn-list");
5277        let mut dyn_state = UiState::new();
5278        layout(
5279            &mut dyn_root,
5280            &mut dyn_state,
5281            Rect::new(0.0, 0.0, 300.0, 200.0),
5282        );
5283        assert_eq!(dyn_state.visible_range("dyn-list"), Some(0..4));
5284
5285        // Per-frame scratch: an empty re-layout clears stale ranges.
5286        let mut empty =
5287            crate::tree::virtual_list(0, 50.0, |_| crate::widgets::text::text("never")).key("list");
5288        layout(&mut empty, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5289        assert_eq!(state.visible_range("list"), None);
5290    }
5291
5292    #[test]
5293    fn virtual_list_dyn_respects_per_row_fixed_heights() {
5294        // Alternating 40px / 80px rows. With a 200px viewport and offset 0,
5295        // accumulated y goes 0, 40, 120, 160, 240 — the fifth row starts
5296        // past the viewport, so four rows are realized.
5297        let mut root = crate::tree::virtual_list_dyn(
5298            20,
5299            50.0,
5300            |i| format!("row-{i}"),
5301            |i| {
5302                let h = if i % 2 == 0 { 40.0 } else { 80.0 };
5303                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5304                    .key(format!("row-{i}"))
5305                    .height(Size::Fixed(h))
5306            },
5307        );
5308        let mut state = UiState::new();
5309        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5310
5311        assert_eq!(
5312            root.children.len(),
5313            4,
5314            "expected 4 realized rows, got {}",
5315            root.children.len()
5316        );
5317        // y positions: row 0 → 0, row 1 → 40, row 2 → 120, row 3 → 160.
5318        let ys: Vec<f32> = root.children.iter().map(|c| c.computed_rect.y).collect();
5319        assert!(
5320            (ys[0] - 0.0).abs() < 0.5,
5321            "row 0 expected y≈0, got {}",
5322            ys[0]
5323        );
5324        assert!(
5325            (ys[1] - 40.0).abs() < 0.5,
5326            "row 1 expected y≈40, got {}",
5327            ys[1]
5328        );
5329        assert!(
5330            (ys[2] - 120.0).abs() < 0.5,
5331            "row 2 expected y≈120, got {}",
5332            ys[2]
5333        );
5334        assert!(
5335            (ys[3] - 160.0).abs() < 0.5,
5336            "row 3 expected y≈160, got {}",
5337            ys[3]
5338        );
5339    }
5340
5341    #[test]
5342    fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
5343        let mut root = crate::tree::virtual_list_dyn(
5344            10,
5345            40.0,
5346            |i| format!("row-{i}"),
5347            |i| {
5348                crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
5349                    .key(format!("row-{i}"))
5350                    .height(Size::Fixed(40.0))
5351            },
5352        )
5353        .gap(10.0);
5354        let mut state = UiState::new();
5355        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5356
5357        assert_eq!(
5358            root.children.len(),
5359            3,
5360            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5361        );
5362        let row_1 = root
5363            .children
5364            .iter()
5365            .find(|c| c.key.as_deref() == Some("row-1"))
5366            .expect("row 1 should be realized");
5367        assert!(
5368            (row_1.computed_rect.y - 50.0).abs() < 0.5,
5369            "gap should place row 1 at y=50"
5370        );
5371        let metrics = state
5372            .scroll
5373            .metrics
5374            .get(&*root.computed_id)
5375            .expect("virtual list writes scroll metrics");
5376        assert!(
5377            (metrics.content_h - 490.0).abs() < 0.5,
5378            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5379            metrics.content_h
5380        );
5381    }
5382
5383    #[test]
5384    fn virtual_list_dyn_caches_measured_heights() {
5385        // Build a list where the first frame realizes rows 0..k, measuring
5386        // each. After layout the cache should hold those measurements and
5387        // the next frame should read them.
5388        let mut root = crate::tree::virtual_list_dyn(
5389            50,
5390            50.0,
5391            |i| format!("row-{i}"),
5392            |i| {
5393                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5394                    .key(format!("row-{i}"))
5395                    .height(Size::Fixed(30.0))
5396            },
5397        );
5398        let mut state = UiState::new();
5399        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5400
5401        let measured = state
5402            .scroll
5403            .measured_row_heights
5404            .get(&*root.computed_id)
5405            .expect("dynamic virtual list should populate the height cache");
5406        // The first pass measures the estimate-derived window, then
5407        // the anchored final pass can extend it with newly revealed
5408        // rows. At least six rows are visible/cached here.
5409        assert!(
5410            measured.len() >= 6,
5411            "expected ≥ 6 cached row heights, got {}",
5412            measured.len()
5413        );
5414        for by_width in measured.values() {
5415            let h = by_width
5416                .get(&300)
5417                .copied()
5418                .expect("measurement should be keyed at the 300px width bucket");
5419            assert!(
5420                (h - 30.0).abs() < 0.5,
5421                "expected cached height ≈ 30, got {h}"
5422            );
5423        }
5424    }
5425
5426    #[test]
5427    fn virtual_list_dyn_realized_rows_place_at_their_measured_heights() {
5428        // Successor to the #59 cache-seeding regression test (the
5429        // per-pass intrinsic cache is gone — `size_tree` sizes each
5430        // realized row subtree once at the list width). The surviving
5431        // guarantee: the height the measure realization stored and the
5432        // rect the layout realization placed agree exactly, so
5433        // anchoring math and painted rows can't diverge.
5434        let mut root = crate::tree::virtual_list_dyn(
5435            50,
5436            20.0,
5437            |i| format!("row-{i}"),
5438            |i| {
5439                crate::tree::column([crate::widgets::text::text(format!("row body {i}"))])
5440                    .key(format!("row-{i}"))
5441                    .height(Size::Hug)
5442            },
5443        );
5444        let mut state = UiState::new();
5445        let _ = take_intrinsic_cache_stats();
5446        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5447        assert!(!root.children.is_empty(), "test requires realized rows");
5448        let stored = state
5449            .scroll
5450            .measured_row_heights
5451            .get(&*root.computed_id)
5452            .expect("dynamic list stores per-row heights");
5453        let bucket = virtual_width_bucket(300.0);
5454        for row in &root.children {
5455            let key = row.key.as_deref().expect("rows are keyed");
5456            let h = stored
5457                .get(key)
5458                .and_then(|by_width| by_width.get(&bucket))
5459                .copied()
5460                .expect("realized row height stored at the current width bucket");
5461            assert!(
5462                (row.computed_rect.h - h).abs() < 0.01,
5463                "row {key} placed at h={} but measured h={h}",
5464                row.computed_rect.h,
5465            );
5466        }
5467        // And the sizing pass actually ran (visits reported through the
5468        // legacy stats shape: hits pinned to 0, misses = node visits).
5469        let stats = take_intrinsic_cache_stats();
5470        assert_eq!(stats.hits, 0);
5471        assert!(stats.misses > 0, "sizing visits should be reported");
5472    }
5473
5474    #[test]
5475    fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
5476        let make_root = || {
5477            crate::tree::virtual_list_dyn(
5478                100,
5479                40.0,
5480                |i| format!("row-{i}"),
5481                |i| {
5482                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5483                        .key(format!("row-{i}"))
5484                        .height(Size::Fixed(40.0))
5485                },
5486            )
5487        };
5488        let mut root = make_root();
5489        let mut state = UiState::new();
5490        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5491
5492        state
5493            .scroll
5494            .offsets
5495            .insert(root.computed_id.clone().to_string(), 400.0);
5496        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5497
5498        let anchor = state
5499            .scroll
5500            .virtual_anchors
5501            .get(&*root.computed_id)
5502            .cloned()
5503            .expect("dynamic list should store a visible anchor");
5504        let before_y = root
5505            .children
5506            .iter()
5507            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5508            .map(|child| child.computed_rect.y)
5509            .expect("anchor row should be realized");
5510        let before_offset = state.scroll_offset(&root.computed_id);
5511
5512        state
5513            .scroll
5514            .measured_row_heights
5515            .entry(root.computed_id.clone().to_string())
5516            .or_default()
5517            .entry("row-0".to_string())
5518            .or_default()
5519            .insert(300, 120.0);
5520
5521        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5522        let after_y = root
5523            .children
5524            .iter()
5525            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5526            .map(|child| child.computed_rect.y)
5527            .expect("anchor row should remain realized");
5528        let after_offset = state.scroll_offset(&root.computed_id);
5529
5530        assert!(
5531            (after_y - before_y).abs() < 0.5,
5532            "anchor row should stay at y={before_y}, got {after_y}"
5533        );
5534        assert!(
5535            (after_offset - (before_offset + 80.0)).abs() < 0.5,
5536            "offset should absorb the 80px measurement delta above anchor"
5537        );
5538    }
5539
5540    #[test]
5541    fn virtual_list_dyn_height_cache_is_width_bucketed() {
5542        let mut root = crate::tree::virtual_list_dyn(
5543            20,
5544            50.0,
5545            |i| format!("row-{i}"),
5546            |i| {
5547                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5548                    .key(format!("row-{i}"))
5549                    .height(Size::Fixed(30.0))
5550            },
5551        );
5552        let mut state = UiState::new();
5553        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5554        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
5555
5556        let row_0 = state
5557            .scroll
5558            .measured_row_heights
5559            .get(&*root.computed_id)
5560            .and_then(|m| m.get("row-0"))
5561            .expect("row 0 should be measured");
5562        assert!(
5563            row_0.contains_key(&300) && row_0.contains_key(&240),
5564            "expected width buckets 300 and 240, got {:?}",
5565            row_0.keys().collect::<Vec<_>>()
5566        );
5567    }
5568
5569    #[test]
5570    fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
5571        // Measured rows use their cached fixed 30px height; rows that
5572        // have not been seen at this width still use the 50px estimate.
5573        // An overshoot offset must clamp to the mixed measured/estimated
5574        // content height after the final visible measurements land.
5575        let make_root = || {
5576            crate::tree::virtual_list_dyn(
5577                20,
5578                50.0,
5579                |i| format!("row-{i}"),
5580                |i| {
5581                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5582                        .key(format!("row-{i}"))
5583                        .height(Size::Fixed(30.0))
5584                },
5585            )
5586        };
5587        let mut state = UiState::new();
5588        let mut root = make_root();
5589        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5590
5591        state
5592            .scroll
5593            .offsets
5594            .insert(root.computed_id.clone().to_string(), 9999.0);
5595        let mut root2 = make_root();
5596        layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5597
5598        let measured = state
5599            .scroll
5600            .measured_row_heights
5601            .get(&*root2.computed_id)
5602            .expect("dynamic virtual list should populate the height cache");
5603        let measured_sum = measured
5604            .values()
5605            .filter_map(|by_width| by_width.get(&300))
5606            .sum::<f32>();
5607        let measured_count = measured
5608            .values()
5609            .filter(|by_width| by_width.contains_key(&300))
5610            .count();
5611        let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
5612        let expected_max_offset = expected_total - 200.0;
5613
5614        let stored = state
5615            .scroll
5616            .offsets
5617            .get(&*root2.computed_id)
5618            .copied()
5619            .unwrap_or(0.0);
5620        assert!(
5621            (stored - expected_max_offset).abs() < 0.5,
5622            "expected offset clamped to {expected_max_offset}, got {stored}"
5623        );
5624    }
5625
5626    // ---- append_only parity ---------------------------------------
5627    //
5628    // The append-only incremental path must produce byte-identical
5629    // geometry to the general dynamic path for any trim-then-append
5630    // frame sequence. These tests drive both variants through the same
5631    // script on independent `UiState`s and compare realized rows, stored
5632    // offsets, and visible ranges every frame (issue #107).
5633
5634    /// A chat-shaped row whose height depends only on the *stable*
5635    /// message id (`first + i`), so a given message keeps its height as
5636    /// it shifts indices under head-trim. Heights are integer-valued so
5637    /// both paths' prefix sums are bit-identical regardless of summation
5638    /// order.
5639    fn parity_list(first: usize, count: usize, append_only: bool, pin: bool) -> El {
5640        let mut el = crate::tree::virtual_list_dyn(
5641            count,
5642            20.0,
5643            move |i| format!("m{}", first + i),
5644            move |i| {
5645                let id = first + i;
5646                let h = 30.0 + (id % 7) as f32 * 10.0;
5647                crate::tree::column([crate::widgets::text::text(format!("m{id}"))])
5648                    .key(format!("m{id}"))
5649                    .height(Size::Fixed(h))
5650            },
5651        )
5652        .key("chat")
5653        .gap(4.0);
5654        if pin {
5655            el = el.pin_end();
5656        }
5657        if append_only {
5658            el = el.append_only();
5659        }
5660        el
5661    }
5662
5663    /// Realized rows as `(key, rect)`, in render order.
5664    fn parity_rows(root: &El) -> Vec<(String, Rect)> {
5665        root.children
5666            .iter()
5667            .map(|c| (c.key.clone().unwrap_or_default(), c.computed_rect))
5668            .collect()
5669    }
5670
5671    struct Frame {
5672        first: usize,
5673        count: usize,
5674        seed_offset: Option<f32>,
5675        request: Option<ScrollRequest>,
5676    }
5677
5678    fn run_parity(frames: &[Frame], pin: bool) {
5679        let mut sg = UiState::new(); // general path
5680        let mut si = UiState::new(); // incremental (append_only) path
5681
5682        for (n, f) in frames.iter().enumerate() {
5683            let viewport = Rect::new(0.0, 0.0, 300.0, 200.0);
5684            let step = |state: &mut UiState, append_only: bool| {
5685                let mut root = parity_list(f.first, f.count, append_only, pin);
5686                assign_ids(&mut root);
5687                if let Some(o) = f.seed_offset {
5688                    state
5689                        .scroll
5690                        .offsets
5691                        .insert(root.computed_id.clone().to_string(), o);
5692                }
5693                if let Some(req) = &f.request {
5694                    state.push_scroll_requests(vec![req.clone()]);
5695                }
5696                layout(&mut root, state, viewport);
5697                root
5698            };
5699
5700            let root_g = step(&mut sg, false);
5701            let root_i = step(&mut si, true);
5702
5703            let rows_g = parity_rows(&root_g);
5704            let rows_i = parity_rows(&root_i);
5705            assert_eq!(
5706                rows_g.len(),
5707                rows_i.len(),
5708                "frame {n}: realized row count differs (general {} vs incremental {})",
5709                rows_g.len(),
5710                rows_i.len()
5711            );
5712            for ((kg, rg), (ki, ri)) in rows_g.iter().zip(&rows_i) {
5713                assert_eq!(kg, ki, "frame {n}: realized key order differs");
5714                assert!(
5715                    (rg.x - ri.x).abs() < 1e-2
5716                        && (rg.y - ri.y).abs() < 1e-2
5717                        && (rg.w - ri.w).abs() < 1e-2
5718                        && (rg.h - ri.h).abs() < 1e-2,
5719                    "frame {n}: rect for {kg} differs: general {rg:?} vs incremental {ri:?}"
5720                );
5721            }
5722
5723            let off_g = sg.scroll.offsets.get(&*root_g.computed_id).copied();
5724            let off_i = si.scroll.offsets.get(&*root_i.computed_id).copied();
5725            assert!(
5726                (off_g.unwrap_or(0.0) - off_i.unwrap_or(0.0)).abs() < 1e-2,
5727                "frame {n}: stored offset differs: general {off_g:?} vs incremental {off_i:?}"
5728            );
5729            assert_eq!(
5730                sg.visible_range("chat"),
5731                si.visible_range("chat"),
5732                "frame {n}: visible range differs"
5733            );
5734        }
5735    }
5736
5737    #[test]
5738    fn append_only_matches_general_top_append_scroll_trim() {
5739        run_parity(
5740            &[
5741                // Start at the top.
5742                Frame {
5743                    first: 0,
5744                    count: 30,
5745                    seed_offset: Some(0.0),
5746                    request: None,
5747                },
5748                // Append 12 rows at the tail.
5749                Frame {
5750                    first: 0,
5751                    count: 42,
5752                    seed_offset: None,
5753                    request: None,
5754                },
5755                // Scroll to the middle.
5756                Frame {
5757                    first: 0,
5758                    count: 42,
5759                    seed_offset: Some(400.0),
5760                    request: None,
5761                },
5762                // Trim 8 off the head, append 16 at the tail.
5763                Frame {
5764                    first: 8,
5765                    count: 50,
5766                    seed_offset: None,
5767                    request: None,
5768                },
5769                // Append more while scrolled mid-list (anchor preservation).
5770                Frame {
5771                    first: 8,
5772                    count: 62,
5773                    seed_offset: None,
5774                    request: None,
5775                },
5776                // Trim again.
5777                Frame {
5778                    first: 20,
5779                    count: 62,
5780                    seed_offset: None,
5781                    request: None,
5782                },
5783            ],
5784            false,
5785        );
5786    }
5787
5788    #[test]
5789    fn append_only_matches_general_to_row_key_request() {
5790        run_parity(
5791            &[
5792                Frame {
5793                    first: 0,
5794                    count: 40,
5795                    seed_offset: Some(0.0),
5796                    request: None,
5797                },
5798                // Programmatic jump to a specific message by key.
5799                Frame {
5800                    first: 0,
5801                    count: 40,
5802                    seed_offset: None,
5803                    request: Some(ScrollRequest::ToRowKey {
5804                        list_key: "chat".into(),
5805                        row_key: "m30".into(),
5806                        align: ScrollAlignment::Start,
5807                    }),
5808                },
5809                // Trim past the anchored row, then append.
5810                Frame {
5811                    first: 12,
5812                    count: 55,
5813                    seed_offset: None,
5814                    request: None,
5815                },
5816            ],
5817            false,
5818        );
5819    }
5820
5821    /// A horizontal resize changes the width bucket, which the index
5822    /// can't reconcile — it must cold-rebuild for the new bucket and
5823    /// still match the general path (which re-reads the new bucket's
5824    /// measurements). Drives both variants through width changes
5825    /// interleaved with append/trim.
5826    #[test]
5827    fn append_only_matches_general_across_width_change() {
5828        let mut sg = UiState::new();
5829        let mut si = UiState::new();
5830        // (first, count, viewport_w)
5831        let script = [
5832            (0usize, 30usize, 300.0f32),
5833            (0, 40, 300.0),
5834            (0, 40, 240.0), // resize → new width bucket
5835            (6, 52, 240.0), // append + trim at the new width
5836            (6, 52, 360.0), // resize wider
5837        ];
5838        for (n, &(first, count, w)) in script.iter().enumerate() {
5839            let viewport = Rect::new(0.0, 0.0, w, 200.0);
5840            let step = |state: &mut UiState, append_only: bool| {
5841                let mut root = parity_list(first, count, append_only, false);
5842                assign_ids(&mut root);
5843                layout(&mut root, state, viewport);
5844                root
5845            };
5846            let rg = step(&mut sg, false);
5847            let ri = step(&mut si, true);
5848            let rows_g = parity_rows(&rg);
5849            let rows_i = parity_rows(&ri);
5850            assert_eq!(rows_g.len(), rows_i.len(), "frame {n}: row count differs");
5851            for ((kg, a), (ki, b)) in rows_g.iter().zip(&rows_i) {
5852                assert_eq!(kg, ki, "frame {n}: key order differs");
5853                assert!(
5854                    (a.x - b.x).abs() < 1e-2
5855                        && (a.y - b.y).abs() < 1e-2
5856                        && (a.w - b.w).abs() < 1e-2
5857                        && (a.h - b.h).abs() < 1e-2,
5858                    "frame {n}: rect for {kg} differs: {a:?} vs {b:?}"
5859                );
5860            }
5861            assert_eq!(
5862                sg.visible_range("chat"),
5863                si.visible_range("chat"),
5864                "frame {n}: visible range differs"
5865            );
5866        }
5867    }
5868
5869    #[test]
5870    fn append_only_matches_general_pin_end_stick_to_bottom() {
5871        run_parity(
5872            &[
5873                Frame {
5874                    first: 0,
5875                    count: 20,
5876                    seed_offset: None,
5877                    request: None,
5878                },
5879                // Tail grows: a pinned list must stay at the bottom.
5880                Frame {
5881                    first: 0,
5882                    count: 35,
5883                    seed_offset: None,
5884                    request: None,
5885                },
5886                // Ring-buffer: trim head while appending tail, still pinned.
5887                Frame {
5888                    first: 10,
5889                    count: 50,
5890                    seed_offset: None,
5891                    request: None,
5892                },
5893                Frame {
5894                    first: 25,
5895                    count: 70,
5896                    seed_offset: None,
5897                    request: None,
5898                },
5899            ],
5900            true,
5901        );
5902    }
5903
5904    #[test]
5905    fn virtual_list_dyn_empty_count_realizes_no_children() {
5906        let mut root = crate::tree::virtual_list_dyn(
5907            0,
5908            50.0,
5909            |i| format!("row-{i}"),
5910            |i| crate::widgets::text::text(format!("r{i}")),
5911        );
5912        let mut state = UiState::new();
5913        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5914        assert_eq!(root.children.len(), 0);
5915    }
5916
5917    #[test]
5918    #[should_panic(expected = "estimated_row_height > 0.0")]
5919    fn virtual_list_dyn_zero_estimate_panics() {
5920        let _ = crate::tree::virtual_list_dyn(
5921            10,
5922            0.0,
5923            |i| format!("row-{i}"),
5924            |i| crate::widgets::text::text(format!("r{i}")),
5925        );
5926    }
5927
5928    #[test]
5929    fn text_runs_constructor_shape_smoke() {
5930        let el = crate::tree::text_runs([
5931            crate::widgets::text::text("Hello, "),
5932            crate::widgets::text::text("world").bold(),
5933            crate::tree::hard_break(),
5934            crate::widgets::text::text("of text").italic(),
5935        ]);
5936        assert_eq!(el.kind, Kind::Inlines);
5937        assert_eq!(el.children.len(), 4);
5938        assert!(matches!(
5939            el.children[1].font_weight,
5940            FontWeight::Bold | FontWeight::Semibold
5941        ));
5942        assert_eq!(el.children[2].kind, Kind::HardBreak);
5943        assert!(el.children[3].text_italic);
5944    }
5945
5946    #[test]
5947    fn wrapped_text_hugs_multiline_height_from_available_width() {
5948        let mut root = column([crate::paragraph(
5949            "A longer sentence should wrap into multiple measured lines.",
5950        )])
5951        .width(Size::Fill(1.0))
5952        .height(Size::Hug);
5953
5954        let mut state = UiState::new();
5955        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
5956
5957        let child_rect = root.children[0].computed_rect;
5958        assert_eq!(child_rect.w, 180.0);
5959        assert!(
5960            child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
5961            "expected multiline paragraph height, got {}",
5962            child_rect.h
5963        );
5964    }
5965
5966    #[test]
5967    fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
5968        // Regression: overlay_rect used to call `intrinsic(c)` with no
5969        // width hint, so a Fixed-width modal containing a wrappable
5970        // paragraph measured the paragraph as a single line — leaving
5971        // the modal's Hug height short by the wrapped lines and
5972        // crowding the buttons against the bottom edge of the panel
5973        // (rumble cert-pending modal showed this).
5974        //
5975        // The fix: pass the child's resolved width as the available
5976        // width for intrinsic measurement, mirroring what column/row
5977        // already do.
5978        const PANEL_W: f32 = 240.0;
5979        const PADDING: f32 = 18.0;
5980        const GAP: f32 = 12.0;
5981
5982        let panel = column([
5983            crate::paragraph(
5984                "A long enough warning paragraph that it has to wrap onto a second line \
5985                 inside this narrow panel.",
5986            ),
5987            crate::widgets::button::button("OK").key("ok"),
5988        ])
5989        .width(Size::Fixed(PANEL_W))
5990        .height(Size::Hug)
5991        .padding(Sides::all(PADDING))
5992        .gap(GAP)
5993        .align(Align::Stretch);
5994
5995        let mut root = crate::stack([panel])
5996            .width(Size::Fill(1.0))
5997            .height(Size::Fill(1.0));
5998        let mut state = UiState::new();
5999        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6000
6001        let panel_rect = root.children[0].computed_rect;
6002        assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
6003
6004        let para_rect = root.children[0].children[0].computed_rect;
6005        let button_rect = root.children[0].children[1].computed_rect;
6006
6007        // Paragraph wrapped to ≥ 2 lines (exact line count depends on
6008        // glyph metrics; just guard against the single-line bug).
6009        assert!(
6010            para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
6011            "paragraph should wrap to multiple lines inside the Fixed-width panel; \
6012             got h={}",
6013            para_rect.h
6014        );
6015
6016        // Panel height must accommodate top padding + paragraph +
6017        // gap + button + bottom padding. The bug was that the panel
6018        // came out exactly `padding + gap + 1-line-paragraph + button`
6019        // — short by the second wrap line — and the button overshot
6020        // the inner area, leaving zero pixels of bottom padding.
6021        let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
6022        assert!(
6023            (bottom_padding - PADDING).abs() < 0.5,
6024            "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
6025        );
6026    }
6027
6028    #[test]
6029    fn row_with_fill_paragraph_propagates_height_to_parent_column() {
6030        // Regression: the Row branch of `intrinsic_constrained` called
6031        // `intrinsic(ch)` unconstrained, so a wrappable Fill child
6032        // (paragraph) measured as a single unwrapped line. Two such rows
6033        // in a column then got one-line-tall allocations and the second
6034        // row's gutter rect overlapped the first row's wrapped text
6035        // (chat-port event-log recipe in damascene-core/README.md hit this).
6036        //
6037        // The fix mirrors `layout_axis`: the Row intrinsic distributes
6038        // its available width across Fill children before measuring,
6039        // so wrappable Fill children see the width they will actually
6040        // be laid out at.
6041        const COL_W: f32 = 600.0;
6042        const GUTTER_W: f32 = 3.0;
6043
6044        let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
6045                    sed do eiusmod tempor incididunt ut labore et dolore magna \
6046                    aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
6047                    ullamco laboris nisi ut aliquip ex ea commodo consequat.";
6048
6049        let make_row = || {
6050            let gutter = El::new(Kind::Custom("gutter"))
6051                .width(Size::Fixed(GUTTER_W))
6052                .height(Size::Fill(1.0));
6053            let body = crate::paragraph(long).width(Size::Fill(1.0));
6054            crate::row([gutter, body]).width(Size::Fill(1.0))
6055        };
6056
6057        let mut root = column([make_row(), make_row()])
6058            .width(Size::Fixed(COL_W))
6059            .height(Size::Hug)
6060            .align(Align::Stretch);
6061        let mut state = UiState::new();
6062        layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
6063
6064        let row0_rect = root.children[0].computed_rect;
6065        let row1_rect = root.children[1].computed_rect;
6066        let para0_rect = root.children[0].children[1].computed_rect;
6067
6068        // Both the paragraph rect and the row rect must reflect the
6069        // wrapped (multi-line) height. The bug pinned them to a single
6070        // line (~`TEXT_SM.line_height` = 20px), so the wrapped text
6071        // painted outside the row's allocated rect.
6072        let line_height = crate::tokens::TEXT_SM.line_height;
6073        assert!(
6074            para0_rect.h > line_height * 1.5,
6075            "paragraph should wrap to multiple lines at ~597px wide; \
6076             got h={} (line_height={})",
6077            para0_rect.h,
6078            line_height,
6079        );
6080        assert!(
6081            row0_rect.h > line_height * 1.5,
6082            "row 0 should accommodate the wrapped paragraph height; \
6083             got h={} (line_height={})",
6084            row0_rect.h,
6085            line_height,
6086        );
6087
6088        // Sanity: row 1 sits below row 0's allocated rect, not above it.
6089        assert!(
6090            row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
6091            "row 1 starts at y={} but row 0 occupies y={}..{}",
6092            row1_rect.y,
6093            row0_rect.y,
6094            row0_rect.y + row0_rect.h,
6095        );
6096    }
6097
6098    /// `min_width` floors a child whose resolved cross-axis size is
6099    /// below the floor. Tests against an `align(Start)` column so
6100    /// `Size::Fixed` doesn't get widened by the default Stretch
6101    /// alignment before clamping has a chance to apply.
6102    #[test]
6103    fn min_width_floors_resolved_cross_axis_size() {
6104        let mut root = column([crate::widgets::text::text("hi")
6105            .width(Size::Fixed(40.0))
6106            .height(Size::Fixed(20.0))
6107            .min_width(120.0)])
6108        .align(Align::Start)
6109        .width(Size::Fixed(500.0))
6110        .height(Size::Fixed(200.0));
6111        let mut state = UiState::new();
6112        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6113        let child_rect = root.children[0].computed_rect;
6114        assert!(
6115            (child_rect.w - 120.0).abs() < 0.5,
6116            "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
6117            child_rect.w,
6118        );
6119    }
6120
6121    /// `max_width` caps a `Size::Fill` child even when the surrounding
6122    /// row offers more space.
6123    #[test]
6124    fn max_width_caps_fill_child() {
6125        let mut root = crate::row([crate::widgets::text::text("body")
6126            .width(Size::Fill(1.0))
6127            .height(Size::Fixed(20.0))
6128            .max_width(160.0)])
6129        .width(Size::Fixed(800.0))
6130        .height(Size::Fixed(40.0));
6131        let mut state = UiState::new();
6132        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
6133        let child_rect = root.children[0].computed_rect;
6134        assert!(
6135            (child_rect.w - 160.0).abs() < 0.5,
6136            "expected Fill child capped at 160, got w={}",
6137            child_rect.w,
6138        );
6139    }
6140
6141    /// `Size::Ch(n)` (the CSS `ch` unit) reserves a fixed width of `n` digit
6142    /// slots independent of the value's text, and scales linearly with `n` —
6143    /// so a live metric anchored in a `ch` field never jitters or reflows.
6144    #[test]
6145    fn ch_unit_reserves_constant_digit_width() {
6146        use crate::widgets::text::text;
6147        let mut root = column([
6148            text("8")
6149                .tabular_numerals()
6150                .width(Size::Ch(4.0))
6151                .height(Size::Fixed(20.0)),
6152            text("123456")
6153                .tabular_numerals()
6154                .width(Size::Ch(4.0))
6155                .height(Size::Fixed(20.0)),
6156            text("8")
6157                .tabular_numerals()
6158                .width(Size::Ch(2.0))
6159                .height(Size::Fixed(20.0)),
6160        ]);
6161        let mut state = UiState::new();
6162        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6163        let four_a = root.children[0].computed_rect.w;
6164        let four_b = root.children[1].computed_rect.w;
6165        let two = root.children[2].computed_rect.w;
6166        assert!(four_a > 0.0);
6167        // Same ch count → same width regardless of the value's length.
6168        assert!(
6169            (four_a - four_b).abs() < 0.01,
6170            "Ch(4) width must not depend on the text: {four_a} vs {four_b}"
6171        );
6172        // Width scales with the digit count: Ch(4) == 2 × Ch(2).
6173        assert!(
6174            (four_a - 2.0 * two).abs() < 0.5,
6175            "Ch(4) should be twice Ch(2): {four_a} vs 2×{two}"
6176        );
6177    }
6178
6179    /// When `min_width` and `max_width` conflict, the lower bound wins
6180    /// (CSS `min-width` precedence over `max-width`).
6181    #[test]
6182    fn min_width_wins_over_max_width_when_conflicting() {
6183        let mut root = column([crate::widgets::text::text("x")
6184            .width(Size::Fixed(50.0))
6185            .height(Size::Fixed(20.0))
6186            .max_width(80.0)
6187            .min_width(120.0)]);
6188        let mut state = UiState::new();
6189        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6190        let child_rect = root.children[0].computed_rect;
6191        assert!(
6192            (child_rect.w - 120.0).abs() < 0.5,
6193            "expected min_width (120) to win over max_width (80), got w={}",
6194            child_rect.w,
6195        );
6196    }
6197
6198    /// `min_height` floors a Hug child column whose children sum to
6199    /// less than the floor. Tested through a fixed-size parent so the
6200    /// resolved rect of the inner column reflects the clamp.
6201    #[test]
6202    fn min_height_floors_hug_column_inside_fixed_parent() {
6203        let inner = column([crate::widgets::text::text("a")
6204            .width(Size::Fixed(40.0))
6205            .height(Size::Fixed(20.0))])
6206        .width(Size::Fixed(80.0))
6207        .height(Size::Hug)
6208        .min_height(200.0);
6209        let mut root = column([inner])
6210            .align(Align::Start)
6211            .width(Size::Fixed(800.0))
6212            .height(Size::Fixed(600.0));
6213        let mut state = UiState::new();
6214        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6215        let inner_rect = root.children[0].computed_rect;
6216        assert!(
6217            (inner_rect.h - 200.0).abs() < 0.5,
6218            "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
6219            inner_rect.h,
6220        );
6221    }
6222
6223    /// Row laying out a `Fill` Hug-column with a wrap-text child must
6224    /// measure the column's height at the column's allocated width, not
6225    /// unconstrained. Repro for the lint regression that fires on a
6226    /// `row([column([wrap_text(...).fill_width()]).fill_width(), fixed])`
6227    /// shape: without the constrained measurement, the column reports
6228    /// its single-line unwrapped height to the row, the row sizes the
6229    /// column rect at that height, and the wrapped text then overflows
6230    /// the column vertically (Overflow `B=N` finding).
6231    #[test]
6232    fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
6233        // 200px-wide row. The fixed child takes 40; the Fill column gets
6234        // 200 - 40 - 12 (gap) = 148. The paragraph wraps at 148px to two
6235        // lines; the column's intrinsic height should reflect that.
6236        let mut root = crate::row([
6237            column([crate::widgets::text::paragraph(
6238                "A long enough description that must wrap to two lines at 148px",
6239            )])
6240            .width(Size::Fill(1.0)),
6241            crate::widgets::text::text("ok")
6242                .width(Size::Fixed(40.0))
6243                .height(Size::Fixed(20.0)),
6244        ])
6245        .gap(12.0)
6246        .align(Align::Center)
6247        .width(Size::Fixed(200.0));
6248        let mut state = UiState::new();
6249        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
6250        // Find the column child (root.children[0]) and its paragraph leaf.
6251        let col_rect = root.children[0].computed_rect;
6252        let para_rect = root.children[0].children[0].computed_rect;
6253        assert!(
6254            (col_rect.h - para_rect.h).abs() < 0.5,
6255            "column height ({}) should track its wrapped child's height ({})",
6256            col_rect.h,
6257            para_rect.h,
6258        );
6259    }
6260
6261    /// `Size::Aspect` on the main axis (height inside a Column) derives
6262    /// from the resolved cross size. Width fills its column's 200px;
6263    /// height should be 200 * 0.5 = 100.
6264    #[test]
6265    fn aspect_on_column_main_axis_derives_from_cross() {
6266        let mut root = column([El::new(Kind::Group)
6267            .width(Size::Fill(1.0))
6268            .height(Size::Aspect(0.5))])
6269        .width(Size::Fixed(200.0))
6270        .height(Size::Fixed(400.0));
6271        let mut state = UiState::new();
6272        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
6273        let r = root.children[0].computed_rect;
6274        assert!(
6275            (r.w - 200.0).abs() < 0.5,
6276            "expected w≈200 (Fill), got {}",
6277            r.w,
6278        );
6279        assert!(
6280            (r.h - 100.0).abs() < 0.5,
6281            "expected h≈100 (Aspect 0.5 of 200), got {}",
6282            r.h,
6283        );
6284    }
6285
6286    /// Surrounding layout flows around an Aspect-sized image: a Hug
6287    /// column containing an Aspect-height El + a fixed-height sibling
6288    /// must have an outer height equal to derived height + sibling.
6289    #[test]
6290    fn aspect_height_pushes_siblings_in_column() {
6291        let mut root = column([
6292            El::new(Kind::Group)
6293                .width(Size::Fill(1.0))
6294                .height(Size::Aspect(0.25)),
6295            crate::widgets::text::text("caption")
6296                .width(Size::Fixed(40.0))
6297                .height(Size::Fixed(20.0)),
6298        ])
6299        .width(Size::Fixed(400.0))
6300        .height(Size::Fixed(500.0));
6301        let mut state = UiState::new();
6302        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
6303        let img = root.children[0].computed_rect;
6304        let cap = root.children[1].computed_rect;
6305        assert!(
6306            (img.h - 100.0).abs() < 0.5,
6307            "expected aspect-derived height ≈100, got {}",
6308            img.h,
6309        );
6310        assert!(
6311            (cap.y - 100.0).abs() < 0.5,
6312            "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
6313            cap.y,
6314        );
6315    }
6316
6317    /// `Size::Aspect` on the cross axis (width inside a Row) derives
6318    /// from the resolved main (height). Height fills 200; width should
6319    /// be 200 * 2.0 = 400.
6320    #[test]
6321    fn aspect_on_row_cross_axis_derives_from_main() {
6322        let mut root = crate::row([El::new(Kind::Group)
6323            .height(Size::Fill(1.0))
6324            .width(Size::Aspect(2.0))])
6325        .width(Size::Fixed(800.0))
6326        .height(Size::Fixed(200.0));
6327        let mut state = UiState::new();
6328        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
6329        let r = root.children[0].computed_rect;
6330        assert!(
6331            (r.h - 200.0).abs() < 0.5,
6332            "expected h≈200 (Fill), got {}",
6333            r.h,
6334        );
6335        assert!(
6336            (r.w - 400.0).abs() < 0.5,
6337            "expected w≈400 (Aspect 2.0 of 200), got {}",
6338            r.w,
6339        );
6340    }
6341
6342    /// Both axes `Aspect` is degenerate — falls back to intrinsic so
6343    /// the El still has a finite measure.
6344    #[test]
6345    fn aspect_on_both_axes_falls_back_to_intrinsic() {
6346        let mut root = column([crate::widgets::text::text("hi")
6347            .width(Size::Aspect(1.0))
6348            .height(Size::Aspect(1.0))])
6349        .width(Size::Fixed(200.0))
6350        .height(Size::Fixed(200.0));
6351        let mut state = UiState::new();
6352        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
6353        let r = root.children[0].computed_rect;
6354        assert!(
6355            r.w > 0.0 && r.h > 0.0,
6356            "expected finite size for both-Aspect fallback, got {}x{}",
6357            r.w,
6358            r.h,
6359        );
6360    }
6361
6362    /// `max_height` and `min_height` cap the Aspect-derived axis, and
6363    /// the hugging parent's intrinsic agrees with the layout-time size
6364    /// (no overflow, no gap).
6365    #[test]
6366    fn aspect_respects_min_and_max_on_derived_axis() {
6367        // Case 1: max_height caps a too-tall derived height.
6368        // Fill(1.0) width inside Fixed(400) → 400 wide; Aspect(1.0) →
6369        // 400 tall; max_height=120 → clamped to 120.
6370        let mut root = column([column([El::new(Kind::Group)
6371            .width(Size::Fill(1.0))
6372            .height(Size::Aspect(1.0))
6373            .max_height(120.0)])
6374        .width(Size::Hug)
6375        .height(Size::Hug)])
6376        .width(Size::Fixed(400.0))
6377        .height(Size::Fixed(600.0));
6378        let mut state = UiState::new();
6379        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6380        let panel = root.children[0].computed_rect;
6381        let img = root.children[0].children[0].computed_rect;
6382        assert!(
6383            (img.h - 120.0).abs() < 0.5,
6384            "max_height should clamp aspect-derived height to 120, got {}",
6385            img.h,
6386        );
6387        assert!(
6388            (panel.h - 120.0).abs() < 0.5,
6389            "hugging panel should match clamped child (120), got {}",
6390            panel.h,
6391        );
6392
6393        // Case 2: min_height pushes a too-short derived height up.
6394        // Aspect(0.1) of 400 = 40; min_height=200 → bumped to 200.
6395        let mut root = column([column([El::new(Kind::Group)
6396            .width(Size::Fill(1.0))
6397            .height(Size::Aspect(0.1))
6398            .min_height(200.0)])
6399        .width(Size::Hug)
6400        .height(Size::Hug)])
6401        .width(Size::Fixed(400.0))
6402        .height(Size::Fixed(600.0));
6403        let mut state = UiState::new();
6404        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6405        let panel = root.children[0].computed_rect;
6406        let img = root.children[0].children[0].computed_rect;
6407        assert!(
6408            (img.h - 200.0).abs() < 0.5,
6409            "min_height should bump aspect-derived height to 200, got {}",
6410            img.h,
6411        );
6412        assert!(
6413            (panel.h - 200.0).abs() < 0.5,
6414            "hugging panel should match bumped child (200), got {}",
6415            panel.h,
6416        );
6417    }
6418
6419    /// `max_width` on the basis axis caps the Fill basis *before* the
6420    /// Aspect-derived axis is computed, matching the layout-time path.
6421    #[test]
6422    fn aspect_basis_is_clamped_before_deriving() {
6423        // Fill width in 400-wide column, but max_width=100 → basis=100.
6424        // Aspect(0.5) → height=50, not 200.
6425        // Align::Stretch (default) so Fill claims the column's cross
6426        // extent; with Align::Start a Fill child would shrink to its
6427        // intrinsic (0 for a bare Group), defeating the test.
6428        let mut root = column([El::new(Kind::Group)
6429            .width(Size::Fill(1.0))
6430            .height(Size::Aspect(0.5))
6431            .max_width(100.0)])
6432        .width(Size::Fixed(400.0))
6433        .height(Size::Fixed(400.0));
6434        let mut state = UiState::new();
6435        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6436        let img = root.children[0].computed_rect;
6437        assert!(
6438            (img.w - 100.0).abs() < 0.5,
6439            "max_width should cap Fill width at 100, got {}",
6440            img.w,
6441        );
6442        assert!(
6443            (img.h - 50.0).abs() < 0.5,
6444            "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
6445            img.h,
6446        );
6447    }
6448
6449    /// Regression: when a `Fill+Aspect` child sits inside a Hug column,
6450    /// the hugging column must size itself to the Aspect-derived height
6451    /// derived against the *parent's* available width, not the El's
6452    /// own natural intrinsic. Otherwise the column hugs too small and
6453    /// the child overflows downward at paint.
6454    #[test]
6455    fn hug_column_around_fill_aspect_child_does_not_overflow() {
6456        // Outer column is Fixed(400, 400) — the available width handed
6457        // down. Middle column hugs (the panel/card surrogate); inner
6458        // image has width=Fill, height=Aspect(0.5). At layout time the
6459        // image should be (400, 200), so the hugging panel must also
6460        // hug to (400, 200) — not to (nat_w, nat_w * 0.5) which would
6461        // be (1, 0.5) for a default-pixel-less El.
6462        let mut root = column([column([El::new(Kind::Group)
6463            .width(Size::Fill(1.0))
6464            .height(Size::Aspect(0.5))])
6465        .width(Size::Hug)
6466        .height(Size::Hug)])
6467        .width(Size::Fixed(400.0))
6468        .height(Size::Fixed(400.0));
6469        let mut state = UiState::new();
6470        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6471        let panel = root.children[0].computed_rect;
6472        let img = root.children[0].children[0].computed_rect;
6473        assert!(
6474            (panel.h - 200.0).abs() < 0.5,
6475            "hugging panel should hug to aspect-derived height 200, got {}",
6476            panel.h,
6477        );
6478        assert!(
6479            (img.h - 200.0).abs() < 0.5,
6480            "image should layout to height 200, got {}",
6481            img.h,
6482        );
6483        assert!(
6484            img.bottom() <= panel.bottom() + 0.5,
6485            "image (bottom={}) must fit within hugging panel (bottom={})",
6486            img.bottom(),
6487            panel.bottom(),
6488        );
6489    }
6490
6491    /// When a parent hugs to its child and the child has `Aspect`, the
6492    /// hugging parent reports a size that matches what the child will
6493    /// actually paint at — the intrinsic post-step ensures consistency.
6494    #[test]
6495    fn hugging_parent_sees_aspect_corrected_intrinsic() {
6496        // Inner El has width=Fixed(80), height=Aspect(0.5) → intrinsic
6497        // height should derive to 40. The hugging column wrapping it
6498        // should size to (80, 40), not (80, 0) or (80, natural).
6499        let mut root = column([column([El::new(Kind::Group)
6500            .width(Size::Fixed(80.0))
6501            .height(Size::Aspect(0.5))])
6502        .width(Size::Hug)
6503        .height(Size::Hug)])
6504        .width(Size::Fixed(400.0))
6505        .height(Size::Fixed(400.0))
6506        .align(Align::Start);
6507        let mut state = UiState::new();
6508        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6509        let hugger = root.children[0].computed_rect;
6510        assert!(
6511            (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
6512            "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
6513            hugger.w,
6514            hugger.h,
6515        );
6516    }
6517
6518    /// `max_height` caps a `Hug` overlay child below its intrinsic.
6519    #[test]
6520    fn max_height_caps_overlay_child_below_intrinsic() {
6521        // Overlay parent sized 600x600; child Hug column whose intrinsic
6522        // height is 300 (single 300-tall fixed leaf), capped at 100.
6523        let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
6524            .width(Size::Fixed(40.0))
6525            .height(Size::Fixed(300.0))])
6526        .width(Size::Hug)
6527        .height(Size::Hug)
6528        .max_height(100.0)])
6529        .width(Size::Fixed(600.0))
6530        .height(Size::Fixed(600.0));
6531        let mut state = UiState::new();
6532        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
6533        let child_rect = root.children[0].computed_rect;
6534        assert!(
6535            (child_rect.h - 100.0).abs() < 0.5,
6536            "expected child height capped at 100, got h={}",
6537            child_rect.h,
6538        );
6539    }
6540
6541    /// `.user_resizable()` (issue #106): layout publishes a grab band
6542    /// straddling the pane's trailing edge, sized off the pane's rect,
6543    /// with the clamp range from `min_width`/`max_width`.
6544    #[test]
6545    fn user_resizable_publishes_trailing_edge_band() {
6546        use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6547        let mut root = crate::tree::row([
6548            column(Vec::<El>::new())
6549                .key("nav")
6550                .user_resizable()
6551                .width(Size::Fixed(200.0))
6552                .min_width(120.0)
6553                .max_width(420.0),
6554            column(Vec::<El>::new()).width(Size::Fill(1.0)),
6555        ])
6556        .height(Size::Fixed(400.0));
6557        let mut state = UiState::new();
6558        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6559
6560        assert_eq!(state.resize.bands.len(), 1);
6561        let band = &state.resize.bands[0];
6562        assert_eq!(band.id, "nav");
6563        assert_eq!(band.key.as_deref(), Some("nav"));
6564        assert_eq!(band.sign, 1.0, "trailing edge: drag-right grows");
6565        assert!((band.current - 200.0).abs() < 0.5);
6566        assert_eq!((band.min, band.max), (120.0, 420.0));
6567        // The band straddles the seam at x=200, full pane height.
6568        assert!((band.band.x - (200.0 - T / 2.0)).abs() < 0.5);
6569        assert!((band.band.w - T).abs() < 0.5);
6570        assert!((band.band.h - 400.0).abs() < 0.5);
6571    }
6572
6573    /// A last-child resizable pane (right-anchored inspector) gets a
6574    /// leading-edge band with the drag direction flipped, and a
6575    /// missing `max_width` caps at the parent's inner extent so the
6576    /// seam can't be dragged out of reach.
6577    #[test]
6578    fn user_resizable_last_child_gets_leading_edge_band() {
6579        use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6580        let mut root = crate::tree::row([
6581            column(Vec::<El>::new()).width(Size::Fill(1.0)),
6582            column(Vec::<El>::new())
6583                .key("inspector")
6584                .user_resizable()
6585                .width(Size::Fixed(240.0)),
6586        ])
6587        .height(Size::Fixed(400.0));
6588        let mut state = UiState::new();
6589        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6590
6591        assert_eq!(state.resize.bands.len(), 1);
6592        let band = &state.resize.bands[0];
6593        assert_eq!(band.sign, -1.0, "leading edge: drag-left grows");
6594        // Pane spans [560, 800]; the band straddles its left edge.
6595        assert!((band.band.x - (560.0 - T / 2.0)).abs() < 0.5);
6596        assert_eq!(band.min, 0.0);
6597        assert!(
6598            (band.max - 800.0).abs() < 0.5,
6599            "no max_width → capped at the parent's inner extent, got {}",
6600            band.max,
6601        );
6602    }
6603
6604    /// A stored override rewrites the pane's declared size as `Fixed`
6605    /// on the next layout, clamped to min/max — including overrides
6606    /// pre-seeded via `set_user_size` before any drag.
6607    #[test]
6608    fn user_resizable_override_applies_and_clamps() {
6609        let build = || {
6610            crate::tree::row([
6611                column(Vec::<El>::new())
6612                    .key("nav")
6613                    .user_resizable()
6614                    .width(Size::Fixed(200.0))
6615                    .min_width(120.0)
6616                    .max_width(420.0),
6617                column(Vec::<El>::new()).key("main").width(Size::Fill(1.0)),
6618            ])
6619            .height(Size::Fixed(400.0))
6620        };
6621        let mut state = UiState::new();
6622        state.set_user_size("nav", 300.0);
6623        let mut root = build();
6624        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6625        let nav = state.rect_of_key("nav").unwrap();
6626        assert!((nav.w - 300.0).abs() < 0.5, "override wins, got {}", nav.w);
6627        let main = state.rect_of_key("main").unwrap();
6628        assert!(
6629            (main.w - 500.0).abs() < 0.5,
6630            "the Fill sibling absorbs the change, got {}",
6631            main.w,
6632        );
6633
6634        // Out-of-range overrides clamp to the pane's declared bounds.
6635        state.set_user_size("nav", 9999.0);
6636        let mut root = build();
6637        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6638        assert!((state.rect_of_key("nav").unwrap().w - 420.0).abs() < 0.5);
6639
6640        // Clearing returns the pane to its declared size.
6641        state.clear_user_size("nav");
6642        let mut root = build();
6643        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6644        assert!((state.rect_of_key("nav").unwrap().w - 200.0).abs() < 0.5);
6645    }
6646
6647    /// A resizable pane in a `column` resizes its *height*: the band
6648    /// lies along the bottom edge and the clamp comes from the height
6649    /// bounds.
6650    #[test]
6651    fn user_resizable_in_column_resizes_height() {
6652        use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6653        let mut root = column([
6654            crate::tree::row(Vec::<El>::new())
6655                .key("topbar")
6656                .user_resizable()
6657                .height(Size::Fixed(100.0))
6658                .min_height(40.0),
6659            crate::tree::row(Vec::<El>::new()).height(Size::Fill(1.0)),
6660        ])
6661        .width(Size::Fixed(800.0));
6662        let mut state = UiState::new();
6663        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6664
6665        assert_eq!(state.resize.bands.len(), 1);
6666        let band = &state.resize.bands[0];
6667        assert!((band.band.y - (100.0 - T / 2.0)).abs() < 0.5);
6668        assert!((band.band.w - 800.0).abs() < 0.5);
6669        assert_eq!(band.min, 40.0);
6670        assert!((band.current - 100.0).abs() < 0.5);
6671
6672        state.set_user_size("topbar", 250.0);
6673        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6674        assert!((state.rect_of_key("topbar").unwrap().h - 250.0).abs() < 0.5);
6675    }
6676}