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