Skip to main content

aetna_core/
layout.rs

1//! Flex-style layout pass over the [`El`] tree.
2//!
3//! Sizing per axis:
4//! - `Fixed(px)` — exact size on its axis.
5//! - `Hug` — intrinsic size (text width, sum of children, etc.). Default.
6//! - `Fill(weight)` — share leftover main-axis space proportionally.
7//!
8//! Defaults match CSS flex's `flex: 0 1 auto`: children content-size
9//! on the main axis, defer to the parent's [`Align`] on the cross
10//! axis. `Align::Stretch` (the column / scroll default) stretches both
11//! `Hug` and `Fill` children to the container's full cross extent —
12//! the analog of CSS `align-items: stretch`. `Align::Center | Start |
13//! End` shrinks them to intrinsic so the alignment can actually
14//! position them — matching CSS's behavior when align-items is
15//! non-stretch. Main-axis distribution is governed by [`Justify`] (or
16//! insert a [`spacer`]).
17//!
18//! The layout pass also assigns each node a stable path-based
19//! [`El::computed_id`]: `root.0.card[account].2.button` — a node's ID is
20//! parent-id + dot + role-or-key + sibling-index. IDs survive minor
21//! refactors and are usable as patch / lint / draw-op targets.
22//!
23//! Rects do not live on `El` — the layout pass writes them to
24//! `UiState`'s computed-rect side map, keyed by `computed_id`. The
25//! container rect flows down the recursion as a parameter; child rects
26//! are computed per-axis and inserted into the side map. Scroll offsets
27//! likewise read/write `UiState`'s scroll-offset side map directly.
28//!
29//! Text intrinsic measurement uses bundled-font glyph advances via
30//! [`crate::text::metrics`]. Full shaping still belongs to the renderer
31//! for now; this keeps layout/lint/SVG close enough to glyphon output
32//! without committing to the final text stack.
33
34use std::cell::RefCell;
35use std::sync::Arc;
36
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::scroll::{ScrollAlignment, ScrollRequest};
40use crate::state::{ScrollAnchor, UiState, VirtualAnchor};
41use crate::text::metrics as text_metrics;
42use crate::tree::*;
43
44/// Second escape hatch: author-supplied layout function.
45///
46/// When set on a node via [`El::layout`], the layout pass calls this
47/// function instead of running the column/row/overlay distribution for
48/// that node's direct children. The function returns one [`Rect`] per
49/// child (in source order), positioned anywhere inside the container.
50/// The library still recurses into each child (so descendants lay out
51/// normally) and still drives hit-test, focus, animation, scroll —
52/// those all read from `UiState`'s computed-rect side map, which receives the
53/// rects this function produces.
54///
55/// Authors typically write a free `fn(LayoutCtx) -> Vec<Rect>` and
56/// pass it directly: `column(children).layout(my_layout)`.
57///
58/// ## What you get
59///
60/// - [`LayoutCtx::container`] — the rect available for placement
61///   (parent rect minus this node's padding).
62/// - [`LayoutCtx::children`] — read-only slice of the node's children;
63///   index here matches the index in your returned `Vec<Rect>`.
64/// - [`LayoutCtx::measure`] — call to get a child's intrinsic
65///   `(width, height)` if you need it for sizing decisions.
66///
67/// ## Scope limits (will panic)
68///
69/// - The custom-layout node itself must size with [`Size::Fixed`] or
70///   [`Size::Fill`] on both axes. `Size::Hug` would require a separate
71///   intrinsic callback and is not yet supported.
72/// - The returned `Vec<Rect>` length must equal `children.len()`.
73#[derive(Clone)]
74pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
75
76impl LayoutFn {
77    pub fn new<F>(f: F) -> Self
78    where
79        F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
80    {
81        LayoutFn(Arc::new(f))
82    }
83}
84
85impl std::fmt::Debug for LayoutFn {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str("LayoutFn(<fn>)")
88    }
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct LayoutIntrinsicCacheStats {
93    pub hits: u64,
94    pub misses: u64,
95}
96
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub struct LayoutPruneStats {
99    pub subtrees: u64,
100    pub nodes: u64,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Hash)]
104struct IntrinsicCacheKey {
105    computed_id: String,
106    available_width_bits: Option<u32>,
107}
108
109#[derive(Default)]
110struct IntrinsicCache {
111    measurements: FxHashMap<IntrinsicCacheKey, (f32, f32)>,
112    stats: LayoutIntrinsicCacheStats,
113    prune: LayoutPruneStats,
114}
115
116thread_local! {
117    static INTRINSIC_CACHE: RefCell<Option<IntrinsicCache>> = const { RefCell::new(None) };
118    static LAST_INTRINSIC_CACHE_STATS: RefCell<LayoutIntrinsicCacheStats> =
119        const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
120    static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
121        const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
122}
123
124struct IntrinsicCacheGuard {
125    previous: Option<IntrinsicCache>,
126}
127
128impl Drop for IntrinsicCacheGuard {
129    fn drop(&mut self) {
130        INTRINSIC_CACHE.with(|cell| {
131            cell.replace(self.previous.take());
132        });
133    }
134}
135
136fn with_intrinsic_cache(f: impl FnOnce()) {
137    let previous = INTRINSIC_CACHE.with(|cell| cell.replace(Some(IntrinsicCache::default())));
138    let mut guard = IntrinsicCacheGuard { previous };
139    f();
140    let finished = INTRINSIC_CACHE.with(|cell| cell.replace(guard.previous.take()));
141    if let Some(cache) = finished {
142        LAST_INTRINSIC_CACHE_STATS.with(|stats| {
143            *stats.borrow_mut() = cache.stats;
144        });
145        LAST_PRUNE_STATS.with(|stats| {
146            *stats.borrow_mut() = cache.prune;
147        });
148    }
149    std::mem::forget(guard);
150}
151
152pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
153    LAST_INTRINSIC_CACHE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
154}
155
156pub fn take_prune_stats() -> LayoutPruneStats {
157    LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
158}
159
160/// Virtualized list state attached to a [`Kind::VirtualList`] node.
161/// Holds the row count, the row-height policy, and the closure that
162/// realizes a row by global index. Set via [`crate::virtual_list`] or
163/// [`crate::virtual_list_dyn`]; the layout pass calls `build_row(i)`
164/// only for indices whose rect intersects the viewport.
165///
166/// ## Row-height policies
167///
168/// - [`VirtualMode::Fixed`] — every row is the same logical-pixel
169///   height. Scroll → visible-range is O(1).
170/// - [`VirtualMode::Dynamic`] — rows vary in height. The library uses
171///   `estimated_row_height` as a placeholder for unmeasured rows,
172///   measures visible rows at the current layout width, and preserves a
173///   row anchor on screen while estimates become measurements.
174///
175/// ## Other current scope
176///
177/// - **Vertical only** — feed/chat-log-shaped lists are the target.
178///   A horizontal variant can come later.
179/// - **No row pooling** — visible rows are rebuilt from scratch each
180///   layout pass. Fine for thousands of items; if it bottlenecks we
181///   add a pool keyed by stable row keys.
182#[derive(Clone, Debug)]
183pub enum VirtualMode {
184    /// Every row is exactly `row_height` logical pixels tall.
185    Fixed { row_height: f32 },
186    /// Rows have variable heights. `estimated_row_height` seeds the
187    /// content-height total and the visible-range walk for rows that
188    /// haven't been measured yet.
189    Dynamic { estimated_row_height: f32 },
190}
191
192/// Policy used to pick the next dynamic virtual-list anchor after each
193/// layout pass. The previous anchor solves the current frame; this
194/// policy rebases the next frame onto a coherent in-viewport row point.
195#[derive(Clone, Copy, Debug, PartialEq)]
196pub enum VirtualAnchorPolicy {
197    /// Pick the row point nearest `y_fraction` through the viewport.
198    /// `0.0` is the top, `1.0` is the bottom. Good default for feeds.
199    ViewportFraction { y_fraction: f32 },
200    /// Prefer the first fully visible row; fall back to the first
201    /// partially visible row.
202    FirstVisible,
203    /// Prefer the last fully visible row; fall back to the last
204    /// partially visible row.
205    LastVisible,
206}
207
208impl Default for VirtualAnchorPolicy {
209    fn default() -> Self {
210        Self::ViewportFraction { y_fraction: 0.25 }
211    }
212}
213
214#[derive(Clone)]
215#[non_exhaustive]
216pub struct VirtualItems {
217    pub count: usize,
218    pub mode: VirtualMode,
219    pub anchor_policy: VirtualAnchorPolicy,
220    pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
221    pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
222}
223
224impl VirtualItems {
225    pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
226    where
227        F: Fn(usize) -> El + Send + Sync + 'static,
228    {
229        assert!(
230            row_height > 0.0,
231            "VirtualItems::new requires row_height > 0.0 (got {row_height})"
232        );
233        VirtualItems {
234            count,
235            mode: VirtualMode::Fixed { row_height },
236            anchor_policy: VirtualAnchorPolicy::default(),
237            row_key: Arc::new(|i| i.to_string()),
238            build_row: Arc::new(build_row),
239        }
240    }
241
242    pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
243    where
244        K: Fn(usize) -> String + Send + Sync + 'static,
245        F: Fn(usize) -> El + Send + Sync + 'static,
246    {
247        assert!(
248            estimated_row_height > 0.0,
249            "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
250        );
251        VirtualItems {
252            count,
253            mode: VirtualMode::Dynamic {
254                estimated_row_height,
255            },
256            anchor_policy: VirtualAnchorPolicy::default(),
257            row_key: Arc::new(row_key),
258            build_row: Arc::new(build_row),
259        }
260    }
261
262    pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
263        self.anchor_policy = policy;
264        self
265    }
266}
267
268impl std::fmt::Debug for VirtualItems {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("VirtualItems")
271            .field("count", &self.count)
272            .field("mode", &self.mode)
273            .field("anchor_policy", &self.anchor_policy)
274            .field("row_key", &"<fn>")
275            .field("build_row", &"<fn>")
276            .finish()
277    }
278}
279
280/// Context handed to a [`LayoutFn`]. Marked `#[non_exhaustive]` so
281/// future fields (intrinsic-at-width, scroll context, …) can be added
282/// without breaking author code that currently reads `container` /
283/// `children` / `measure`.
284#[non_exhaustive]
285pub struct LayoutCtx<'a> {
286    /// Inner rect of the parent (after padding) — the area available
287    /// for child placement. Children may be positioned anywhere; the
288    /// library does not clamp returned rects to this region.
289    pub container: Rect,
290    /// Direct children of the node, in source order. Read-only — return
291    /// positions through your `Vec<Rect>`.
292    pub children: &'a [El],
293    /// Intrinsic `(width, height)` for any child. Wrapped text returns
294    /// its unwrapped width here; if you need width-dependent wrapping
295    /// you'll need to size the child with `Fixed` / `Fill` instead.
296    pub measure: &'a dyn Fn(&El) -> (f32, f32),
297    /// Look up any keyed node's laid-out rect. Returns `None` when the
298    /// key is absent from the tree, when the node hasn't been laid out
299    /// yet (siblings later in source order), or when the key was used
300    /// on a node without a recorded rect. Used by widgets like
301    /// [`crate::widgets::popover::popover`] to position children
302    /// relative to elements outside their own subtree.
303    pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
304    /// Look up a node's laid-out rect by its `computed_id`. Same
305    /// semantics as [`Self::rect_of_key`] but skips the `key →
306    /// computed_id` translation — useful for runtime-synthesized
307    /// layers (tooltips, focus rings) that anchor to a node the
308    /// library already knows by id.
309    pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
310}
311
312/// Lay out the whole tree into the given viewport rect. Assigns
313/// `computed_id`s, rebuilds the key index, and runs the layout walk.
314///
315/// Hosts that drive their own pipeline (the Aetna runtime does this in
316/// [`crate::runtime::RunnerCore::prepare_layout`]) typically call
317/// [`assign_ids`] before synthesizing floating layers (tooltips,
318/// toasts), then route the laid-out call through
319/// [`layout_post_assign`] so the id walk doesn't run twice per frame.
320pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
321    {
322        crate::profile_span!("layout::assign_ids");
323        assign_id(root, "root");
324    }
325    layout_post_assign(root, ui_state, viewport);
326}
327
328/// Like [`layout`], but skips the recursive `assign_id` walk. Callers
329/// are responsible for ensuring every node's `computed_id` is already
330/// set — typically by invoking [`assign_ids`] earlier in the pipeline,
331/// then having any per-frame floating-layer synthesis pass call
332/// [`assign_id_appended`] on its newly pushed layer.
333pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
334    with_intrinsic_cache(|| {
335        {
336            crate::profile_span!("layout::root_setup");
337            ui_state
338                .layout
339                .computed_rects
340                .insert(root.computed_id.clone(), viewport);
341            rebuild_key_index(root, ui_state);
342            // Per-scrollable scratch is rebuilt every layout — entries for
343            // scrollables that disappeared mid-frame must not leave stale
344            // thumb rects behind for hit-test or paint to find.
345            ui_state.scroll.metrics.clear();
346            ui_state.scroll.thumb_rects.clear();
347            ui_state.scroll.thumb_tracks.clear();
348        }
349        crate::profile_span!("layout::children");
350        layout_children(root, viewport, ui_state);
351    });
352}
353
354/// Assign `computed_id`s to a child that was just appended to an
355/// already-id-assigned `parent`. Companion to [`layout_post_assign`]:
356/// floating-layer synthesis (tooltip, toast) pushes one new child onto
357/// the root and uses this to give the new subtree the same path-style
358/// ids the recursive `assign_id` would have, without re-walking the
359/// rest of the tree.
360pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
361    let role = role_token(&child.kind);
362    let suffix = match (&child.key, role) {
363        (Some(k), r) => format!("{r}[{k}]"),
364        (None, r) => format!("{r}.{child_index}"),
365    };
366    assign_id(child, &format!("{parent_id}.{suffix}"));
367}
368
369/// Walk the tree once and refresh `ui_state.layout.key_index` so
370/// `LayoutCtx::rect_of_key` can resolve `key → computed_id` without
371/// re-scanning the tree per lookup. First key wins — duplicate keys
372/// are an author bug, but we don't want to crash layout over it.
373fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
374    ui_state.layout.key_index.clear();
375    fn visit(node: &El, index: &mut rustc_hash::FxHashMap<String, String>) {
376        if let Some(key) = &node.key {
377            index
378                .entry(key.clone())
379                .or_insert_with(|| node.computed_id.clone());
380        }
381        for c in &node.children {
382            visit(c, index);
383        }
384    }
385    visit(root, &mut ui_state.layout.key_index);
386}
387
388/// Assign every node's `computed_id` without positioning anything else.
389/// Useful when callers need to read or seed side-map entries (e.g.,
390/// scroll offsets) before `layout` runs.
391pub fn assign_ids(root: &mut El) {
392    assign_id(root, "root");
393}
394
395fn assign_id(node: &mut El, path: &str) {
396    node.computed_id = path.to_string();
397    for (i, c) in node.children.iter_mut().enumerate() {
398        let role = role_token(&c.kind);
399        let suffix = match (&c.key, role) {
400            (Some(k), r) => format!("{r}[{k}]"),
401            (None, r) => format!("{r}.{i}"),
402        };
403        let child_path = format!("{path}.{suffix}");
404        assign_id(c, &child_path);
405    }
406}
407
408fn role_token(k: &Kind) -> &'static str {
409    match k {
410        Kind::Group => "group",
411        Kind::Card => "card",
412        Kind::Button => "button",
413        Kind::Badge => "badge",
414        Kind::Text => "text",
415        Kind::Heading => "heading",
416        Kind::Spacer => "spacer",
417        Kind::Divider => "divider",
418        Kind::Overlay => "overlay",
419        Kind::Scrim => "scrim",
420        Kind::Modal => "modal",
421        Kind::Scroll => "scroll",
422        Kind::VirtualList => "virtual_list",
423        Kind::Inlines => "inlines",
424        Kind::HardBreak => "hard_break",
425        Kind::Math => "math",
426        Kind::Image => "image",
427        Kind::Surface => "surface",
428        Kind::Vector => "vector",
429        Kind::Custom(name) => name,
430    }
431}
432
433fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
434    if matches!(node.kind, Kind::Inlines) {
435        // The paragraph paints as a single AttributedText DrawOp;
436        // child Text/HardBreak nodes are aggregated by draw_ops::
437        // push_node and don't paint independently. Give each child a
438        // zero-size rect so the rest of the engine (hit-test, focus,
439        // animation, lint) treats them as non-paint pseudo-nodes. The
440        // paragraph's hit-test target is the Inlines node itself,
441        // sized by node_rect.
442        for c in &mut node.children {
443            ui_state.layout.computed_rects.insert(
444                c.computed_id.clone(),
445                Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
446            );
447            // Recurse so descendants of Text/HardBreak nodes (rare —
448            // these are leaves in practice — but keeping the invariant
449            // simple) still get their rects assigned.
450            layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
451        }
452        return;
453    }
454    if let Some(items) = node.virtual_items.clone() {
455        layout_virtual(node, node_rect, items, ui_state);
456        return;
457    }
458    if let Some(layout_fn) = node.layout_override.clone() {
459        layout_custom(node, node_rect, layout_fn, ui_state);
460        if node.scrollable {
461            apply_scroll_offset(node, node_rect, ui_state);
462        }
463        return;
464    }
465    match node.axis {
466        Axis::Overlay => {
467            let inner = node_rect.inset(node.padding);
468            for c in &mut node.children {
469                let c_rect = overlay_rect(c, inner, node.align, node.justify);
470                ui_state
471                    .layout
472                    .computed_rects
473                    .insert(c.computed_id.clone(), c_rect);
474                layout_children(c, c_rect, ui_state);
475            }
476        }
477        Axis::Column => layout_axis(node, node_rect, true, ui_state),
478        Axis::Row => layout_axis(node, node_rect, false, ui_state),
479    }
480    if node.scrollable {
481        apply_scroll_offset(node, node_rect, ui_state);
482    }
483}
484
485fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
486    let inner = node_rect.inset(node.padding);
487    let measure = |c: &El| intrinsic(c);
488    // Split-borrow `ui_state` so the `rect_of_key` closure reads the
489    // key index + computed rects while the surrounding function still
490    // holds the mutable borrow needed to insert this node's children
491    // back into `computed_rects` afterwards.
492    let key_index = &ui_state.layout.key_index;
493    let computed_rects = &ui_state.layout.computed_rects;
494    let rect_of_key = |key: &str| -> Option<Rect> {
495        let id = key_index.get(key)?;
496        computed_rects.get(id).copied()
497    };
498    let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
499    let rects = (layout_fn.0)(LayoutCtx {
500        container: inner,
501        children: &node.children,
502        measure: &measure,
503        rect_of_key: &rect_of_key,
504        rect_of_id: &rect_of_id,
505    });
506    assert_eq!(
507        rects.len(),
508        node.children.len(),
509        "LayoutFn for {:?} returned {} rects for {} children",
510        node.computed_id,
511        rects.len(),
512        node.children.len(),
513    );
514    for (c, c_rect) in node.children.iter_mut().zip(rects) {
515        ui_state
516            .layout
517            .computed_rects
518            .insert(c.computed_id.clone(), c_rect);
519        layout_children(c, c_rect, ui_state);
520    }
521}
522
523/// Virtualized list realization. Dispatches by [`VirtualMode`] —
524/// `Fixed` uses an O(1) division to find the visible range; `Dynamic`
525/// walks measured-or-estimated heights, measures each visible row's
526/// natural intrinsic height, and writes the result back to the height
527/// cache on `UiState` so subsequent frames have it available.
528fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
529    let inner = node_rect.inset(node.padding);
530    match items.mode {
531        VirtualMode::Fixed { row_height } => layout_virtual_fixed(
532            node,
533            inner,
534            items.count,
535            row_height,
536            items.build_row,
537            ui_state,
538        ),
539        VirtualMode::Dynamic {
540            estimated_row_height,
541        } => layout_virtual_dynamic(
542            node,
543            inner,
544            items.count,
545            estimated_row_height,
546            DynamicVirtualFns {
547                anchor_policy: items.anchor_policy,
548                row_key: items.row_key,
549                build_row: items.build_row,
550            },
551            ui_state,
552        ),
553    }
554}
555
556/// Consume any pending [`ScrollRequest`]s targeting this list's `key`,
557/// resolving each into a target offset using the live viewport rect and
558/// the caller-supplied row-extent function. Writes the resolved offset
559/// directly into `scroll.offsets`; the immediately-following
560/// `write_virtual_scroll_state` call clamps it to `[0, max_offset]`.
561///
562/// Requests for other lists are left in the queue for sibling lists in
563/// the same layout pass. Anything still queued after layout completes is
564/// dropped by the runtime (see `prepare_layout`).
565fn resolve_scroll_requests<F, K>(
566    node: &El,
567    inner: Rect,
568    count: usize,
569    row_extent: F,
570    row_for_key: K,
571    ui_state: &mut UiState,
572) -> bool
573where
574    F: Fn(usize) -> (f32, f32),
575    K: Fn(&str) -> Option<usize>,
576{
577    if ui_state.scroll.pending_requests.is_empty() {
578        return false;
579    }
580    let Some(key) = node.key.as_deref() else {
581        return false;
582    };
583    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
584    let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
585        pending.into_iter().partition(|req| match req {
586            ScrollRequest::ToRow { list_key, .. } => list_key == key,
587            ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
588            // EnsureVisible isn't a virtual-list-row request; let the
589            // non-virtual scroll resolver pick it up downstream.
590            ScrollRequest::EnsureVisible { .. } => false,
591        });
592    ui_state.scroll.pending_requests = remaining;
593
594    let mut wrote = false;
595    for req in matched {
596        let (row, align) = match req {
597            ScrollRequest::ToRow { row, align, .. } => (row, align),
598            ScrollRequest::ToRowKey { row_key, align, .. } => {
599                let Some(row) = row_for_key(&row_key) else {
600                    continue;
601                };
602                (row, align)
603            }
604            ScrollRequest::EnsureVisible { .. } => continue,
605        };
606        if row >= count {
607            continue;
608        }
609        let (row_top, row_h) = row_extent(row);
610        let row_bottom = row_top + row_h;
611        let viewport_h = inner.h;
612        let current = ui_state
613            .scroll
614            .offsets
615            .get(&node.computed_id)
616            .copied()
617            .unwrap_or(0.0);
618        let new_offset = match align {
619            ScrollAlignment::Start => row_top,
620            ScrollAlignment::End => row_bottom - viewport_h,
621            ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
622            ScrollAlignment::Visible => {
623                if row_top < current {
624                    row_top
625                } else if row_bottom > current + viewport_h {
626                    row_bottom - viewport_h
627                } else {
628                    continue;
629                }
630            }
631        };
632        ui_state
633            .scroll
634            .offsets
635            .insert(node.computed_id.clone(), new_offset);
636        wrote = true;
637    }
638    wrote
639}
640
641/// Clamp the stored scroll offset, write the metrics + thumb rect, and
642/// return the clamped offset. Shared scaffold for both virtual modes.
643fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
644    let max_offset = (total_h - inner.h).max(0.0);
645    let stored = ui_state
646        .scroll
647        .offsets
648        .get(&node.computed_id)
649        .copied()
650        .unwrap_or(0.0);
651    let stored = resolve_pin_end(node, stored, max_offset, ui_state);
652    let offset = stored.clamp(0.0, max_offset);
653    ui_state
654        .scroll
655        .offsets
656        .insert(node.computed_id.clone(), offset);
657    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
658    offset
659}
660
661fn write_virtual_scroll_metrics(
662    node: &El,
663    inner: Rect,
664    total_h: f32,
665    max_offset: f32,
666    offset: f32,
667    ui_state: &mut UiState,
668) {
669    ui_state.scroll.metrics.insert(
670        node.computed_id.clone(),
671        crate::state::ScrollMetrics {
672            viewport_h: inner.h,
673            content_h: total_h,
674            max_offset,
675        },
676    );
677    write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
678}
679
680/// Assign the realized row a path-style `computed_id` matching the
681/// regular tree's role/key/index convention so hit-test, focus, and
682/// state lookups remain stable across scrolls.
683fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
684    let role = role_token(&child.kind);
685    let suffix = match (&child.key, role) {
686        (Some(k), r) => format!("{r}[{k}]"),
687        (None, r) => format!("{r}.{global_i}"),
688    };
689    assign_id(child, &format!("{parent_id}.{suffix}"));
690}
691
692fn layout_virtual_fixed(
693    node: &mut El,
694    inner: Rect,
695    count: usize,
696    row_height: f32,
697    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
698    ui_state: &mut UiState,
699) {
700    let gap = node.gap.max(0.0);
701    let pitch = row_height + gap;
702    let total_h = virtual_total_height(count, count as f32 * row_height, gap);
703    resolve_scroll_requests(
704        node,
705        inner,
706        count,
707        |i| (i as f32 * pitch, row_height),
708        |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
709        ui_state,
710    );
711    let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
712
713    if count == 0 {
714        node.children.clear();
715        return;
716    }
717
718    // Visible index range — `start` floors, `end` ceils, both clamped.
719    // Include one extra candidate because a large gap can make the
720    // pitch-based ceil land on the gap before the next visible row.
721    let start = (offset / pitch).floor() as usize;
722    let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
723
724    let mut realized: Vec<El> = Vec::new();
725    for global_i in start..end {
726        let row_top = global_i as f32 * pitch;
727        if row_top >= offset + inner.h || row_top + row_height <= offset {
728            continue;
729        }
730        let mut child = (build_row)(global_i);
731        assign_virtual_row_id(&mut child, &node.computed_id, global_i);
732
733        let row_y = inner.y + row_top - offset;
734        let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
735        ui_state
736            .layout
737            .computed_rects
738            .insert(child.computed_id.clone(), c_rect);
739        layout_children(&mut child, c_rect, ui_state);
740        realized.push(child);
741    }
742    node.children = realized;
743}
744
745fn layout_virtual_dynamic(
746    node: &mut El,
747    inner: Rect,
748    count: usize,
749    estimated_row_height: f32,
750    fns: DynamicVirtualFns,
751    ui_state: &mut UiState,
752) {
753    let gap = node.gap.max(0.0);
754    let width_bucket = virtual_width_bucket(inner.w);
755    let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
756    prune_dynamic_measurements(node, &row_keys, ui_state);
757
758    if count == 0 {
759        ui_state.scroll.virtual_anchors.remove(&node.computed_id);
760        let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
761        debug_assert_eq!(offset, 0.0);
762        node.children.clear();
763        return;
764    }
765
766    let mut row_heights = dynamic_row_heights(
767        node,
768        &row_keys,
769        width_bucket,
770        estimated_row_height,
771        ui_state,
772    );
773
774    // Skip the cache snapshot entirely when nothing in the queue
775    // targets this list — a hot path on dynamic lists with warm
776    // caches (potentially thousands of entries) that would otherwise
777    // pay a per-frame HashMap clone for an operation that fires
778    // maybe once a minute.
779    let has_request = node.key.as_deref().is_some_and(|k| {
780        ui_state.scroll.pending_requests.iter().any(|r| match r {
781            ScrollRequest::ToRow { list_key, .. } => list_key == k,
782            ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
783            ScrollRequest::EnsureVisible { .. } => false,
784        })
785    });
786    let mut request_wrote = false;
787    if has_request {
788        request_wrote = resolve_scroll_requests(
789            node,
790            inner,
791            count,
792            |target| {
793                (
794                    dynamic_row_top(&row_heights, gap, target),
795                    row_heights[target],
796                )
797            },
798            |row_key| row_keys.iter().position(|key| key == row_key),
799            ui_state,
800        );
801    }
802
803    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
804    let max_offset = (total_h - inner.h).max(0.0);
805    let stored = ui_state
806        .scroll
807        .offsets
808        .get(&node.computed_id)
809        .copied()
810        .unwrap_or(0.0);
811    let pin_active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
812    let provisional_offset = if pin_active {
813        max_offset
814    } else if request_wrote {
815        stored
816    } else {
817        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
818            .unwrap_or(stored)
819    }
820    .clamp(0.0, max_offset);
821
822    let (measure_start, _, measure_end) =
823        dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
824    measure_dynamic_range(
825        node,
826        DynamicRangeCtx {
827            inner,
828            row_keys: &row_keys,
829            width_bucket,
830            build_row: &fns.build_row,
831        },
832        measure_start,
833        measure_end,
834        ui_state,
835    );
836
837    row_heights = dynamic_row_heights(
838        node,
839        &row_keys,
840        width_bucket,
841        estimated_row_height,
842        ui_state,
843    );
844    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
845    let max_offset = (total_h - inner.h).max(0.0);
846    let stored = ui_state
847        .scroll
848        .offsets
849        .get(&node.computed_id)
850        .copied()
851        .unwrap_or(0.0);
852    let pin_resolved = resolve_pin_end(node, stored, max_offset, ui_state);
853    let pin_active = node.pin_end
854        && ui_state
855            .scroll
856            .pin_active
857            .get(&node.computed_id)
858            .copied()
859            .unwrap_or(false);
860    let mut offset = if pin_active {
861        pin_resolved
862    } else if request_wrote {
863        stored
864    } else {
865        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
866            .unwrap_or(stored)
867    }
868    .clamp(0.0, max_offset);
869
870    ui_state
871        .scroll
872        .offsets
873        .insert(node.computed_id.clone(), offset);
874
875    let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
876    let mut realized_rows = layout_dynamic_range(
877        node,
878        DynamicRangeCtx {
879            inner,
880            row_keys: &row_keys,
881            width_bucket,
882            build_row: &fns.build_row,
883        },
884        offset,
885        start,
886        start_y,
887        end,
888        ui_state,
889    );
890
891    row_heights = dynamic_row_heights(
892        node,
893        &row_keys,
894        width_bucket,
895        estimated_row_height,
896        ui_state,
897    );
898    let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
899    let max_offset = (total_h - inner.h).max(0.0);
900    let corrected_offset = if pin_active {
901        max_offset
902    } else if request_wrote {
903        offset
904    } else {
905        dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
906            .unwrap_or(offset)
907    }
908    .clamp(0.0, max_offset);
909    if (corrected_offset - offset).abs() > 0.01 {
910        let dy = offset - corrected_offset;
911        for child in &node.children {
912            shift_subtree_y(child, dy, ui_state);
913        }
914        for row in &mut realized_rows {
915            row.rect.y += dy;
916        }
917        offset = corrected_offset;
918        ui_state
919            .scroll
920            .offsets
921            .insert(node.computed_id.clone(), offset);
922    }
923    if node.pin_end {
924        ui_state
925            .scroll
926            .pin_prev_max
927            .insert(node.computed_id.clone(), max_offset);
928    }
929    write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
930
931    if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
932        ui_state
933            .scroll
934            .virtual_anchors
935            .insert(node.computed_id.clone(), anchor);
936    } else {
937        ui_state.scroll.virtual_anchors.remove(&node.computed_id);
938    }
939}
940
941struct DynamicVirtualFns {
942    anchor_policy: VirtualAnchorPolicy,
943    row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
944    build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
945}
946
947#[derive(Clone, Copy)]
948struct DynamicRangeCtx<'a> {
949    inner: Rect,
950    row_keys: &'a [String],
951    width_bucket: u32,
952    build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
953}
954
955fn virtual_width_bucket(width: f32) -> u32 {
956    width.max(0.0).round().min(u32::MAX as f32) as u32
957}
958
959fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
960    let Some(measurements) = ui_state
961        .scroll
962        .measured_row_heights
963        .get_mut(&node.computed_id)
964    else {
965        return;
966    };
967    let live_keys = row_keys
968        .iter()
969        .map(String::as_str)
970        .collect::<FxHashSet<_>>();
971    measurements.retain(|key, widths| {
972        let live = live_keys.contains(key.as_str());
973        if live {
974            widths.retain(|_, h| h.is_finite() && *h >= 0.0);
975        }
976        live && !widths.is_empty()
977    });
978    if measurements.is_empty() {
979        ui_state
980            .scroll
981            .measured_row_heights
982            .remove(&node.computed_id);
983    }
984}
985
986fn dynamic_row_heights(
987    node: &El,
988    row_keys: &[String],
989    width_bucket: u32,
990    estimated_row_height: f32,
991    ui_state: &UiState,
992) -> Vec<f32> {
993    let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
994    row_keys
995        .iter()
996        .map(|key| {
997            measurements
998                .and_then(|m| m.get(key))
999                .and_then(|by_width| by_width.get(&width_bucket))
1000                .copied()
1001                .unwrap_or(estimated_row_height)
1002        })
1003        .collect()
1004}
1005
1006fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1007    row_heights
1008        .iter()
1009        .take(target)
1010        .fold(0.0, |y, h| y + *h + gap)
1011}
1012
1013fn dynamic_visible_range(
1014    row_heights: &[f32],
1015    gap: f32,
1016    offset: f32,
1017    viewport_h: f32,
1018) -> (usize, f32, usize) {
1019    let count = row_heights.len();
1020    let mut start = 0;
1021    let mut y = 0.0_f32;
1022    while start < count {
1023        let h = row_heights[start];
1024        if y + h > offset {
1025            break;
1026        }
1027        y += h + gap;
1028        start += 1;
1029    }
1030
1031    let mut end = start;
1032    let mut cursor = y;
1033    let viewport_bottom = offset + viewport_h;
1034    while end < count && cursor < viewport_bottom {
1035        let h = row_heights[end];
1036        end += 1;
1037        cursor += h + gap;
1038    }
1039    (start, y, end)
1040}
1041
1042fn dynamic_anchor_offset(
1043    node: &El,
1044    row_keys: &[String],
1045    row_heights: &[f32],
1046    gap: f32,
1047    stored: f32,
1048    ui_state: &UiState,
1049) -> Option<f32> {
1050    let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1051    let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1052        anchor.row_index
1053    } else {
1054        row_keys.iter().position(|key| key == &anchor.row_key)?
1055    };
1056    let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1057    let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1058    let scroll_delta = stored - anchor.resolved_offset;
1059    let viewport_y = anchor.viewport_y - scroll_delta;
1060    Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1061}
1062
1063fn measure_dynamic_range(
1064    node: &El,
1065    ctx: DynamicRangeCtx<'_>,
1066    start: usize,
1067    end: usize,
1068    ui_state: &mut UiState,
1069) {
1070    if start >= end {
1071        return;
1072    }
1073    let mut new_measurements = Vec::new();
1074    for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1075        let child = (ctx.build_row)(idx);
1076        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1077        new_measurements.push((key.clone(), actual_h));
1078    }
1079    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1080}
1081
1082fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1083    match child.height {
1084        Size::Fixed(v) => v.max(0.0),
1085        Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1086        Size::Fill(_) => panic!(
1087            "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
1088             Size::Fill would absorb the viewport's height and break virtualization",
1089            node.computed_id,
1090        ),
1091    }
1092}
1093
1094fn store_dynamic_measurements(
1095    node: &El,
1096    width_bucket: u32,
1097    measurements: Vec<(String, f32)>,
1098    ui_state: &mut UiState,
1099) {
1100    if measurements.is_empty() {
1101        return;
1102    }
1103    let entry = ui_state
1104        .scroll
1105        .measured_row_heights
1106        .entry(node.computed_id.clone())
1107        .or_default();
1108    for (row_key, h) in measurements {
1109        entry.entry(row_key).or_default().insert(width_bucket, h);
1110    }
1111}
1112
1113#[derive(Clone, Debug)]
1114struct DynamicRealizedRow {
1115    index: usize,
1116    key: String,
1117    rect: Rect,
1118}
1119
1120fn layout_dynamic_range(
1121    node: &mut El,
1122    ctx: DynamicRangeCtx<'_>,
1123    offset: f32,
1124    start: usize,
1125    start_y: f32,
1126    end: usize,
1127    ui_state: &mut UiState,
1128) -> Vec<DynamicRealizedRow> {
1129    let gap = node.gap.max(0.0);
1130    let mut cursor_y = start_y;
1131    let mut realized = Vec::new();
1132    let mut realized_rows = Vec::new();
1133    let mut new_measurements = Vec::new();
1134
1135    for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1136        let mut child = (ctx.build_row)(idx);
1137        assign_virtual_row_id(&mut child, &node.computed_id, idx);
1138        let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1139        new_measurements.push((key.clone(), actual_h));
1140
1141        let row_y = ctx.inner.y + cursor_y - offset;
1142        let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1143        ui_state
1144            .layout
1145            .computed_rects
1146            .insert(child.computed_id.clone(), c_rect);
1147        layout_children(&mut child, c_rect, ui_state);
1148
1149        realized_rows.push(DynamicRealizedRow {
1150            index: idx,
1151            key: key.clone(),
1152            rect: c_rect,
1153        });
1154        realized.push(child);
1155        cursor_y += actual_h + gap;
1156    }
1157
1158    store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1159    node.children = realized;
1160    realized_rows
1161}
1162
1163fn choose_dynamic_anchor(
1164    policy: VirtualAnchorPolicy,
1165    inner: Rect,
1166    offset: f32,
1167    rows: &[DynamicRealizedRow],
1168) -> Option<VirtualAnchor> {
1169    let visible = rows
1170        .iter()
1171        .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1172        .collect::<Vec<_>>();
1173    if visible.is_empty() {
1174        return None;
1175    }
1176
1177    let chosen = match policy {
1178        VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1179            let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1180            visible
1181                .iter()
1182                .min_by(|a, b| {
1183                    let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1184                    let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1185                    ad.total_cmp(&bd)
1186                })
1187                .copied()
1188                .map(|row| {
1189                    let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1190                    (row.clone(), anchor_y)
1191                })
1192        }
1193        VirtualAnchorPolicy::FirstVisible => {
1194            let row = visible
1195                .iter()
1196                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1197                .or_else(|| visible.first())
1198                .copied()?;
1199            let anchor_y = row.rect.y.max(inner.y);
1200            Some((row.clone(), anchor_y))
1201        }
1202        VirtualAnchorPolicy::LastVisible => {
1203            let row = visible
1204                .iter()
1205                .rev()
1206                .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1207                .or_else(|| visible.last())
1208                .copied()?;
1209            let anchor_y = row.rect.bottom().min(inner.bottom());
1210            Some((row.clone(), anchor_y))
1211        }
1212    }?;
1213
1214    let (row, anchor_y) = chosen;
1215    let row_h = row.rect.h.max(0.0);
1216    let row_fraction = if row_h > 0.0 {
1217        ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1218    } else {
1219        0.0
1220    };
1221    Some(VirtualAnchor {
1222        row_key: row.key.clone(),
1223        row_index: row.index,
1224        row_fraction,
1225        viewport_y: anchor_y - inner.y,
1226        resolved_offset: offset,
1227    })
1228}
1229
1230fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1231    if y < top {
1232        top - y
1233    } else if y > bottom {
1234        y - bottom
1235    } else {
1236        0.0
1237    }
1238}
1239
1240fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1241    if count == 0 {
1242        0.0
1243    } else {
1244        row_sum + gap * count.saturating_sub(1) as f32
1245    }
1246}
1247
1248/// Scrollable post-pass: measure content height from the laid-out
1249/// children's stored rects, clamp the scroll offset to the available
1250/// range, and shift every descendant rect by `-offset`.
1251///
1252/// Children should size with `Hug` or `Fixed` on the main axis —
1253/// `Fill` children would absorb the viewport's height and there would
1254/// be nothing to scroll.
1255fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1256    let inner = node_rect.inset(node.padding);
1257    if node.children.is_empty() {
1258        ui_state
1259            .scroll
1260            .offsets
1261            .insert(node.computed_id.clone(), 0.0);
1262        ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1263        ui_state.scroll.metrics.insert(
1264            node.computed_id.clone(),
1265            crate::state::ScrollMetrics {
1266                viewport_h: inner.h,
1267                content_h: 0.0,
1268                max_offset: 0.0,
1269            },
1270        );
1271        return;
1272    }
1273    let content_bottom = node
1274        .children
1275        .iter()
1276        .map(|c| ui_state.rect(&c.computed_id).bottom())
1277        .fold(f32::NEG_INFINITY, f32::max);
1278    let content_h = (content_bottom - inner.y).max(0.0);
1279    let max_offset = (content_h - inner.h).max(0.0);
1280
1281    // Resolve any matching `ScrollRequest::EnsureVisible` against
1282    // this scroll BEFORE reading the stored offset, so the request's
1283    // chosen offset wins (and gets clamped below, just like
1284    // wheel-driven offsets do). A request matches when the node
1285    // keyed `container_key` is an ancestor of this scroll —
1286    // `key_index` resolves the key to a computed_id and a
1287    // prefix-match on `node.computed_id` tells us we're inside.
1288    let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1289
1290    let stored = ui_state
1291        .scroll
1292        .offsets
1293        .get(&node.computed_id)
1294        .copied()
1295        .unwrap_or(0.0);
1296    let stored = resolve_pin_end(node, stored, max_offset, ui_state);
1297    let pin_active = node.pin_end
1298        && ui_state
1299            .scroll
1300            .pin_active
1301            .get(&node.computed_id)
1302            .copied()
1303            .unwrap_or(false);
1304    let stored = if pin_active || request_wrote {
1305        stored
1306    } else {
1307        scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
1308    };
1309    let clamped = stored.clamp(0.0, max_offset);
1310    if clamped > 0.0 {
1311        for c in &node.children {
1312            shift_subtree_y(c, -clamped, ui_state);
1313        }
1314    }
1315    ui_state
1316        .scroll
1317        .offsets
1318        .insert(node.computed_id.clone(), clamped);
1319    ui_state.scroll.metrics.insert(
1320        node.computed_id.clone(),
1321        crate::state::ScrollMetrics {
1322            viewport_h: inner.h,
1323            content_h,
1324            max_offset,
1325        },
1326    );
1327
1328    write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1329
1330    if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
1331        ui_state
1332            .scroll
1333            .scroll_anchors
1334            .insert(node.computed_id.clone(), anchor);
1335    } else {
1336        ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1337    }
1338}
1339
1340fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
1341    let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
1342    let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
1343    if rect.h <= 0.0 {
1344        return None;
1345    }
1346    let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
1347    let scroll_delta = stored - anchor.resolved_offset;
1348    let viewport_y = anchor.viewport_y - scroll_delta;
1349    Some(rect.y - inner.y + rect_point - viewport_y)
1350}
1351
1352fn choose_scroll_anchor(
1353    node: &El,
1354    inner: Rect,
1355    offset: f32,
1356    ui_state: &UiState,
1357) -> Option<ScrollAnchor> {
1358    if inner.h <= 0.0 {
1359        return None;
1360    }
1361    let target_y = inner.y + inner.h * 0.25;
1362    let mut best = None;
1363    for child in &node.children {
1364        choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
1365    }
1366    let candidate = best?;
1367    let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
1368    let rect_fraction = if candidate.rect.h > 0.0 {
1369        ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
1370    } else {
1371        0.0
1372    };
1373    Some(ScrollAnchor {
1374        node_id: candidate.node_id,
1375        rect_fraction,
1376        viewport_y: anchor_y - inner.y,
1377        resolved_offset: offset,
1378    })
1379}
1380
1381#[derive(Clone, Debug)]
1382struct ScrollAnchorCandidate {
1383    node_id: String,
1384    rect: Rect,
1385    distance: f32,
1386    depth: usize,
1387}
1388
1389fn choose_scroll_anchor_in_subtree(
1390    node: &El,
1391    inner: Rect,
1392    target_y: f32,
1393    depth: usize,
1394    ui_state: &UiState,
1395    best: &mut Option<ScrollAnchorCandidate>,
1396) {
1397    let Some(rect) = ui_state
1398        .layout
1399        .computed_rects
1400        .get(&node.computed_id)
1401        .copied()
1402    else {
1403        return;
1404    };
1405    if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
1406        let distance = distance_to_interval(target_y, rect.y, rect.bottom());
1407        let candidate = ScrollAnchorCandidate {
1408            node_id: node.computed_id.clone(),
1409            rect,
1410            distance,
1411            depth,
1412        };
1413        let replace = best.as_ref().is_none_or(|current| {
1414            candidate.distance < current.distance
1415                || (candidate.distance == current.distance && candidate.depth > current.depth)
1416                || (candidate.distance == current.distance
1417                    && candidate.depth == current.depth
1418                    && candidate.rect.h < current.rect.h)
1419        });
1420        if replace {
1421            *best = Some(candidate);
1422        }
1423    }
1424
1425    if node.scrollable {
1426        return;
1427    }
1428    for child in &node.children {
1429        choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
1430    }
1431}
1432
1433/// Stored offset within this much of `max_offset` counts as "at the
1434/// tail" for [`El::pin_end`]. Wheel deltas are integer pixels, so a
1435/// half-pixel slack absorbs floating-point rounding without admitting
1436/// any deliberate user scroll.
1437const PIN_END_EPSILON: f32 = 0.5;
1438
1439fn pin_end_would_be_active(
1440    node: &El,
1441    stored: f32,
1442    _max_offset: f32,
1443    ui_state: &UiState,
1444) -> Option<bool> {
1445    if !node.pin_end {
1446        return None;
1447    }
1448    let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1449    let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1450    Some(match prev_active {
1451        None => true,
1452        Some(prev) => {
1453            let prev_max = prev_max.unwrap_or(0.0);
1454            if prev && stored < prev_max - PIN_END_EPSILON {
1455                false
1456            } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
1457                true
1458            } else {
1459                prev
1460            }
1461        }
1462    })
1463}
1464
1465/// Apply [`El::pin_end`] semantics to `stored`. Reads the previous
1466/// frame's `max_offset` from `scroll.metrics` to decide whether the
1467/// stored offset has moved off the tail since last frame (user wheel /
1468/// drag / programmatic write), and updates `scroll.pin_active`
1469/// accordingly. Returns the offset that should be clamped + written
1470/// downstream — `max_offset` when the pin is engaged, the input
1471/// `stored` otherwise.
1472///
1473/// First frame for an opted-in container starts pinned, so a freshly
1474/// mounted `scroll([...]).pin_end()` paints with its tail visible.
1475fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1476    if !node.pin_end {
1477        ui_state.scroll.pin_active.remove(&node.computed_id);
1478        ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1479        return stored;
1480    }
1481    let active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1482    ui_state
1483        .scroll
1484        .pin_active
1485        .insert(node.computed_id.clone(), active);
1486    ui_state
1487        .scroll
1488        .pin_prev_max
1489        .insert(node.computed_id.clone(), max_offset);
1490    if active { max_offset } else { stored }
1491}
1492
1493/// Walk pending `ScrollRequest::EnsureVisible` requests and pop any
1494/// whose `container_key` resolves to an ancestor of `node`. For each
1495/// match, write a stored offset that brings the request's content-
1496/// space `y..y+h` range into the viewport using minimal-displacement
1497/// semantics (top edge if above, bottom edge if below, leave alone if
1498/// already inside). The clamp + shift downstream of this call ensures
1499/// the resulting offset stays inside `[0, max_offset]`.
1500///
1501/// Matching is by computed-id prefix on the keyed ancestor — a
1502/// scroll is "inside" the keyed widget when its id starts with the
1503/// ancestor's id followed by `.`, the same rule used by
1504/// [`crate::state::query::target_in_subtree`].
1505fn resolve_ensure_visible_for_scroll(
1506    node: &El,
1507    inner: Rect,
1508    content_h: f32,
1509    ui_state: &mut UiState,
1510) -> bool {
1511    if ui_state.scroll.pending_requests.is_empty() {
1512        return false;
1513    }
1514    let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1515    let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1516    let mut wrote = false;
1517    for req in pending {
1518        let ScrollRequest::EnsureVisible {
1519            container_key,
1520            y,
1521            h,
1522        } = &req
1523        else {
1524            remaining.push(req);
1525            continue;
1526        };
1527        let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1528            // Container hasn't been laid out yet (or its key isn't
1529            // in this tree). Keep the request for a future frame —
1530            // dropped at end-of-frame like row requests for
1531            // missing lists.
1532            remaining.push(req);
1533            continue;
1534        };
1535        // Match this scroll only if it sits inside the keyed widget.
1536        // Same prefix rule as `target_in_subtree`.
1537        let inside = node.computed_id == *ancestor_id
1538            || node
1539                .computed_id
1540                .strip_prefix(ancestor_id.as_str())
1541                .is_some_and(|rest| rest.starts_with('.'));
1542        if !inside {
1543            remaining.push(req);
1544            continue;
1545        }
1546        let current = ui_state
1547            .scroll
1548            .offsets
1549            .get(&node.computed_id)
1550            .copied()
1551            .unwrap_or(0.0);
1552        let target_top = *y;
1553        let target_bottom = *y + *h;
1554        let viewport_h = inner.h;
1555        // Minimal-displacement: if the range is fully visible, no
1556        // change. If it's above the viewport top, scroll up to it.
1557        // If it's below the viewport bottom, scroll just enough to
1558        // expose the bottom edge — but never less than 0 or more
1559        // than `content_h - viewport_h` (the clamp downstream will
1560        // do that anyway).
1561        let new_offset = if target_top < current {
1562            target_top
1563        } else if target_bottom > current + viewport_h {
1564            target_bottom - viewport_h
1565        } else {
1566            // Already visible: don't override an in-progress
1567            // manual scroll just because the caret happens to be
1568            // mid-viewport. Skip this request without disturbing
1569            // the offset.
1570            continue;
1571        };
1572        // Clamp against the live content extent so we don't write
1573        // a wildly-out-of-range offset when the request races a
1574        // layout pass that hasn't yet measured all rows.
1575        let max = (content_h - viewport_h).max(0.0);
1576        let new_offset = new_offset.clamp(0.0, max);
1577        ui_state
1578            .scroll
1579            .offsets
1580            .insert(node.computed_id.clone(), new_offset);
1581        wrote = true;
1582    }
1583    ui_state.scroll.pending_requests = remaining;
1584    wrote
1585}
1586
1587/// Compute and store the scrollbar thumb + track rects for `node`
1588/// when the author opted into a visible scrollbar AND content
1589/// overflows. Both rects are anchored to the right edge of `inner`.
1590/// The visible thumb is `SCROLLBAR_THUMB_WIDTH` wide and tracks the
1591/// scroll offset; the track is `SCROLLBAR_HITBOX_WIDTH` wide and
1592/// covers the full inner height so a press above/below the thumb
1593/// can page-scroll.
1594fn write_thumb_rect(
1595    node: &El,
1596    inner: Rect,
1597    content_h: f32,
1598    max_offset: f32,
1599    offset: f32,
1600    ui_state: &mut UiState,
1601) {
1602    if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1603        return;
1604    }
1605    let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1606    let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1607    let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1608    let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1609    let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1610    let track_remaining = (inner.h - thumb_h).max(0.0);
1611    let thumb_y = inner.y + track_remaining * (offset / max_offset);
1612    let thumb_x = inner.right() - thumb_w - track_inset;
1613    let track_x = inner.right() - track_w - track_inset;
1614    ui_state.scroll.thumb_rects.insert(
1615        node.computed_id.clone(),
1616        Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1617    );
1618    ui_state.scroll.thumb_tracks.insert(
1619        node.computed_id.clone(),
1620        Rect::new(track_x, inner.y, track_w, inner.h),
1621    );
1622}
1623
1624fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1625    if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1626        rect.y += dy;
1627    }
1628    if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
1629        thumb.y += dy;
1630    }
1631    if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
1632        track.y += dy;
1633    }
1634    for c in &node.children {
1635        shift_subtree_y(c, dy, ui_state);
1636    }
1637}
1638
1639fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1640    let inner = node_rect.inset(node.padding);
1641    let n = node.children.len();
1642    if n == 0 {
1643        return;
1644    }
1645
1646    let total_gap = node.gap * n.saturating_sub(1) as f32;
1647    let main_extent = if vertical { inner.h } else { inner.w };
1648    let cross_extent = if vertical { inner.w } else { inner.h };
1649
1650    let intrinsics: Vec<(f32, f32)> = {
1651        crate::profile_span!("layout::axis::intrinsics");
1652        node.children
1653            .iter()
1654            .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1655            .collect()
1656    };
1657
1658    let mut consumed = 0.0;
1659    let mut fill_weight_total = 0.0;
1660    for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1661        match main_size_of(c, *iw, *ih, vertical) {
1662            MainSize::Resolved(v) => consumed += v,
1663            MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1664        }
1665    }
1666    let remaining = (main_extent - consumed - total_gap).max(0.0);
1667
1668    // Free space after children + gaps. When there are Fill children they
1669    // claim it all, so justify is moot; otherwise this is what center/end
1670    // distribute around.
1671    let free_after_used = if fill_weight_total == 0.0 {
1672        remaining
1673    } else {
1674        0.0
1675    };
1676    let mut cursor = match node.justify {
1677        Justify::Start => 0.0,
1678        Justify::Center => free_after_used * 0.5,
1679        Justify::End => free_after_used,
1680        Justify::SpaceBetween => 0.0,
1681    };
1682    let between_extra =
1683        if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1684            remaining / (n - 1) as f32
1685        } else {
1686            0.0
1687        };
1688    let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1689
1690    crate::profile_span!("layout::axis::place");
1691    for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1692        let main_size = match main_size_of(c, iw, ih, vertical) {
1693            MainSize::Resolved(v) => v,
1694            MainSize::Fill(w) => {
1695                let raw = remaining * w.max(0.001) / fill_weight_total.max(0.001);
1696                if vertical {
1697                    clamp_h(c, raw)
1698                } else {
1699                    clamp_w(c, raw)
1700                }
1701            }
1702        };
1703
1704        let cross_intent = if vertical { c.width } else { c.height };
1705        let cross_intrinsic = if vertical { iw } else { ih };
1706        // CSS-flex parity for cross-axis sizing: `Size::Fixed` is an
1707        // explicit author override and always wins. Otherwise the
1708        // parent's `Align` decides — `Stretch` (the column default)
1709        // stretches non-fixed children to the container, `Center` /
1710        // `Start` / `End` shrink to intrinsic so the alignment can
1711        // actually position them. This collapses Hug and Fill on the
1712        // cross axis (both are "follow align-items"), the same way
1713        // CSS flex doesn't distinguish between them on the cross axis.
1714        let cross_size = match cross_intent {
1715            Size::Fixed(v) => v,
1716            Size::Hug | Size::Fill(_) => match node.align {
1717                Align::Stretch => cross_extent,
1718                Align::Start | Align::Center | Align::End => cross_intrinsic,
1719            },
1720        };
1721        let cross_size = if vertical {
1722            clamp_w(c, cross_size)
1723        } else {
1724            clamp_h(c, cross_size)
1725        };
1726
1727        let cross_off = match node.align {
1728            Align::Start | Align::Stretch => 0.0,
1729            Align::Center => (cross_extent - cross_size) * 0.5,
1730            Align::End => cross_extent - cross_size,
1731        };
1732
1733        let c_rect = if vertical {
1734            Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1735        } else {
1736            Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1737        };
1738        ui_state
1739            .layout
1740            .computed_rects
1741            .insert(c.computed_id.clone(), c_rect);
1742        if can_prune_scroll_child(c, c_rect, scroll_visible) {
1743            let nodes = zero_descendant_rects(c, c_rect, ui_state);
1744            record_pruned_subtree(nodes);
1745        } else {
1746            layout_children(c, c_rect, ui_state);
1747        }
1748
1749        cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1750    }
1751}
1752
1753const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1754
1755fn scroll_visible_content_rect(
1756    node: &El,
1757    inner: Rect,
1758    vertical: bool,
1759    ui_state: &UiState,
1760) -> Option<Rect> {
1761    if !vertical || !node.scrollable || node.pin_end {
1762        return None;
1763    }
1764    let offset = ui_state
1765        .scroll
1766        .offsets
1767        .get(&node.computed_id)
1768        .copied()
1769        .unwrap_or(0.0)
1770        .max(0.0);
1771    Some(Rect::new(
1772        inner.x,
1773        inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1774        inner.w,
1775        inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1776    ))
1777}
1778
1779fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1780    let Some(visible) = visible else {
1781        return false;
1782    };
1783    child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1784}
1785
1786fn subtree_is_layout_confined(node: &El) -> bool {
1787    if node.translate != (0.0, 0.0)
1788        || node.scale != 1.0
1789        || node.shadow > 0.0
1790        || node.paint_overflow != Sides::zero()
1791        || node.hit_overflow != Sides::zero()
1792        || node.layout_override.is_some()
1793        || node.virtual_items.is_some()
1794    {
1795        return false;
1796    }
1797    node.children.iter().all(subtree_is_layout_confined)
1798}
1799
1800fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1801    let mut count = 0;
1802    let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1803    for child in &node.children {
1804        ui_state
1805            .layout
1806            .computed_rects
1807            .insert(child.computed_id.clone(), zero);
1808        count += 1 + zero_descendant_rects(child, zero, ui_state);
1809    }
1810    count
1811}
1812
1813fn record_pruned_subtree(nodes: u64) {
1814    INTRINSIC_CACHE.with(|cell| {
1815        if let Some(cache) = cell.borrow_mut().as_mut() {
1816            cache.prune.subtrees += 1;
1817            cache.prune.nodes += nodes;
1818        }
1819    });
1820}
1821
1822enum MainSize {
1823    Resolved(f32),
1824    Fill(f32),
1825}
1826
1827fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1828    let s = if vertical { c.height } else { c.width };
1829    let intr = if vertical { ih } else { iw };
1830    let clamp = |v: f32| {
1831        if vertical {
1832            clamp_h(c, v)
1833        } else {
1834            clamp_w(c, v)
1835        }
1836    };
1837    match s {
1838        Size::Fixed(v) => MainSize::Resolved(clamp(v)),
1839        Size::Hug => MainSize::Resolved(clamp(intr)),
1840        Size::Fill(w) => MainSize::Fill(w),
1841    }
1842}
1843
1844fn child_intrinsic(
1845    c: &El,
1846    vertical: bool,
1847    parent_cross_extent: f32,
1848    parent_align: Align,
1849) -> (f32, f32) {
1850    if !vertical {
1851        return intrinsic(c);
1852    }
1853    let available_width = match c.width {
1854        Size::Fixed(v) => Some(v),
1855        Size::Fill(_) => Some(parent_cross_extent),
1856        Size::Hug => match parent_align {
1857            Align::Stretch => Some(parent_cross_extent),
1858            Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1859        },
1860    };
1861    intrinsic_constrained(c, available_width)
1862}
1863
1864fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1865    // Wrap-text height depends on width, so constrain the intrinsic
1866    // measurement to the width the child will actually be laid out at
1867    // — same shape as `child_intrinsic` does for column/row children.
1868    // Without this, a Fixed-width modal with a wrappable paragraph
1869    // measures as a single-line block and the modal's Hug height ends
1870    // up shorter than the actual content needs, eating bottom padding.
1871    let constrained_width = match c.width {
1872        Size::Fixed(v) => Some(v),
1873        Size::Fill(_) | Size::Hug => Some(parent.w),
1874    };
1875    let (iw, ih) = intrinsic_constrained(c, constrained_width);
1876    let w = match c.width {
1877        Size::Fixed(v) => v,
1878        Size::Hug => iw.min(parent.w),
1879        Size::Fill(_) => parent.w,
1880    };
1881    let h = match c.height {
1882        Size::Fixed(v) => v,
1883        Size::Hug => ih.min(parent.h),
1884        Size::Fill(_) => parent.h,
1885    };
1886    let w = clamp_w(c, w);
1887    let h = clamp_h(c, h);
1888    let x = match align {
1889        Align::Start | Align::Stretch => parent.x,
1890        Align::Center => parent.x + (parent.w - w) * 0.5,
1891        Align::End => parent.right() - w,
1892    };
1893    let y = match justify {
1894        Justify::Start | Justify::SpaceBetween => parent.y,
1895        Justify::Center => parent.y + (parent.h - h) * 0.5,
1896        Justify::End => parent.bottom() - h,
1897    };
1898    Rect::new(x, y, w, h)
1899}
1900
1901/// Intrinsic (width, height) for hugging layouts.
1902pub fn intrinsic(c: &El) -> (f32, f32) {
1903    intrinsic_constrained(c, None)
1904}
1905
1906fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1907    let key = intrinsic_cache_key(c, available_width);
1908    if let Some(key) = &key
1909        && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
1910            let mut slot = cell.borrow_mut();
1911            let cache = slot.as_mut()?;
1912            let cached = cache.measurements.get(key).copied();
1913            if cached.is_some() {
1914                cache.stats.hits += 1;
1915            }
1916            cached
1917        })
1918    {
1919        return cached;
1920    }
1921
1922    if key.is_some() {
1923        INTRINSIC_CACHE.with(|cell| {
1924            if let Some(cache) = cell.borrow_mut().as_mut() {
1925                cache.stats.misses += 1;
1926            }
1927        });
1928    }
1929
1930    let measured = intrinsic_constrained_uncached(c, available_width);
1931
1932    if let Some(key) = key {
1933        INTRINSIC_CACHE.with(|cell| {
1934            if let Some(cache) = cell.borrow_mut().as_mut() {
1935                cache.measurements.insert(key, measured);
1936            }
1937        });
1938    }
1939
1940    measured
1941}
1942
1943fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
1944    if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
1945        return None;
1946    }
1947    if c.computed_id.is_empty() {
1948        return None;
1949    }
1950    Some(IntrinsicCacheKey {
1951        computed_id: c.computed_id.clone(),
1952        available_width_bits: available_width.map(f32::to_bits),
1953    })
1954}
1955
1956fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
1957    if c.layout_override.is_some() {
1958        // Custom-layout nodes don't define an intrinsic. Authors must
1959        // size them with `Fixed` or `Fill` on both axes; the returned
1960        // (0.0, 0.0) is replaced by `apply_min` for `Fixed` and is
1961        // unread for `Fill` (parent's distribution decides).
1962        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1963            panic!(
1964                "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1965                 Size::Hug is not supported for custom layouts",
1966                c.computed_id,
1967            );
1968        }
1969        return apply_min(c, 0.0, 0.0);
1970    }
1971    if c.virtual_items.is_some() {
1972        // VirtualList sizes the whole viewport (the parent decides) and
1973        // realizes only on-screen rows. Hug-sizing it would mean
1974        // "shrink to fit all rows", defeating virtualization. Same
1975        // shape as the layout_override guard.
1976        if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1977            panic!(
1978                "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1979                 Size::Hug would defeat virtualization",
1980                c.computed_id,
1981            );
1982        }
1983        return apply_min(c, 0.0, 0.0);
1984    }
1985    if matches!(c.kind, Kind::Inlines) {
1986        return inline_paragraph_intrinsic(c, available_width);
1987    }
1988    if matches!(c.kind, Kind::HardBreak) {
1989        // HardBreak is meaningful only inside Inlines (where draw_ops
1990        // encodes it as `\n` in the attributed text). Outside Inlines
1991        // it's a no-op layout-wise.
1992        return apply_min(c, 0.0, 0.0);
1993    }
1994    if matches!(c.kind, Kind::Math) {
1995        if let Some(expr) = &c.math {
1996            let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
1997            return apply_min(
1998                c,
1999                layout.width + c.padding.left + c.padding.right,
2000                layout.height() + c.padding.top + c.padding.bottom,
2001            );
2002        }
2003        return apply_min(c, 0.0, 0.0);
2004    }
2005    if c.icon.is_some() {
2006        return apply_min(
2007            c,
2008            c.font_size + c.padding.left + c.padding.right,
2009            c.font_size + c.padding.top + c.padding.bottom,
2010        );
2011    }
2012    if let Some(img) = &c.image {
2013        // Natural pixel size as a logical-pixel intrinsic. Authors who
2014        // want a different sized box set `.width()` / `.height()`;
2015        // the projection inside that box is decided by `image_fit`.
2016        let w = img.width() as f32 + c.padding.left + c.padding.right;
2017        let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2018        return apply_min(c, w, h);
2019    }
2020    if let Some(text) = &c.text {
2021        let content_available = match c.text_wrap {
2022            TextWrap::NoWrap => None,
2023            TextWrap::Wrap => available_width
2024                .or(match c.width {
2025                    Size::Fixed(v) => Some(v),
2026                    Size::Fill(_) | Size::Hug => None,
2027                })
2028                .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2029        };
2030        let display = display_text_for_measure(c, text, content_available);
2031        let layout = text_metrics::layout_text_with_line_height_and_family(
2032            &display,
2033            c.font_size,
2034            c.line_height,
2035            c.font_family,
2036            c.font_weight,
2037            c.font_mono,
2038            c.text_wrap,
2039            content_available,
2040        );
2041        let w = match (content_available, c.width) {
2042            (Some(available), Size::Hug) => {
2043                let unwrapped = text_metrics::layout_text_with_family(
2044                    text,
2045                    c.font_size,
2046                    c.font_family,
2047                    c.font_weight,
2048                    c.font_mono,
2049                    TextWrap::NoWrap,
2050                    None,
2051                );
2052                unwrapped.width.min(available) + c.padding.left + c.padding.right
2053            }
2054            (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2055                available + c.padding.left + c.padding.right
2056            }
2057            (None, _) => layout.width + c.padding.left + c.padding.right,
2058        };
2059        let h = layout.height + c.padding.top + c.padding.bottom;
2060        return apply_min(c, w, h);
2061    }
2062    match c.axis {
2063        Axis::Overlay => {
2064            let mut w: f32 = 0.0;
2065            let mut h: f32 = 0.0;
2066            for ch in &c.children {
2067                let child_available =
2068                    available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2069                let (cw, chh) = intrinsic_constrained(ch, child_available);
2070                w = w.max(cw);
2071                h = h.max(chh);
2072            }
2073            apply_min(
2074                c,
2075                w + c.padding.left + c.padding.right,
2076                h + c.padding.top + c.padding.bottom,
2077            )
2078        }
2079        Axis::Column => {
2080            let mut w: f32 = 0.0;
2081            let mut h: f32 = c.padding.top + c.padding.bottom;
2082            let n = c.children.len();
2083            let child_available =
2084                available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2085            for (i, ch) in c.children.iter().enumerate() {
2086                let (cw, chh) = intrinsic_constrained(ch, child_available);
2087                w = w.max(cw);
2088                h += chh;
2089                if i + 1 < n {
2090                    h += c.gap;
2091                }
2092            }
2093            apply_min(c, w + c.padding.left + c.padding.right, h)
2094        }
2095        Axis::Row => {
2096            // Two-pass measurement so that wrappable Fill children see
2097            // the width they will actually be laid out at. Without
2098            // this, a `Size::Fill` paragraph inside a row falls through
2099            // `inline_paragraph_intrinsic`'s `available_width` fallback
2100            // with `None` and reports its unwrapped single-line height
2101            // — the row then under-reserves vertical space and the
2102            // wrapped text overflows downward into the next row. This
2103            // mirrors how `layout_axis` (the runtime pass) already
2104            // splits Resolved vs. Fill main-axis sizing.
2105            let n = c.children.len();
2106            let total_gap = c.gap * n.saturating_sub(1) as f32;
2107            let inner_available = available_width
2108                .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2109
2110            // First pass: Fixed and Hug children measure unconstrained.
2111            // Fixed-width wrappable children self-resolve their wrap
2112            // width via `inline_paragraph_intrinsic`'s own Fixed
2113            // fallback; Hug children take their natural width. We only
2114            // need to feed an explicit available width to Fill.
2115            let mut consumed: f32 = 0.0;
2116            let mut fill_weight_total: f32 = 0.0;
2117            let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2118            for ch in &c.children {
2119                match ch.width {
2120                    Size::Fill(w) => {
2121                        fill_weight_total += w.max(0.001);
2122                        sizes.push(None);
2123                    }
2124                    _ => {
2125                        let (cw, chh) = intrinsic(ch);
2126                        consumed += cw;
2127                        sizes.push(Some((cw, chh)));
2128                    }
2129                }
2130            }
2131
2132            // Second pass: distribute the leftover among Fill children
2133            // by weight and remeasure each with its share. Without an
2134            // available_width hint (row inside a Hug ancestor with no
2135            // outer constraint) we fall back to unconstrained
2136            // measurement — same lossy shape as the prior code, but
2137            // limited to the case where there's genuinely no width to
2138            // distribute.
2139            let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2140            let mut w_total: f32 = c.padding.left + c.padding.right;
2141            let mut h_max: f32 = 0.0;
2142            for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2143                let (cw, chh) = match slot {
2144                    Some(rc) => rc,
2145                    None => match (fill_remaining, fill_weight_total > 0.0) {
2146                        (Some(av), true) => {
2147                            let weight = match ch.width {
2148                                Size::Fill(w) => w.max(0.001),
2149                                _ => 1.0,
2150                            };
2151                            intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2152                        }
2153                        _ => intrinsic(ch),
2154                    },
2155                };
2156                w_total += cw;
2157                if i + 1 < n {
2158                    w_total += c.gap;
2159                }
2160                h_max = h_max.max(chh);
2161            }
2162            apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2163        }
2164    }
2165}
2166
2167pub(crate) fn text_layout(
2168    c: &El,
2169    available_width: Option<f32>,
2170) -> Option<text_metrics::TextLayout> {
2171    let text = c.text.as_ref()?;
2172    let content_available = match c.text_wrap {
2173        TextWrap::NoWrap => None,
2174        TextWrap::Wrap => available_width
2175            .or(match c.width {
2176                Size::Fixed(v) => Some(v),
2177                Size::Fill(_) | Size::Hug => None,
2178            })
2179            .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2180    };
2181    let display = display_text_for_measure(c, text, content_available);
2182    Some(text_metrics::layout_text_with_line_height_and_family(
2183        &display,
2184        c.font_size,
2185        c.line_height,
2186        c.font_family,
2187        c.font_weight,
2188        c.font_mono,
2189        c.text_wrap,
2190        content_available,
2191    ))
2192}
2193
2194fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2195    if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2196        (c.text_wrap, c.text_max_lines, available_width)
2197    {
2198        text_metrics::clamp_text_to_lines_with_family(
2199            text,
2200            c.font_size,
2201            c.font_family,
2202            c.font_weight,
2203            c.font_mono,
2204            width,
2205            max_lines,
2206        )
2207    } else {
2208        text.to_string()
2209    }
2210}
2211
2212fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2213    if let Size::Fixed(v) = c.width {
2214        w = v;
2215    }
2216    if let Size::Fixed(v) = c.height {
2217        h = v;
2218    }
2219    (clamp_w(c, w), clamp_h(c, h))
2220}
2221
2222/// Apply [`El::min_width`] / [`El::max_width`] to a resolved width,
2223/// matching CSS's `min-width` over `max-width` precedence (when both
2224/// constraints conflict, the lower bound wins). Also clamps to a
2225/// non-negative result so a zero-padding Hug never reports a negative
2226/// intrinsic.
2227pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2228    if let Some(max_w) = c.max_width {
2229        w = w.min(max_w);
2230    }
2231    if let Some(min_w) = c.min_width {
2232        w = w.max(min_w);
2233    }
2234    w.max(0.0)
2235}
2236
2237/// Height-axis companion to [`clamp_w`].
2238pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2239    if let Some(max_h) = c.max_height {
2240        h = h.min(max_h);
2241    }
2242    if let Some(min_h) = c.min_height {
2243        h = h.max(min_h);
2244    }
2245    h.max(0.0)
2246}
2247
2248/// Approximate intrinsic measurement for `Kind::Inlines` paragraphs.
2249///
2250/// The paragraph paints through cosmic-text's rich-text shaping (which
2251/// resolves bold/italic/mono runs against fontdb), but layout needs a
2252/// width and height *before* we get to the renderer. We concatenate
2253/// the runs' text into one string and call `text_metrics::layout_text`
2254/// at the dominant font size — same approximation the lint pass uses
2255/// for single-style text. Bold/italic widths are slightly different
2256/// from regular; for body-text paragraphs that difference is well
2257/// under one wrap-line and we accept it. If a fixture wraps within
2258/// 1-2 characters of a boundary the rendered glyphs may straddle the
2259/// laid-out rect by a fraction of a glyph.
2260fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2261    if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2262        return inline_mixed_intrinsic(node, available_width);
2263    }
2264    let concat = concat_inline_text(&node.children);
2265    let size = inline_paragraph_size(node);
2266    let line_height = inline_paragraph_line_height(node);
2267    let content_available = match node.text_wrap {
2268        TextWrap::NoWrap => None,
2269        TextWrap::Wrap => available_width
2270            .or(match node.width {
2271                Size::Fixed(v) => Some(v),
2272                Size::Fill(_) | Size::Hug => None,
2273            })
2274            .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2275    };
2276    let layout = text_metrics::layout_text_with_line_height_and_family(
2277        &concat,
2278        size,
2279        line_height,
2280        node.font_family,
2281        FontWeight::Regular,
2282        false,
2283        node.text_wrap,
2284        content_available,
2285    );
2286    let w = match (content_available, node.width) {
2287        (Some(available), Size::Hug) => {
2288            let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2289                &concat,
2290                size,
2291                line_height,
2292                node.font_family,
2293                FontWeight::Regular,
2294                false,
2295                TextWrap::NoWrap,
2296                None,
2297            );
2298            unwrapped.width.min(available) + node.padding.left + node.padding.right
2299        }
2300        (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2301            available + node.padding.left + node.padding.right
2302        }
2303        (None, _) => layout.width + node.padding.left + node.padding.right,
2304    };
2305    let h = layout.height + node.padding.top + node.padding.bottom;
2306    apply_min(node, w, h)
2307}
2308
2309fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2310    let wrap_width = match node.text_wrap {
2311        TextWrap::Wrap => available_width.or(match node.width {
2312            Size::Fixed(v) => Some(v),
2313            Size::Fill(_) | Size::Hug => None,
2314        }),
2315        TextWrap::NoWrap => None,
2316    }
2317    .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2318
2319    let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2320        node.text_wrap,
2321        wrap_width,
2322        node.font_size * 0.82,
2323        node.font_size * 0.22,
2324        node.line_height,
2325    );
2326
2327    for child in &node.children {
2328        match child.kind {
2329            Kind::HardBreak => {
2330                breaker.finish_line();
2331                continue;
2332            }
2333            Kind::Text => {
2334                let text = child.text.as_deref().unwrap_or("");
2335                for chunk in inline_text_chunks(text) {
2336                    let is_space = chunk.chars().all(char::is_whitespace);
2337                    if breaker.skips_leading_space(is_space) {
2338                        continue;
2339                    }
2340                    let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2341                    if breaker.wraps_before(is_space, w) {
2342                        breaker.finish_line();
2343                    }
2344                    if breaker.skips_overflowing_space(is_space, w) {
2345                        continue;
2346                    }
2347                    breaker.push(w, ascent, descent);
2348                }
2349                continue;
2350            }
2351            _ => {}
2352        }
2353        let (w, ascent, descent) = inline_child_metrics(child);
2354        if breaker.wraps_before(false, w) {
2355            breaker.finish_line();
2356        }
2357        breaker.push(w, ascent, descent);
2358    }
2359    let measurement = breaker.finish();
2360    let w = measurement.width + node.padding.left + node.padding.right;
2361    let h = measurement.height + node.padding.top + node.padding.bottom;
2362    apply_min(node, w, h)
2363}
2364
2365fn inline_text_chunks(text: &str) -> Vec<&str> {
2366    let mut chunks = Vec::new();
2367    let mut start = 0;
2368    let mut last_space = None;
2369    for (i, ch) in text.char_indices() {
2370        let is_space = ch.is_whitespace();
2371        match last_space {
2372            None => last_space = Some(is_space),
2373            Some(prev) if prev != is_space => {
2374                chunks.push(&text[start..i]);
2375                start = i;
2376                last_space = Some(is_space);
2377            }
2378            _ => {}
2379        }
2380    }
2381    if start < text.len() {
2382        chunks.push(&text[start..]);
2383    }
2384    chunks
2385}
2386
2387fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2388    let layout = text_metrics::layout_text_with_line_height_and_family(
2389        text,
2390        child.font_size,
2391        child.line_height,
2392        child.font_family,
2393        child.font_weight,
2394        child.font_mono,
2395        TextWrap::NoWrap,
2396        None,
2397    );
2398    (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2399}
2400
2401fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2402    match child.kind {
2403        Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2404        Kind::Math => {
2405            if let Some(expr) = &child.math {
2406                let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2407                (layout.width, layout.ascent, layout.descent)
2408            } else {
2409                (0.0, 0.0, 0.0)
2410            }
2411        }
2412        _ => (0.0, 0.0, 0.0),
2413    }
2414}
2415
2416/// Walk an Inlines paragraph's children and produce the source-order
2417/// concatenation that draw_ops will hand to the atlas. `Kind::Text`
2418/// contributes its `text` field; `Kind::HardBreak` contributes a
2419/// newline; anything else contributes nothing (an unsupported child
2420/// kind inside Inlines is a programmer error elsewhere — measurement
2421/// silently ignores it).
2422fn concat_inline_text(children: &[El]) -> String {
2423    let mut s = String::new();
2424    for c in children {
2425        match c.kind {
2426            Kind::Text => {
2427                if let Some(t) = &c.text {
2428                    s.push_str(t);
2429                }
2430            }
2431            Kind::HardBreak => s.push('\n'),
2432            _ => {}
2433        }
2434    }
2435    s
2436}
2437
2438/// Pick the font size that drives the paragraph's measurement. We use
2439/// the maximum across text children rather than the parent's own
2440/// `font_size`, because builders set sizes on the leaf text nodes.
2441fn inline_paragraph_size(node: &El) -> f32 {
2442    let mut size: f32 = node.font_size;
2443    for c in &node.children {
2444        if matches!(c.kind, Kind::Text) {
2445            size = size.max(c.font_size);
2446        }
2447    }
2448    size
2449}
2450
2451fn inline_paragraph_line_height(node: &El) -> f32 {
2452    let mut line_height: f32 = node.line_height;
2453    let mut max_size: f32 = node.font_size;
2454    for c in &node.children {
2455        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2456            max_size = c.font_size;
2457            line_height = c.line_height;
2458        }
2459    }
2460    line_height
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465    use super::*;
2466    use crate::state::UiState;
2467
2468    /// CSS-flex parity: a `Size::Fill` child of a column with
2469    /// `align(Center)` should shrink to its intrinsic cross-axis size
2470    /// and be horizontally centered, matching `align-items: center`
2471    /// in CSS flex (which causes flex items to lose their stretch).
2472    #[test]
2473    fn align_center_shrinks_fill_child_to_intrinsic() {
2474        // Column with align(Center). Inner row has the default
2475        // El::new width = Fill(1.0); without Proposal B it would
2476        // claim the full 200px and align would be a no-op.
2477        let mut root = column([crate::row([crate::widgets::text::text("hi")
2478            .width(Size::Fixed(40.0))
2479            .height(Size::Fixed(20.0))])])
2480        .align(Align::Center)
2481        .width(Size::Fixed(200.0))
2482        .height(Size::Fixed(100.0));
2483        let mut state = UiState::new();
2484        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2485        let row_rect = state.rect(&root.children[0].computed_id);
2486        // Row's intrinsic width = 40 (single fixed child). 200 - 40 = 160
2487        // leftover; centered → row starts at x=80.
2488        assert!(
2489            (row_rect.x - 80.0).abs() < 0.5,
2490            "expected x≈80 (centered), got {}",
2491            row_rect.x
2492        );
2493        assert!(
2494            (row_rect.w - 40.0).abs() < 0.5,
2495            "expected w≈40 (shrunk to intrinsic), got {}",
2496            row_rect.w
2497        );
2498    }
2499
2500    /// `align(Stretch)` (the default) preserves Fill stretching: a
2501    /// Fill-width child still claims the full cross axis.
2502    #[test]
2503    fn align_stretch_preserves_fill_stretch() {
2504        let mut root = column([crate::row([crate::widgets::text::text("hi")
2505            .width(Size::Fixed(40.0))
2506            .height(Size::Fixed(20.0))])])
2507        .align(Align::Stretch)
2508        .width(Size::Fixed(200.0))
2509        .height(Size::Fixed(100.0));
2510        let mut state = UiState::new();
2511        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2512        let row_rect = state.rect(&root.children[0].computed_id);
2513        assert!(
2514            (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2515            "expected stretched (x=0, w=200), got x={} w={}",
2516            row_rect.x,
2517            row_rect.w
2518        );
2519    }
2520
2521    /// When all children are Hug-sized, `Justify::Center` should split
2522    /// the leftover space symmetrically across the main axis.
2523    #[test]
2524    fn justify_center_centers_hug_children() {
2525        let mut root = column([crate::widgets::text::text("hi")
2526            .width(Size::Fixed(40.0))
2527            .height(Size::Fixed(20.0))])
2528        .justify(Justify::Center)
2529        .height(Size::Fill(1.0));
2530        let mut state = UiState::new();
2531        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2532        let child_rect = state.rect(&root.children[0].computed_id);
2533        // Expected: 100 - 20 = 80 leftover; centered → starts at y=40.
2534        assert!(
2535            (child_rect.y - 40.0).abs() < 0.5,
2536            "expected y≈40, got {}",
2537            child_rect.y
2538        );
2539    }
2540
2541    #[test]
2542    fn justify_end_pushes_to_bottom() {
2543        let mut root = column([crate::widgets::text::text("hi")
2544            .width(Size::Fixed(40.0))
2545            .height(Size::Fixed(20.0))])
2546        .justify(Justify::End)
2547        .height(Size::Fill(1.0));
2548        let mut state = UiState::new();
2549        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2550        let child_rect = state.rect(&root.children[0].computed_id);
2551        assert!(
2552            (child_rect.y - 80.0).abs() < 0.5,
2553            "expected y≈80, got {}",
2554            child_rect.y
2555        );
2556    }
2557
2558    /// CSS `justify-content: space-between`: when no main-axis Fill
2559    /// children claim the slack, the leftover space is distributed
2560    /// evenly *between* (not around) the children — outer edges flush.
2561    #[test]
2562    fn justify_space_between_distributes_evenly() {
2563        let row_child = || {
2564            crate::widgets::text::text("x")
2565                .width(Size::Fixed(20.0))
2566                .height(Size::Fixed(20.0))
2567        };
2568        let mut root = column([row_child(), row_child(), row_child()])
2569            .justify(Justify::SpaceBetween)
2570            .height(Size::Fixed(200.0));
2571        let mut state = UiState::new();
2572        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2573        // Used main = 3 * 20 = 60. Leftover = 140 over (n-1) = 2 gaps
2574        // → 70 between. Positions: 0, 90, 180.
2575        let y0 = state.rect(&root.children[0].computed_id).y;
2576        let y1 = state.rect(&root.children[1].computed_id).y;
2577        let y2 = state.rect(&root.children[2].computed_id).y;
2578        assert!(
2579            y0.abs() < 0.5,
2580            "first child should be flush at y=0, got {y0}"
2581        );
2582        assert!(
2583            (y1 - 90.0).abs() < 0.5,
2584            "middle child should be at y≈90, got {y1}"
2585        );
2586        assert!(
2587            (y2 - 180.0).abs() < 0.5,
2588            "last child should be flush at y≈180, got {y2}"
2589        );
2590    }
2591
2592    /// CSS `flex: <weight>`: when multiple `Size::Fill` children share
2593    /// a container, the available space is distributed in proportion
2594    /// to their weights.
2595    #[test]
2596    fn fill_weight_distributes_proportionally() {
2597        let big = crate::widgets::text::text("big")
2598            .width(Size::Fixed(40.0))
2599            .height(Size::Fill(2.0));
2600        let small = crate::widgets::text::text("small")
2601            .width(Size::Fixed(40.0))
2602            .height(Size::Fill(1.0));
2603        let mut root = column([big, small]).height(Size::Fixed(300.0));
2604        let mut state = UiState::new();
2605        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2606        // Total weight = 3, available = 300. Big = 200, small = 100.
2607        let big_h = state.rect(&root.children[0].computed_id).h;
2608        let small_h = state.rect(&root.children[1].computed_id).h;
2609        assert!(
2610            (big_h - 200.0).abs() < 0.5,
2611            "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2612        );
2613        assert!(
2614            (small_h - 100.0).abs() < 0.5,
2615            "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2616        );
2617    }
2618
2619    /// `padding` on a `Hug`-sized container is included in the
2620    /// container's intrinsic — matching CSS `box-sizing: content-box`
2621    /// where padding adds to the rendered size.
2622    #[test]
2623    fn padding_on_hug_includes_in_intrinsic() {
2624        let root = column([crate::widgets::text::text("x")
2625            .width(Size::Fixed(40.0))
2626            .height(Size::Fixed(40.0))])
2627        .padding(Sides::all(20.0));
2628        let (w, h) = intrinsic(&root);
2629        // 40 content + 2*20 padding on each axis = 80.
2630        assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2631        assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2632    }
2633
2634    /// Cross-axis `Align::End` on a row pins children to the bottom
2635    /// edge — CSS `align-items: flex-end`. Mirror of `justify_end`
2636    /// but on the cross axis instead of the main axis.
2637    #[test]
2638    fn align_end_pins_to_cross_axis_far_edge() {
2639        let mut root = crate::row([crate::widgets::text::text("hi")
2640            .width(Size::Fixed(40.0))
2641            .height(Size::Fixed(20.0))])
2642        .align(Align::End)
2643        .width(Size::Fixed(200.0))
2644        .height(Size::Fixed(100.0));
2645        let mut state = UiState::new();
2646        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2647        let child_rect = state.rect(&root.children[0].computed_id);
2648        // Row cross axis = height. End → child y = 100 - 20 = 80.
2649        assert!(
2650            (child_rect.y - 80.0).abs() < 0.5,
2651            "expected y≈80 (pinned to bottom), got {}",
2652            child_rect.y
2653        );
2654    }
2655
2656    #[test]
2657    fn overlay_can_center_hug_child() {
2658        let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2659            .width(Size::Fixed(200.0))
2660            .height(Size::Hug)])
2661        .align(Align::Center)
2662        .justify(Justify::Center);
2663        let mut state = UiState::new();
2664        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2665        let child_rect = state.rect(&root.children[0].computed_id);
2666        assert!(
2667            (child_rect.x - 200.0).abs() < 0.5,
2668            "expected x≈200, got {}",
2669            child_rect.x
2670        );
2671        assert!(
2672            child_rect.y > 100.0 && child_rect.y < 200.0,
2673            "expected centered y, got {}",
2674            child_rect.y
2675        );
2676    }
2677
2678    #[test]
2679    fn scroll_offset_translates_children_and_clamps_to_content() {
2680        // Six 50px-tall rows in a 200px-tall scroll viewport.
2681        // Content height = 6 * 50 + 5 * 12 (gap) = 360 px. Visible
2682        // viewport (no padding) = 200 px → max_offset = 160.
2683        let mut root = scroll(
2684            (0..6)
2685                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2686        )
2687        .key("list")
2688        .gap(12.0)
2689        .height(Size::Fixed(200.0));
2690        let mut state = UiState::new();
2691        assign_ids(&mut root);
2692        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2693
2694        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2695
2696        // Offset is in range, applied verbatim.
2697        let stored = state
2698            .scroll
2699            .offsets
2700            .get(&root.computed_id)
2701            .copied()
2702            .unwrap_or(0.0);
2703        assert!(
2704            (stored - 80.0).abs() < 0.01,
2705            "offset clamped unexpectedly: {stored}"
2706        );
2707        // First child shifted up by 80.
2708        let c0 = state.rect(&root.children[0].computed_id);
2709        assert!(
2710            (c0.y - (-80.0)).abs() < 0.01,
2711            "child 0 y = {} (expected -80)",
2712            c0.y
2713        );
2714        // Now overshoot — should clamp to max_offset=160.
2715        state
2716            .scroll
2717            .offsets
2718            .insert(root.computed_id.clone(), 9999.0);
2719        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2720        let stored = state
2721            .scroll
2722            .offsets
2723            .get(&root.computed_id)
2724            .copied()
2725            .unwrap_or(0.0);
2726        assert!(
2727            (stored - 160.0).abs() < 0.01,
2728            "overshoot clamped to {stored}"
2729        );
2730        // Content fits → offset clamps to 0.
2731        let mut tiny =
2732            scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
2733                .height(Size::Fixed(200.0));
2734        let mut tiny_state = UiState::new();
2735        assign_ids(&mut tiny);
2736        tiny_state
2737            .scroll
2738            .offsets
2739            .insert(tiny.computed_id.clone(), 50.0);
2740        layout(
2741            &mut tiny,
2742            &mut tiny_state,
2743            Rect::new(0.0, 0.0, 300.0, 200.0),
2744        );
2745        assert_eq!(
2746            tiny_state
2747                .scroll
2748                .offsets
2749                .get(&tiny.computed_id)
2750                .copied()
2751                .unwrap_or(0.0),
2752            0.0
2753        );
2754    }
2755
2756    #[test]
2757    fn scroll_layout_prunes_far_offscreen_descendants() {
2758        let far = column([crate::widgets::text::text("far row body").key("far-text")])
2759            .height(Size::Fixed(40.0));
2760        let mut root = scroll([
2761            column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
2762            crate::tree::spacer().height(Size::Fixed(400.0)),
2763            far,
2764        ])
2765        .key("list")
2766        .height(Size::Fixed(80.0));
2767        let mut state = UiState::new();
2768        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
2769        let stats = take_prune_stats();
2770
2771        assert!(
2772            stats.subtrees >= 1,
2773            "expected at least one far scroll child to be pruned, got {stats:?}"
2774        );
2775        assert!(
2776            stats.nodes >= 1,
2777            "expected pruned descendants to be zeroed, got {stats:?}"
2778        );
2779        let far_text = state
2780            .rect_of_key(&root, "far-text")
2781            .expect("far text keeps a zero rect while pruned");
2782        assert_eq!(far_text.w, 0.0);
2783        assert_eq!(far_text.h, 0.0);
2784    }
2785
2786    #[test]
2787    fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
2788        let make_root = || {
2789            let paragraph_text = "Variable width text wraps into a different number of lines when \
2790                                  the viewport narrows, which used to make a plain scroll box lose \
2791                                  the item the user was reading.";
2792            scroll([column((0..30).map(|i| {
2793                crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
2794                    .key(format!("paragraph-{i}"))
2795            }))
2796            .gap(8.0)])
2797            .key("article")
2798            .height(Size::Fixed(180.0))
2799        };
2800
2801        let mut root = make_root();
2802        let mut state = UiState::new();
2803        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
2804
2805        state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
2806        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
2807
2808        let anchor = state
2809            .scroll
2810            .scroll_anchors
2811            .get(&root.computed_id)
2812            .cloned()
2813            .expect("plain scroll should store a visible descendant anchor");
2814        let before_rect = state.rect(&anchor.node_id);
2815        let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
2816        let before_offset = state.scroll_offset(&root.computed_id);
2817
2818        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
2819
2820        let after_rect = state.rect(&anchor.node_id);
2821        let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
2822        let after_offset = state.scroll_offset(&root.computed_id);
2823        assert!(
2824            (after_anchor_y - before_anchor_y).abs() < 0.5,
2825            "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
2826        );
2827        assert!(
2828            (after_offset - before_offset).abs() > 20.0,
2829            "offset should absorb height changes above the anchor"
2830        );
2831    }
2832
2833    #[test]
2834    fn scrollbar_thumb_size_and_position_track_overflow() {
2835        // 6 rows x 50px + 5 gaps x 12 = 360 content; 200 viewport.
2836        // viewport/content = 200/360 ≈ 0.555 → thumb_h ≈ 111.1.
2837        let mut root = scroll(
2838            (0..6)
2839                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2840        )
2841        .gap(12.0)
2842        .height(Size::Fixed(200.0));
2843        let mut state = UiState::new();
2844        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2845
2846        let metrics = state
2847            .scroll
2848            .metrics
2849            .get(&root.computed_id)
2850            .copied()
2851            .expect("scrollable should have metrics");
2852        assert!((metrics.viewport_h - 200.0).abs() < 0.01);
2853        assert!((metrics.content_h - 360.0).abs() < 0.01);
2854        assert!((metrics.max_offset - 160.0).abs() < 0.01);
2855
2856        let thumb = state
2857            .scroll
2858            .thumb_rects
2859            .get(&root.computed_id)
2860            .copied()
2861            .expect("scrollable with scrollbar() and overflow gets a thumb");
2862        // viewport^2 / content_h = 200^2 / 360 = 111.11..
2863        assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
2864        assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
2865        // At offset 0, thumb sits at the top of the inner rect.
2866        assert!(thumb.y.abs() < 0.01);
2867        // Right-anchored: thumb_x + thumb_w + track_inset == viewport_right.
2868        assert!(
2869            (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
2870            "thumb anchored at {} (expected {})",
2871            thumb.x,
2872            300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
2873        );
2874
2875        // Slide to half — thumb should be at half the track_remaining.
2876        state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2877        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2878        let thumb = state
2879            .scroll
2880            .thumb_rects
2881            .get(&root.computed_id)
2882            .copied()
2883            .unwrap();
2884        let track_remaining = 200.0 - thumb.h;
2885        let expected_y = track_remaining * (80.0 / 160.0);
2886        assert!(
2887            (thumb.y - expected_y).abs() < 0.5,
2888            "thumb at half-scroll y = {} (expected {expected_y})",
2889            thumb.y,
2890        );
2891    }
2892
2893    #[test]
2894    fn scrollbar_track_is_wider_than_thumb_and_full_height() {
2895        // The track is the click hitbox: wider than the visible
2896        // thumb (Fitts's law) and tall enough to detect track
2897        // clicks above and below the thumb for paging.
2898        let mut root = scroll(
2899            (0..6)
2900                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2901        )
2902        .gap(12.0)
2903        .height(Size::Fixed(200.0));
2904        let mut state = UiState::new();
2905        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2906
2907        let thumb = state
2908            .scroll
2909            .thumb_rects
2910            .get(&root.computed_id)
2911            .copied()
2912            .unwrap();
2913        let track = state
2914            .scroll
2915            .thumb_tracks
2916            .get(&root.computed_id)
2917            .copied()
2918            .unwrap();
2919        // Track wider than thumb on the same right edge.
2920        assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
2921        assert!(
2922            (track.right() - thumb.right()).abs() < 0.01,
2923            "track and thumb must share the right edge",
2924        );
2925        // Track spans the full inner viewport (so above/below thumb
2926        // are both inside it for click-to-page).
2927        assert!(
2928            (track.h - 200.0).abs() < 0.01,
2929            "track height = {} (expected 200)",
2930            track.h,
2931        );
2932    }
2933
2934    #[test]
2935    fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
2936        // Same scrollable, but author opted out — no thumb_rect.
2937        let mut suppressed = scroll(
2938            (0..6)
2939                .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2940        )
2941        .no_scrollbar()
2942        .height(Size::Fixed(200.0));
2943        let mut state = UiState::new();
2944        layout(
2945            &mut suppressed,
2946            &mut state,
2947            Rect::new(0.0, 0.0, 300.0, 200.0),
2948        );
2949        assert!(
2950            !state
2951                .scroll
2952                .thumb_rects
2953                .contains_key(&suppressed.computed_id)
2954        );
2955
2956        // Same scrollable, content fits → no thumb either.
2957        let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
2958            .height(Size::Fixed(200.0));
2959        let mut tiny_state = UiState::new();
2960        layout(
2961            &mut tiny,
2962            &mut tiny_state,
2963            Rect::new(0.0, 0.0, 300.0, 200.0),
2964        );
2965        assert!(
2966            !tiny_state
2967                .scroll
2968                .thumb_rects
2969                .contains_key(&tiny.computed_id)
2970        );
2971    }
2972
2973    #[test]
2974    fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
2975        let make_root = || {
2976            scroll([
2977                crate::tree::spacer().height(Size::Fixed(80.0)),
2978                scroll((0..6).map(|i| {
2979                    crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
2980                }))
2981                .key("inner")
2982                .height(Size::Fixed(120.0)),
2983                crate::tree::spacer().height(Size::Fixed(260.0)),
2984            ])
2985            .key("outer")
2986            .height(Size::Fixed(220.0))
2987        };
2988
2989        let mut root = make_root();
2990        let mut state = UiState::new();
2991        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
2992        let inner = root
2993            .children
2994            .iter()
2995            .find(|child| child.key.as_deref() == Some("inner"))
2996            .expect("inner scroll");
2997        let inner_id = inner.computed_id.clone();
2998        let inner_rect = state.rect(&inner_id);
2999        let thumb = state
3000            .scroll
3001            .thumb_rects
3002            .get(&inner_id)
3003            .copied()
3004            .expect("inner scroll should have a thumb");
3005        let track = state
3006            .scroll
3007            .thumb_tracks
3008            .get(&inner_id)
3009            .copied()
3010            .expect("inner scroll should have a track");
3011        let thumb_rel_y = thumb.y - inner_rect.y;
3012        let track_rel_y = track.y - inner_rect.y;
3013
3014        state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3015        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3016        let inner_rect_after = state.rect(&inner_id);
3017        let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3018        let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3019
3020        assert!(
3021            (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3022            "outer scroll should shift the inner viewport"
3023        );
3024        assert!(
3025            (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3026            "inner thumb should stay fixed relative to its viewport"
3027        );
3028        assert!(
3029            (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3030            "inner track should stay fixed relative to its viewport"
3031        );
3032    }
3033
3034    #[test]
3035    fn layout_override_places_children_at_returned_rects() {
3036        // A custom layout that just stacks children diagonally inside the container.
3037        let mut root = column((0..3).map(|i| {
3038            crate::widgets::text::text(format!("dot {i}"))
3039                .width(Size::Fixed(20.0))
3040                .height(Size::Fixed(20.0))
3041        }))
3042        .width(Size::Fixed(200.0))
3043        .height(Size::Fixed(200.0))
3044        .layout(|ctx| {
3045            ctx.children
3046                .iter()
3047                .enumerate()
3048                .map(|(i, _)| {
3049                    let off = i as f32 * 30.0;
3050                    Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3051                })
3052                .collect()
3053        });
3054        let mut state = UiState::new();
3055        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3056        let r0 = state.rect(&root.children[0].computed_id);
3057        let r1 = state.rect(&root.children[1].computed_id);
3058        let r2 = state.rect(&root.children[2].computed_id);
3059        assert_eq!((r0.x, r0.y), (0.0, 0.0));
3060        assert_eq!((r1.x, r1.y), (30.0, 30.0));
3061        assert_eq!((r2.x, r2.y), (60.0, 60.0));
3062    }
3063
3064    #[test]
3065    fn layout_override_rect_of_key_resolves_earlier_sibling() {
3066        // The popover-anchor pattern: a custom-laid-out node positions
3067        // its child by reading another keyed node's rect via the new
3068        // LayoutCtx::rect_of_key callback. The trigger lives in an
3069        // earlier sibling so its rect is already in `computed_rects`
3070        // by the time the popover layer's layout_override runs.
3071        use crate::tree::stack;
3072        let trigger_x = 40.0;
3073        let trigger_y = 20.0;
3074        let trigger_w = 60.0;
3075        let trigger_h = 30.0;
3076        let mut root = stack([
3077            // Earlier sibling: the trigger.
3078            crate::widgets::button::button("Open")
3079                .key("trig")
3080                .width(Size::Fixed(trigger_w))
3081                .height(Size::Fixed(trigger_h)),
3082            // Later sibling: a custom-laid-out container that reads
3083            // the trigger's rect to position its single child.
3084            stack([crate::widgets::text::text("popover")
3085                .width(Size::Fixed(80.0))
3086                .height(Size::Fixed(20.0))])
3087            .width(Size::Fill(1.0))
3088            .height(Size::Fill(1.0))
3089            .layout(|ctx| {
3090                let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3091                vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3092            }),
3093        ])
3094        .padding(Sides::xy(trigger_x, trigger_y));
3095        let mut state = UiState::new();
3096        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3097
3098        let popover_layer = &root.children[1];
3099        let panel_id = &popover_layer.children[0].computed_id;
3100        let panel_rect = state.rect(panel_id);
3101        // Anchored to (trigger.x, trigger.bottom() + 4.0). With padding
3102        // (40, 20) and trigger height 30 → expect (40, 54).
3103        assert!(
3104            (panel_rect.x - trigger_x).abs() < 0.01,
3105            "popover x = {} (expected {trigger_x})",
3106            panel_rect.x,
3107        );
3108        assert!(
3109            (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3110            "popover y = {} (expected {})",
3111            panel_rect.y,
3112            trigger_y + trigger_h + 4.0,
3113        );
3114    }
3115
3116    #[test]
3117    fn layout_override_rect_of_key_returns_none_for_missing_key() {
3118        let mut root = column([crate::widgets::text::text("inner")
3119            .width(Size::Fixed(40.0))
3120            .height(Size::Fixed(20.0))])
3121        .width(Size::Fixed(200.0))
3122        .height(Size::Fixed(200.0))
3123        .layout(|ctx| {
3124            assert!((ctx.rect_of_key)("nope").is_none());
3125            vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3126        });
3127        let mut state = UiState::new();
3128        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3129    }
3130
3131    #[test]
3132    fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3133        // First-frame contract: a custom layout running before its
3134        // target's sibling has been laid out should see `None`, not a
3135        // zero rect or a panic. This is what makes the popover pattern
3136        // (trigger first, popover layer second in source order) the
3137        // supported shape — the reverse direction simply gets `None`.
3138        use crate::tree::stack;
3139        let mut root = stack([
3140            stack([crate::widgets::text::text("panel")
3141                .width(Size::Fixed(40.0))
3142                .height(Size::Fixed(20.0))])
3143            .width(Size::Fill(1.0))
3144            .height(Size::Fill(1.0))
3145            .layout(|ctx| {
3146                assert!(
3147                    (ctx.rect_of_key)("later").is_none(),
3148                    "later sibling's rect must not be available yet"
3149                );
3150                vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3151            }),
3152            crate::widgets::button::button("after").key("later"),
3153        ]);
3154        let mut state = UiState::new();
3155        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3156    }
3157
3158    #[test]
3159    fn layout_override_measure_returns_intrinsic() {
3160        // The custom layout reads `measure` to size each child.
3161        let mut root = column([crate::widgets::text::text("hi")
3162            .width(Size::Fixed(40.0))
3163            .height(Size::Fixed(20.0))])
3164        .width(Size::Fixed(200.0))
3165        .height(Size::Fixed(200.0))
3166        .layout(|ctx| {
3167            let (w, h) = (ctx.measure)(&ctx.children[0]);
3168            assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3169            assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3170            vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3171        });
3172        let mut state = UiState::new();
3173        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3174        let r = state.rect(&root.children[0].computed_id);
3175        assert_eq!((r.w, r.h), (40.0, 20.0));
3176    }
3177
3178    #[test]
3179    #[should_panic(expected = "returned 1 rects for 2 children")]
3180    fn layout_override_length_mismatch_panics() {
3181        let mut root = column([
3182            crate::widgets::text::text("a")
3183                .width(Size::Fixed(10.0))
3184                .height(Size::Fixed(10.0)),
3185            crate::widgets::text::text("b")
3186                .width(Size::Fixed(10.0))
3187                .height(Size::Fixed(10.0)),
3188        ])
3189        .width(Size::Fixed(200.0))
3190        .height(Size::Fixed(200.0))
3191        .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3192        let mut state = UiState::new();
3193        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3194    }
3195
3196    #[test]
3197    #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3198    fn layout_override_hug_panics() {
3199        // Hug check fires when the parent's layout pass measures the
3200        // custom-layout child for sizing — i.e. when a layout_override
3201        // node is a child of a column/row, not when it's the root.
3202        let mut root = column([column([crate::widgets::text::text("c")])
3203            .width(Size::Hug)
3204            .height(Size::Fixed(200.0))
3205            .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3206        .width(Size::Fixed(200.0))
3207        .height(Size::Fixed(200.0));
3208        let mut state = UiState::new();
3209        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3210    }
3211
3212    #[test]
3213    fn virtual_list_realizes_only_visible_rows() {
3214        // 100 rows × 50px each in a 200px viewport, offset = 120.
3215        // Visible range: rows whose y in [-50, 200) → start = floor(120/50) = 2,
3216        // end = ceil((120+200)/50) = ceil(6.4) = 7. Five rows realized.
3217        let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3218            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3219        });
3220        let mut state = UiState::new();
3221        assign_ids(&mut root);
3222        state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3223        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3224
3225        assert_eq!(
3226            root.children.len(),
3227            5,
3228            "expected 5 realized rows, got {}",
3229            root.children.len()
3230        );
3231        // Identity check: the first realized row should be the row keyed "row-2".
3232        assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3233        assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3234        // Position check: first realized row's y = inner.y + 2*50 - 120 = -20.
3235        let r0 = state.rect(&root.children[0].computed_id);
3236        assert!(
3237            (r0.y - (-20.0)).abs() < 0.5,
3238            "row 2 expected y≈-20, got {}",
3239            r0.y
3240        );
3241    }
3242
3243    #[test]
3244    fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
3245        let mut root = crate::tree::virtual_list(10, 40.0, |i| {
3246            crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3247        })
3248        .gap(10.0);
3249        let mut state = UiState::new();
3250        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3251
3252        assert_eq!(
3253            root.children.len(),
3254            3,
3255            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3256        );
3257        let row_1 = root
3258            .children
3259            .iter()
3260            .find(|c| c.key.as_deref() == Some("row-1"))
3261            .expect("row 1 should be realized");
3262        assert!(
3263            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3264            "gap should place row 1 at y=50"
3265        );
3266        let metrics = state
3267            .scroll
3268            .metrics
3269            .get(&root.computed_id)
3270            .expect("virtual list writes scroll metrics");
3271        assert!(
3272            (metrics.content_h - 490.0).abs() < 0.5,
3273            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3274            metrics.content_h
3275        );
3276    }
3277
3278    #[test]
3279    fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3280        let make_root = || {
3281            crate::tree::virtual_list(50, 50.0, |i| {
3282                crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3283            })
3284        };
3285
3286        let mut state = UiState::new();
3287        let mut root_a = make_root();
3288        assign_ids(&mut root_a);
3289        // Scroll so row 5 is visible.
3290        state
3291            .scroll
3292            .offsets
3293            .insert(root_a.computed_id.clone(), 250.0);
3294        layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3295        let id_at_offset_a = root_a
3296            .children
3297            .iter()
3298            .find(|c| c.key.as_deref() == Some("row-5"))
3299            .unwrap()
3300            .computed_id
3301            .clone();
3302
3303        // Re-layout with a different offset — row 5 is still visible.
3304        let mut root_b = make_root();
3305        assign_ids(&mut root_b);
3306        state
3307            .scroll
3308            .offsets
3309            .insert(root_b.computed_id.clone(), 200.0);
3310        layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3311        let id_at_offset_b = root_b
3312            .children
3313            .iter()
3314            .find(|c| c.key.as_deref() == Some("row-5"))
3315            .unwrap()
3316            .computed_id
3317            .clone();
3318
3319        assert_eq!(
3320            id_at_offset_a, id_at_offset_b,
3321            "row-5's computed_id changed when scroll offset moved"
3322        );
3323    }
3324
3325    #[test]
3326    fn virtual_list_clamps_overshoot_offset() {
3327        // 10 rows × 50 = 500 content height; viewport 200; max offset = 300.
3328        let mut root =
3329            crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3330        let mut state = UiState::new();
3331        assign_ids(&mut root);
3332        state
3333            .scroll
3334            .offsets
3335            .insert(root.computed_id.clone(), 9999.0);
3336        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3337        let stored = state
3338            .scroll
3339            .offsets
3340            .get(&root.computed_id)
3341            .copied()
3342            .unwrap_or(0.0);
3343        assert!(
3344            (stored - 300.0).abs() < 0.01,
3345            "expected clamp to 300, got {stored}"
3346        );
3347    }
3348
3349    #[test]
3350    fn virtual_list_empty_count_realizes_no_children() {
3351        let mut root =
3352            crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3353        let mut state = UiState::new();
3354        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3355        assert_eq!(root.children.len(), 0);
3356    }
3357
3358    #[test]
3359    #[should_panic(expected = "row_height > 0.0")]
3360    fn virtual_list_zero_row_height_panics() {
3361        let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3362    }
3363
3364    #[test]
3365    #[should_panic(expected = "Size::Hug would defeat virtualization")]
3366    fn virtual_list_hug_panics() {
3367        let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3368            crate::widgets::text::text(format!("r{i}"))
3369        })
3370        .height(Size::Hug)])
3371        .width(Size::Fixed(300.0))
3372        .height(Size::Fixed(200.0));
3373        let mut state = UiState::new();
3374        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3375    }
3376
3377    #[test]
3378    fn virtual_list_dyn_respects_per_row_fixed_heights() {
3379        // Alternating 40px / 80px rows. With a 200px viewport and offset 0,
3380        // accumulated y goes 0, 40, 120, 160, 240 — the fifth row starts
3381        // past the viewport, so four rows are realized.
3382        let mut root = crate::tree::virtual_list_dyn(
3383            20,
3384            50.0,
3385            |i| format!("row-{i}"),
3386            |i| {
3387                let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3388                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3389                    .key(format!("row-{i}"))
3390                    .height(Size::Fixed(h))
3391            },
3392        );
3393        let mut state = UiState::new();
3394        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3395
3396        assert_eq!(
3397            root.children.len(),
3398            4,
3399            "expected 4 realized rows, got {}",
3400            root.children.len()
3401        );
3402        // y positions: row 0 → 0, row 1 → 40, row 2 → 120, row 3 → 160.
3403        let ys: Vec<f32> = root
3404            .children
3405            .iter()
3406            .map(|c| state.rect(&c.computed_id).y)
3407            .collect();
3408        assert!(
3409            (ys[0] - 0.0).abs() < 0.5,
3410            "row 0 expected y≈0, got {}",
3411            ys[0]
3412        );
3413        assert!(
3414            (ys[1] - 40.0).abs() < 0.5,
3415            "row 1 expected y≈40, got {}",
3416            ys[1]
3417        );
3418        assert!(
3419            (ys[2] - 120.0).abs() < 0.5,
3420            "row 2 expected y≈120, got {}",
3421            ys[2]
3422        );
3423        assert!(
3424            (ys[3] - 160.0).abs() < 0.5,
3425            "row 3 expected y≈160, got {}",
3426            ys[3]
3427        );
3428    }
3429
3430    #[test]
3431    fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3432        let mut root = crate::tree::virtual_list_dyn(
3433            10,
3434            40.0,
3435            |i| format!("row-{i}"),
3436            |i| {
3437                crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3438                    .key(format!("row-{i}"))
3439                    .height(Size::Fixed(40.0))
3440            },
3441        )
3442        .gap(10.0);
3443        let mut state = UiState::new();
3444        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3445
3446        assert_eq!(
3447            root.children.len(),
3448            3,
3449            "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3450        );
3451        let row_1 = root
3452            .children
3453            .iter()
3454            .find(|c| c.key.as_deref() == Some("row-1"))
3455            .expect("row 1 should be realized");
3456        assert!(
3457            (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3458            "gap should place row 1 at y=50"
3459        );
3460        let metrics = state
3461            .scroll
3462            .metrics
3463            .get(&root.computed_id)
3464            .expect("virtual list writes scroll metrics");
3465        assert!(
3466            (metrics.content_h - 490.0).abs() < 0.5,
3467            "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3468            metrics.content_h
3469        );
3470    }
3471
3472    #[test]
3473    fn virtual_list_dyn_caches_measured_heights() {
3474        // Build a list where the first frame realizes rows 0..k, measuring
3475        // each. After layout the cache should hold those measurements and
3476        // the next frame should read them.
3477        let mut root = crate::tree::virtual_list_dyn(
3478            50,
3479            50.0,
3480            |i| format!("row-{i}"),
3481            |i| {
3482                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3483                    .key(format!("row-{i}"))
3484                    .height(Size::Fixed(30.0))
3485            },
3486        );
3487        let mut state = UiState::new();
3488        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3489
3490        let measured = state
3491            .scroll
3492            .measured_row_heights
3493            .get(&root.computed_id)
3494            .expect("dynamic virtual list should populate the height cache");
3495        // The first pass measures the estimate-derived window, then
3496        // the anchored final pass can extend it with newly revealed
3497        // rows. At least six rows are visible/cached here.
3498        assert!(
3499            measured.len() >= 6,
3500            "expected ≥ 6 cached row heights, got {}",
3501            measured.len()
3502        );
3503        for by_width in measured.values() {
3504            let h = by_width
3505                .get(&300)
3506                .copied()
3507                .expect("measurement should be keyed at the 300px width bucket");
3508            assert!(
3509                (h - 30.0).abs() < 0.5,
3510                "expected cached height ≈ 30, got {h}"
3511            );
3512        }
3513    }
3514
3515    #[test]
3516    fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3517        let make_root = || {
3518            crate::tree::virtual_list_dyn(
3519                100,
3520                40.0,
3521                |i| format!("row-{i}"),
3522                |i| {
3523                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3524                        .key(format!("row-{i}"))
3525                        .height(Size::Fixed(40.0))
3526                },
3527            )
3528        };
3529        let mut root = make_root();
3530        let mut state = UiState::new();
3531        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3532
3533        state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3534        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3535
3536        let anchor = state
3537            .scroll
3538            .virtual_anchors
3539            .get(&root.computed_id)
3540            .cloned()
3541            .expect("dynamic list should store a visible anchor");
3542        let before_y = root
3543            .children
3544            .iter()
3545            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3546            .map(|child| state.rect(&child.computed_id).y)
3547            .expect("anchor row should be realized");
3548        let before_offset = state.scroll_offset(&root.computed_id);
3549
3550        state
3551            .scroll
3552            .measured_row_heights
3553            .entry(root.computed_id.clone())
3554            .or_default()
3555            .entry("row-0".to_string())
3556            .or_default()
3557            .insert(300, 120.0);
3558
3559        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3560        let after_y = root
3561            .children
3562            .iter()
3563            .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3564            .map(|child| state.rect(&child.computed_id).y)
3565            .expect("anchor row should remain realized");
3566        let after_offset = state.scroll_offset(&root.computed_id);
3567
3568        assert!(
3569            (after_y - before_y).abs() < 0.5,
3570            "anchor row should stay at y={before_y}, got {after_y}"
3571        );
3572        assert!(
3573            (after_offset - (before_offset + 80.0)).abs() < 0.5,
3574            "offset should absorb the 80px measurement delta above anchor"
3575        );
3576    }
3577
3578    #[test]
3579    fn virtual_list_dyn_height_cache_is_width_bucketed() {
3580        let mut root = crate::tree::virtual_list_dyn(
3581            20,
3582            50.0,
3583            |i| format!("row-{i}"),
3584            |i| {
3585                crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3586                    .key(format!("row-{i}"))
3587                    .height(Size::Fixed(30.0))
3588            },
3589        );
3590        let mut state = UiState::new();
3591        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3592        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3593
3594        let row_0 = state
3595            .scroll
3596            .measured_row_heights
3597            .get(&root.computed_id)
3598            .and_then(|m| m.get("row-0"))
3599            .expect("row 0 should be measured");
3600        assert!(
3601            row_0.contains_key(&300) && row_0.contains_key(&240),
3602            "expected width buckets 300 and 240, got {:?}",
3603            row_0.keys().collect::<Vec<_>>()
3604        );
3605    }
3606
3607    #[test]
3608    fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3609        // Measured rows use their cached fixed 30px height; rows that
3610        // have not been seen at this width still use the 50px estimate.
3611        // An overshoot offset must clamp to the mixed measured/estimated
3612        // content height after the final visible measurements land.
3613        let make_root = || {
3614            crate::tree::virtual_list_dyn(
3615                20,
3616                50.0,
3617                |i| format!("row-{i}"),
3618                |i| {
3619                    crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3620                        .key(format!("row-{i}"))
3621                        .height(Size::Fixed(30.0))
3622                },
3623            )
3624        };
3625        let mut state = UiState::new();
3626        let mut root = make_root();
3627        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3628
3629        state
3630            .scroll
3631            .offsets
3632            .insert(root.computed_id.clone(), 9999.0);
3633        let mut root2 = make_root();
3634        layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3635
3636        let measured = state
3637            .scroll
3638            .measured_row_heights
3639            .get(&root2.computed_id)
3640            .expect("dynamic virtual list should populate the height cache");
3641        let measured_sum = measured
3642            .values()
3643            .filter_map(|by_width| by_width.get(&300))
3644            .sum::<f32>();
3645        let measured_count = measured
3646            .values()
3647            .filter(|by_width| by_width.contains_key(&300))
3648            .count();
3649        let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3650        let expected_max_offset = expected_total - 200.0;
3651
3652        let stored = state
3653            .scroll
3654            .offsets
3655            .get(&root2.computed_id)
3656            .copied()
3657            .unwrap_or(0.0);
3658        assert!(
3659            (stored - expected_max_offset).abs() < 0.5,
3660            "expected offset clamped to {expected_max_offset}, got {stored}"
3661        );
3662    }
3663
3664    #[test]
3665    fn virtual_list_dyn_empty_count_realizes_no_children() {
3666        let mut root = crate::tree::virtual_list_dyn(
3667            0,
3668            50.0,
3669            |i| format!("row-{i}"),
3670            |i| crate::widgets::text::text(format!("r{i}")),
3671        );
3672        let mut state = UiState::new();
3673        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3674        assert_eq!(root.children.len(), 0);
3675    }
3676
3677    #[test]
3678    #[should_panic(expected = "estimated_row_height > 0.0")]
3679    fn virtual_list_dyn_zero_estimate_panics() {
3680        let _ = crate::tree::virtual_list_dyn(
3681            10,
3682            0.0,
3683            |i| format!("row-{i}"),
3684            |i| crate::widgets::text::text(format!("r{i}")),
3685        );
3686    }
3687
3688    #[test]
3689    fn text_runs_constructor_shape_smoke() {
3690        let el = crate::tree::text_runs([
3691            crate::widgets::text::text("Hello, "),
3692            crate::widgets::text::text("world").bold(),
3693            crate::tree::hard_break(),
3694            crate::widgets::text::text("of text").italic(),
3695        ]);
3696        assert_eq!(el.kind, Kind::Inlines);
3697        assert_eq!(el.children.len(), 4);
3698        assert!(matches!(
3699            el.children[1].font_weight,
3700            FontWeight::Bold | FontWeight::Semibold
3701        ));
3702        assert_eq!(el.children[2].kind, Kind::HardBreak);
3703        assert!(el.children[3].text_italic);
3704    }
3705
3706    #[test]
3707    fn wrapped_text_hugs_multiline_height_from_available_width() {
3708        let mut root = column([crate::paragraph(
3709            "A longer sentence should wrap into multiple measured lines.",
3710        )])
3711        .width(Size::Fill(1.0))
3712        .height(Size::Hug);
3713
3714        let mut state = UiState::new();
3715        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
3716
3717        let child_rect = state.rect(&root.children[0].computed_id);
3718        assert_eq!(child_rect.w, 180.0);
3719        assert!(
3720            child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3721            "expected multiline paragraph height, got {}",
3722            child_rect.h
3723        );
3724    }
3725
3726    #[test]
3727    fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
3728        // Regression: overlay_rect used to call `intrinsic(c)` with no
3729        // width hint, so a Fixed-width modal containing a wrappable
3730        // paragraph measured the paragraph as a single line — leaving
3731        // the modal's Hug height short by the wrapped lines and
3732        // crowding the buttons against the bottom edge of the panel
3733        // (rumble cert-pending modal showed this).
3734        //
3735        // The fix: pass the child's resolved width as the available
3736        // width for intrinsic measurement, mirroring what column/row
3737        // already do.
3738        const PANEL_W: f32 = 240.0;
3739        const PADDING: f32 = 18.0;
3740        const GAP: f32 = 12.0;
3741
3742        let panel = column([
3743            crate::paragraph(
3744                "A long enough warning paragraph that it has to wrap onto a second line \
3745                 inside this narrow panel.",
3746            ),
3747            crate::widgets::button::button("OK").key("ok"),
3748        ])
3749        .width(Size::Fixed(PANEL_W))
3750        .height(Size::Hug)
3751        .padding(Sides::all(PADDING))
3752        .gap(GAP)
3753        .align(Align::Stretch);
3754
3755        let mut root = crate::stack([panel])
3756            .width(Size::Fill(1.0))
3757            .height(Size::Fill(1.0));
3758        let mut state = UiState::new();
3759        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3760
3761        let panel_rect = state.rect(&root.children[0].computed_id);
3762        assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
3763
3764        let para_rect = state.rect(&root.children[0].children[0].computed_id);
3765        let button_rect = state.rect(&root.children[0].children[1].computed_id);
3766
3767        // Paragraph wrapped to ≥ 2 lines (exact line count depends on
3768        // glyph metrics; just guard against the single-line bug).
3769        assert!(
3770            para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3771            "paragraph should wrap to multiple lines inside the Fixed-width panel; \
3772             got h={}",
3773            para_rect.h
3774        );
3775
3776        // Panel height must accommodate top padding + paragraph +
3777        // gap + button + bottom padding. The bug was that the panel
3778        // came out exactly `padding + gap + 1-line-paragraph + button`
3779        // — short by the second wrap line — and the button overshot
3780        // the inner area, leaving zero pixels of bottom padding.
3781        let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
3782        assert!(
3783            (bottom_padding - PADDING).abs() < 0.5,
3784            "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
3785        );
3786    }
3787
3788    #[test]
3789    fn row_with_fill_paragraph_propagates_height_to_parent_column() {
3790        // Regression: the Row branch of `intrinsic_constrained` called
3791        // `intrinsic(ch)` unconstrained, so a wrappable Fill child
3792        // (paragraph) measured as a single unwrapped line. Two such rows
3793        // in a column then got one-line-tall allocations and the second
3794        // row's gutter rect overlapped the first row's wrapped text
3795        // (chat-port event-log recipe in aetna-core/README.md hit this).
3796        //
3797        // The fix mirrors `layout_axis`: the Row intrinsic distributes
3798        // its available width across Fill children before measuring,
3799        // so wrappable Fill children see the width they will actually
3800        // be laid out at.
3801        const COL_W: f32 = 600.0;
3802        const GUTTER_W: f32 = 3.0;
3803
3804        let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
3805                    sed do eiusmod tempor incididunt ut labore et dolore magna \
3806                    aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
3807                    ullamco laboris nisi ut aliquip ex ea commodo consequat.";
3808
3809        let make_row = || {
3810            let gutter = El::new(Kind::Custom("gutter"))
3811                .width(Size::Fixed(GUTTER_W))
3812                .height(Size::Fill(1.0));
3813            let body = crate::paragraph(long).width(Size::Fill(1.0));
3814            crate::row([gutter, body]).width(Size::Fill(1.0))
3815        };
3816
3817        let mut root = column([make_row(), make_row()])
3818            .width(Size::Fixed(COL_W))
3819            .height(Size::Hug)
3820            .align(Align::Stretch);
3821        let mut state = UiState::new();
3822        layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
3823
3824        let row0_rect = state.rect(&root.children[0].computed_id);
3825        let row1_rect = state.rect(&root.children[1].computed_id);
3826        let para0_rect = state.rect(&root.children[0].children[1].computed_id);
3827
3828        // Both the paragraph rect and the row rect must reflect the
3829        // wrapped (multi-line) height. The bug pinned them to a single
3830        // line (~`TEXT_SM.line_height` = 20px), so the wrapped text
3831        // painted outside the row's allocated rect.
3832        let line_height = crate::tokens::TEXT_SM.line_height;
3833        assert!(
3834            para0_rect.h > line_height * 1.5,
3835            "paragraph should wrap to multiple lines at ~597px wide; \
3836             got h={} (line_height={})",
3837            para0_rect.h,
3838            line_height,
3839        );
3840        assert!(
3841            row0_rect.h > line_height * 1.5,
3842            "row 0 should accommodate the wrapped paragraph height; \
3843             got h={} (line_height={})",
3844            row0_rect.h,
3845            line_height,
3846        );
3847
3848        // Sanity: row 1 sits below row 0's allocated rect, not above it.
3849        assert!(
3850            row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
3851            "row 1 starts at y={} but row 0 occupies y={}..{}",
3852            row1_rect.y,
3853            row0_rect.y,
3854            row0_rect.y + row0_rect.h,
3855        );
3856    }
3857
3858    /// `min_width` floors a child whose resolved cross-axis size is
3859    /// below the floor. Tests against an `align(Start)` column so
3860    /// `Size::Fixed` doesn't get widened by the default Stretch
3861    /// alignment before clamping has a chance to apply.
3862    #[test]
3863    fn min_width_floors_resolved_cross_axis_size() {
3864        let mut root = column([crate::widgets::text::text("hi")
3865            .width(Size::Fixed(40.0))
3866            .height(Size::Fixed(20.0))
3867            .min_width(120.0)])
3868        .align(Align::Start)
3869        .width(Size::Fixed(500.0))
3870        .height(Size::Fixed(200.0));
3871        let mut state = UiState::new();
3872        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
3873        let child_rect = state.rect(&root.children[0].computed_id);
3874        assert!(
3875            (child_rect.w - 120.0).abs() < 0.5,
3876            "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
3877            child_rect.w,
3878        );
3879    }
3880
3881    /// `max_width` caps a `Size::Fill` child even when the surrounding
3882    /// row offers more space.
3883    #[test]
3884    fn max_width_caps_fill_child() {
3885        let mut root = crate::row([crate::widgets::text::text("body")
3886            .width(Size::Fill(1.0))
3887            .height(Size::Fixed(20.0))
3888            .max_width(160.0)])
3889        .width(Size::Fixed(800.0))
3890        .height(Size::Fixed(40.0));
3891        let mut state = UiState::new();
3892        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
3893        let child_rect = state.rect(&root.children[0].computed_id);
3894        assert!(
3895            (child_rect.w - 160.0).abs() < 0.5,
3896            "expected Fill child capped at 160, got w={}",
3897            child_rect.w,
3898        );
3899    }
3900
3901    /// When `min_width` and `max_width` conflict, the lower bound wins
3902    /// (CSS `min-width` precedence over `max-width`).
3903    #[test]
3904    fn min_width_wins_over_max_width_when_conflicting() {
3905        let mut root = column([crate::widgets::text::text("x")
3906            .width(Size::Fixed(50.0))
3907            .height(Size::Fixed(20.0))
3908            .max_width(80.0)
3909            .min_width(120.0)]);
3910        let mut state = UiState::new();
3911        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
3912        let child_rect = state.rect(&root.children[0].computed_id);
3913        assert!(
3914            (child_rect.w - 120.0).abs() < 0.5,
3915            "expected min_width (120) to win over max_width (80), got w={}",
3916            child_rect.w,
3917        );
3918    }
3919
3920    /// `min_height` floors a Hug child column whose children sum to
3921    /// less than the floor. Tested through a fixed-size parent so the
3922    /// resolved rect of the inner column reflects the clamp.
3923    #[test]
3924    fn min_height_floors_hug_column_inside_fixed_parent() {
3925        let inner = column([crate::widgets::text::text("a")
3926            .width(Size::Fixed(40.0))
3927            .height(Size::Fixed(20.0))])
3928        .width(Size::Fixed(80.0))
3929        .height(Size::Hug)
3930        .min_height(200.0);
3931        let mut root = column([inner])
3932            .align(Align::Start)
3933            .width(Size::Fixed(800.0))
3934            .height(Size::Fixed(600.0));
3935        let mut state = UiState::new();
3936        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3937        let inner_rect = state.rect(&root.children[0].computed_id);
3938        assert!(
3939            (inner_rect.h - 200.0).abs() < 0.5,
3940            "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
3941            inner_rect.h,
3942        );
3943    }
3944
3945    /// `max_height` caps a `Hug` overlay child below its intrinsic.
3946    #[test]
3947    fn max_height_caps_overlay_child_below_intrinsic() {
3948        // Overlay parent sized 600x600; child Hug column whose intrinsic
3949        // height is 300 (single 300-tall fixed leaf), capped at 100.
3950        let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
3951            .width(Size::Fixed(40.0))
3952            .height(Size::Fixed(300.0))])
3953        .width(Size::Hug)
3954        .height(Size::Hug)
3955        .max_height(100.0)])
3956        .width(Size::Fixed(600.0))
3957        .height(Size::Fixed(600.0));
3958        let mut state = UiState::new();
3959        layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3960        let child_rect = state.rect(&root.children[0].computed_id);
3961        assert!(
3962            (child_rect.h - 100.0).abs() < 0.5,
3963            "expected child height capped at 100, got h={}",
3964            child_rect.h,
3965        );
3966    }
3967}