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