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