Skip to main content

telar_layout_reactive/
context.rs

1use geometry_core::Rect;
2use layout_core::{AvailableSpace, LayoutEngine, LayoutError, LayoutStyle, MeasureFn, NodeId};
3use reactive_core::{RwSignal, batch, signal};
4use rustc_hash::FxHashMap;
5
6reactive_core::surface_local! {
7    /// A per-surface layout tree: the taffy engine plus the node→rect-signal registry. The layout tree is
8    /// a per-surface world so nodes can be created and laid out from anywhere — including reactive effects
9    /// (reactive lists) that fire from an effect body. Under M3 several surfaces share one UI thread, so the
10    /// runner activates each surface's [`LayoutContext`] around its build/event/frame; app code just calls
11    /// the free functions, which operate on whichever surface is currently active.
12    slot LAYOUT_RUNTIME: LayoutRuntime = LayoutRuntime::new();
13    access with_runtime, with_runtime_ref;
14    context LayoutContext, LayoutGuard;
15}
16
17/// Resets the active surface's layout runtime to a fresh, empty tree. The single-window app/preview harness
18/// calls this at construction; a multi-surface runner instead gives each surface its own [`LayoutContext`].
19pub fn reset_layout_runtime() {
20    with_runtime(|rt| *rt = LayoutRuntime::new());
21}
22
23pub fn new_leaf(style: LayoutStyle) -> Result<(NodeId, RwSignal<Rect>), LayoutError> {
24    with_runtime(|rt| rt.new_leaf(style))
25}
26
27/// A leaf whose intrinsic size is computed by `measure` at layout time (e.g. text
28/// whose height depends on how many lines it wraps into at the resolved width).
29pub fn new_measured_leaf(
30    style: LayoutStyle,
31    measure: MeasureFn,
32) -> Result<(NodeId, RwSignal<Rect>), LayoutError> {
33    with_runtime(|rt| rt.new_measured_leaf(style, measure))
34}
35
36pub fn new_container(style: LayoutStyle, children: &[NodeId]) -> Result<NodeId, LayoutError> {
37    with_runtime(|rt| rt.new_container(style, children))
38}
39
40pub fn compute_layout(
41    root: NodeId,
42    width: AvailableSpace,
43    height: AvailableSpace,
44) -> Result<(), LayoutError> {
45    compute_layout_root(root, width, height)
46}
47
48/// Lays out `root` against the given space and reflects the result into each node's rect signal.
49/// Collects the (signal, rect) updates while holding the runtime borrow, then applies them in a batch
50/// *after* releasing it — a rect `.set()` can flush effects, and one of those may itself touch the
51/// layout runtime (a reactive list), which would re-enter the borrow.
52pub fn compute_layout_root(
53    root: NodeId,
54    width: AvailableSpace,
55    height: AvailableSpace,
56) -> Result<(), LayoutError> {
57    // Reconciled here rather than in `set_direction` so the flip reaches every surface on the thread: the setter only knows about whichever one was active when it ran.
58    let direction = crate::direction::current_direction();
59    with_runtime(|rt| rt.engine.set_direction(direction));
60    let updates = with_runtime(|rt| rt.compute_layout(root, width, height))?;
61    batch(|| {
62        for (sig, rect) in updates {
63            if sig.peek() != rect {
64                sig.set(rect);
65            }
66        }
67    });
68    Ok(())
69}
70
71/// Re-lays out every root that has been computed at least once, picking up any nodes a reactive change
72/// dirtied since the last frame. Each `compute_layout` early-returns when its root is clean and the space
73/// is unchanged, so this is cheap on a still frame. The runtime calls it once per redraw (after flushing
74/// reactive effects, before rendering) so a data change deep in the tree — e.g. a reactive list adding an
75/// item — is reflected in layout without the app shell knowing about it. Node dirtiness propagates up to
76/// the root through taffy, so a dirtied list container makes its root recompute.
77pub fn relayout_if_dirty() {
78    let roots: Vec<(NodeId, AvailableSpace, AvailableSpace)> = with_runtime(|rt| {
79        rt.last_space
80            .iter()
81            .map(|(&n, &(w, h))| (n, w, h))
82            .collect()
83    });
84    for (root, width, height) in roots {
85        let _ = compute_layout_root(root, width, height);
86    }
87}
88
89pub fn track_layout(node: NodeId) -> Option<RwSignal<Rect>> {
90    with_runtime(|rt| rt.track_layout(node))
91}
92
93/// The node's WINDOW-absolute rect (top-left from the top-level walk, size from its layout), or `None` if it
94/// has not been laid out under a window root yet. Unlike `track_layout`, this is correct even for a node in a
95/// sub-root computed separately (whose rect signal is root-local) — use it to anchor a portaled overlay to a
96/// trigger, since the portal hoists out of ancestor transforms and needs absolute coordinates.
97///
98/// This is the trigger's *laid-out* position. Scrolling moves content by a render transform, not by relaying
99/// it out, so a node inside a scrolled viewport appears somewhere else on screen — `ui_core::visible_rect`
100/// applies the offsets on top of this, which is what an anchored overlay wants.
101pub fn absolute_rect(node: NodeId) -> Option<Rect> {
102    with_runtime(|rt| {
103        let &(x, y) = rt.abs_pos.get(&node)?;
104        let size = rt.registry.get(&node).map(|s| s.peek()).unwrap_or_default();
105        Some(Rect::new(x, y, size.width, size.height))
106    })
107}
108
109/// Whether `node` is `ancestor` or sits anywhere beneath it. Follows the parent links the runtime records, so
110/// it crosses into a separately-computed sub-root (a scroll's content) the way the layout tree does.
111pub fn is_descendant_of(node: NodeId, ancestor: NodeId) -> bool {
112    with_runtime(|rt| rt.is_in_subtree(node, ancestor))
113}
114
115pub fn mark_dirty(node: NodeId) -> Result<(), LayoutError> {
116    with_runtime(|rt| rt.mark_dirty(node))
117}
118
119/// Shows or hides a node in layout flow. A hidden node takes no space (and lays out none of its subtree); mark an ancestor dirty and recompute for the change to take effect. Used for responsive layouts (e.g. collapsing a sidebar on narrow windows).
120pub fn set_display(node: NodeId, visible: bool) {
121    with_runtime(|rt| rt.set_display(node, visible))
122}
123
124/// Whether `node` is a flex row (main axis horizontal). A transparent `for … gap:N` fragment reads its host
125/// container's axis to know which edge the per-item gap margin sits on.
126pub fn container_is_row(node: NodeId) -> bool {
127    with_runtime(|rt| rt.engine.is_row(node))
128}
129
130/// Sets `node`'s leading main-axis margin (`left` for a row host, `top` for a column) to `px` — the primitive
131/// a transparent `for … gap:N` uses to space its items without a container of its own. Marks the node dirty.
132pub fn set_leading_margin(node: NodeId, is_row: bool, px: f32) {
133    with_runtime(|rt| rt.engine.set_leading_margin(node, is_row, px))
134}
135
136/// Sets `node`'s minimum height to `px` after the initial layout (dirtying it, which propagates up), so a
137/// content-measured leaf grows to at least `px` even when its content is shorter. A scrolling editor uses it
138/// to fill its viewport so a click anywhere in the empty area — not just over the text — lands on the leaf.
139pub fn set_min_height(node: NodeId, px: f32) {
140    with_runtime(|rt| rt.engine.set_min_height(node, Some(px)))
141}
142
143/// Replaces `parent`'s children with `children`, in order, marking `parent` dirty. Operates on the
144/// thread-local runtime; `parent` must be a container already registered in the runtime.
145pub fn set_children(parent: NodeId, children: &[NodeId]) -> Result<(), LayoutError> {
146    with_runtime(|rt| rt.set_children(parent, children))
147}
148
149/// Detaches and frees `node` (a former list item) from the runtime: removes it from the layout tree and
150/// drops its rect signal and bookkeeping. The caller must have removed it from its parent's child list
151/// (via [`set_children`]) first.
152pub fn remove_node(node: NodeId) {
153    with_runtime(|rt| rt.remove_node(node))
154}
155
156/// Pins the overlay host to `node` — the app's window-spanning root — so overlays always fill the viewport
157/// even when the app computes several independent layout roots (e.g. a shell with a separate sidebar root
158/// computed after the main one, which the auto-detection would otherwise pick as the host). Call it each
159/// relayout with the current main root (it survives hot-reload rebuilds, which mint a new root node). Once
160/// pinned, auto-detection no longer overrides the host.
161pub fn set_overlay_host(node: NodeId) {
162    with_runtime(|rt| {
163        rt.overlay_host = Some(node);
164        rt.host_pinned = true;
165    });
166}
167
168/// Attaches `node` (an overlay's out-of-flow content) as an extra child of the current layout host — the
169/// top-level root computed against the window — so it fills the viewport regardless of where the `overlay`
170/// was declared in the tree. Returns `true` when attached; `false` when no host has been computed yet (the
171/// caller then falls back to normal in-tree layout). The host is marked dirty so the next frame lays the
172/// portal out.
173pub fn attach_overlay(node: NodeId) -> bool {
174    with_runtime(|rt| {
175        let Some(host) = rt.overlay_host else {
176            return false;
177        };
178        if rt.engine.add_child(host, node).is_err() {
179            return false;
180        }
181        rt.parents.insert(node, host);
182        rt.engine.mark_dirty(host).ok();
183        true
184    })
185}
186
187/// Detaches an overlay's content from the layout host (inverse of [`attach_overlay`]); the caller frees it
188/// afterwards with [`remove_node`]. A no-op if the host is gone.
189pub fn detach_overlay(node: NodeId) {
190    with_runtime(|rt| {
191        // Remove from the host the overlay actually attached to (recorded in `parents` at attach), NOT the
192        // current `overlay_host`: auto-detection may have moved the host to another root (e.g. a nested
193        // scroll's content root) since attach, and taffy panics if `node` is not a child of the node removed.
194        if let Some(host) = rt.parents.remove(&node) {
195            rt.engine.remove_child(host, node).ok();
196            rt.engine.mark_dirty(host).ok();
197        }
198    });
199}
200
201struct LayoutRuntime {
202    engine: LayoutEngine,
203    registry: FxHashMap<NodeId, RwSignal<Rect>>,
204    parents: FxHashMap<NodeId, NodeId>,
205    boundary_nodes: FxHashMap<NodeId, (f32, f32)>,
206    // Available space each root was last computed against, so compute_layout can re-run when only the space changed (e.g. a window resize) even though the node itself is clean. Without this, resizing an independently-computed root is silently a no-op and its layout freezes at the first size.
207    last_space: FxHashMap<NodeId, (AvailableSpace, AvailableSpace)>,
208    // Nodes with a definite `max-width`, their original style, and the width pinned on the previous compute (`None` = unpinned). taffy sizes a max-width box's intrinsic height at its uncapped width, so a wrapping child reports a 1-line height and the box ends up too short. compute_layout pins each resolved width as a definite width and re-runs so heights are correct. The stored pin lets the undo pass stay idempotent: an unpinned box whose space did not change is left untouched.
209    constrained: Vec<(NodeId, LayoutStyle, Option<f32>)>,
210    // Whether each compute-root's width/height were originally `auto`, captured the first time it is computed. An auto-sized root fills the definite space it is computed in, so a top-level page need not declare width:100% to avoid collapsing to its content width.
211    root_auto: FxHashMap<NodeId, (bool, bool)>,
212    // The parent-less (top-level) root last computed against the window — the layout host that `overlay`s
213    // attach their out-of-flow content to, so a portal fills the viewport regardless of where it is declared.
214    overlay_host: Option<NodeId>,
215    // When set, `overlay_host` was pinned by the app via `set_overlay_host` and auto-detection (last
216    // parent-less root wins) must NOT override it. An app with several independent roots (e.g. a shell with a
217    // separate sidebar root computed after the main one) needs this: the window-spanning root is the host,
218    // not whichever root happened to be computed last.
219    host_pinned: bool,
220    // Window-absolute top-left of each node, captured during the top-level (parent-less) root's walk (which
221    // runs from the window origin, so its rects ARE window-absolute). Node rect SIGNALS stay root-local (a
222    // sub-root computed separately, like the sandbox's scrolling `content`, leaves them content-local); this
223    // map is the ONE place with window-absolute positions, so `absolute_rect` can anchor a portaled overlay
224    // (which hoists out of ancestor transforms → needs absolute coords) to a trigger in a sub-root.
225    abs_pos: FxHashMap<NodeId, (f32, f32)>,
226    // Guards against recursive compute(): an effect that reads a layout signal and calls compute_layout() again creates a re-layout cycle caught immediately in debug builds.
227    #[cfg(debug_assertions)]
228    is_computing: bool,
229}
230
231impl LayoutRuntime {
232    fn new() -> Self {
233        Self {
234            engine: LayoutEngine::new(),
235            registry: FxHashMap::default(),
236            parents: FxHashMap::default(),
237            boundary_nodes: FxHashMap::default(),
238            last_space: FxHashMap::default(),
239            constrained: Vec::new(),
240            root_auto: FxHashMap::default(),
241            overlay_host: None,
242            host_pinned: false,
243            abs_pos: FxHashMap::default(),
244            #[cfg(debug_assertions)]
245            is_computing: false,
246        }
247    }
248
249    fn track_constrained(&mut self, node: NodeId, style: &LayoutStyle) {
250        if style.max_width_px().is_some() {
251            self.constrained.push((node, style.clone(), None));
252        }
253    }
254
255    pub(crate) fn new_leaf(
256        &mut self,
257        style: LayoutStyle,
258    ) -> Result<(NodeId, RwSignal<Rect>), LayoutError> {
259        let node = self.engine.new_leaf(style.clone())?;
260        let signal = signal(Rect::default());
261        self.registry.insert(node, signal.clone());
262        if let Some(dimensions) = self.engine.is_fixed_size(node) {
263            self.boundary_nodes.insert(node, dimensions);
264        }
265        self.track_constrained(node, &style);
266        Ok((node, signal))
267    }
268
269    pub(crate) fn new_measured_leaf(
270        &mut self,
271        style: LayoutStyle,
272        measure: MeasureFn,
273    ) -> Result<(NodeId, RwSignal<Rect>), LayoutError> {
274        let node = self.engine.new_measured_leaf(style.clone(), measure)?;
275        let signal = signal(Rect::default());
276        self.registry.insert(node, signal.clone());
277        self.track_constrained(node, &style);
278        Ok((node, signal))
279    }
280
281    pub(crate) fn new_container(
282        &mut self,
283        style: LayoutStyle,
284        children: &[NodeId],
285    ) -> Result<NodeId, LayoutError> {
286        let node = self.engine.new_container(style.clone(), children)?;
287        let signal = signal(Rect::default());
288        self.registry.insert(node, signal);
289        for &child in children {
290            self.parents.insert(child, node);
291        }
292        if let Some(dimensions) = self.engine.is_fixed_size(node) {
293            self.boundary_nodes.insert(node, dimensions);
294        }
295        self.track_constrained(node, &style);
296        Ok(node)
297    }
298
299    fn compute_layout(
300        &mut self,
301        root: NodeId,
302        width: AvailableSpace,
303        height: AvailableSpace,
304    ) -> Result<Vec<(RwSignal<Rect>, Rect)>, LayoutError> {
305        // A top-level root (no parent) computed against the window is the overlay host: overlays attach
306        // their content here so a portal fills the viewport wherever it is declared. Refreshed each compute
307        // so it stays current across a hot-reload rebuild (which mints a new root node). A definite height
308        // marks the surface/window root; a detached sub-root laid out for its intrinsic height (a scroll's
309        // content, computed with `MaxContent`) must NOT become the host, or a portal declared inside a scroll
310        // would attach to that scroll and be torn down (and mis-detached) with it.
311        if !self.host_pinned
312            && !self.parents.contains_key(&root)
313            && matches!(height, AvailableSpace::Definite(_))
314        {
315            self.overlay_host = Some(root);
316        }
317        // A changed available space (window resize) must re-run layout even when the node is clean: dirty the root so the cached size from the previous space is discarded. Skip only when both the node is clean and the space is unchanged.
318        let is_space_changed = self.last_space.get(&root) != Some(&(width, height));
319        if is_space_changed {
320            self.engine.mark_dirty(root).ok();
321            self.last_space.insert(root, (width, height));
322        } else if !self.engine.is_dirty(root) {
323            return Ok(Vec::new());
324        }
325        // A layout root fills the definite space it is computed in: an auto width or height becomes the available size, so a top-level page need not declare width:100% to avoid collapsing to its content. Only this root is affected.
326        let (width_auto, height_auto) = match self.root_auto.get(&root).copied() {
327            Some(v) => v,
328            None => {
329                let v = self.engine.is_size_auto(root);
330                self.root_auto.insert(root, v);
331                v
332            }
333        };
334        // Undo any width pins from a previous layout so each max-width box resolves against the new available space before we re-pin it after the first pass. Idempotent: only touch a box when the space changed (everything must re-resolve) or it actually carried a pin to lift. Leaving unpinned boxes alone when the space is unchanged avoids dirtying their ancestors, which would otherwise force find_boundary_root to fall back to global_root every frame. This runs before the root-fill below so that when the root itself is a max-width box, restoring its original (auto-width) style does not clobber the definite width the fill assigns.
335        for i in 0..self.constrained.len() {
336            let node = self.constrained[i].0;
337            let had_pin = self.constrained[i].2.is_some();
338            if !is_space_changed && !had_pin {
339                continue;
340            }
341            let style = self.constrained[i].1.clone();
342            self.engine.set_style(node, style).ok();
343            self.engine.mark_dirty(node).ok();
344            self.constrained[i].2 = None;
345        }
346        let mut did_fill_root = false;
347        if width_auto {
348            let w = match width {
349                AvailableSpace::Definite(w) => Some(w),
350                _ => None,
351            };
352            self.engine.set_width(root, w);
353            did_fill_root = true;
354        }
355        if height_auto {
356            let h = match height {
357                AvailableSpace::Definite(h) => Some(h),
358                _ => None,
359            };
360            self.engine.set_height(root, h);
361            did_fill_root = true;
362        }
363        if did_fill_root {
364            self.engine.mark_dirty(root).ok();
365        }
366        let mut dirty_nodes = Vec::new();
367        self.engine.collect_dirty_nodes(root, &mut dirty_nodes);
368        if dirty_nodes.is_empty() {
369            return Ok(Vec::new());
370        }
371        #[cfg(debug_assertions)]
372        {
373            assert!(
374                !self.is_computing,
375                "[rsx layout] cycle detected: compute_layout() called recursively. \
376                 An effect is reading a layout signal and then calling compute_layout() again inside its body. \
377                 This causes an infinite re-layout loop (capped by MAX_FLUSH_ITERATIONS). \
378                 Move style mutations outside of layout-observing effects."
379            );
380            self.is_computing = true;
381        }
382        let (layout_root, layout_width, layout_height) =
383            self.find_boundary_root(&dirty_nodes, root, width, height);
384        self.engine
385            .compute_layout(layout_root, layout_width, layout_height)?;
386        // Second pass: pin each max-width box to the width it just resolved to, so a re-layout sizes its wrapping children at the capped width (correct line count / height) instead of taffy's uncapped 1-line intrinsic estimate.
387        let mut did_pin_any = false;
388        for i in 0..self.constrained.len() {
389            let node = self.constrained[i].0;
390            let style = self.constrained[i].1.clone();
391            let Some(max_w) = style.max_width_px() else {
392                continue;
393            };
394            if !self.is_in_subtree(node, layout_root) {
395                continue;
396            }
397            if let Ok(layout) = self.engine.layout(node) {
398                if layout.width > 0.0 && layout.width <= max_w + 0.5 {
399                    self.engine.set_style(node, style.width(layout.width)).ok();
400                    self.engine.mark_dirty(node).ok();
401                    self.constrained[i].2 = Some(layout.width);
402                    did_pin_any = true;
403                }
404            }
405        }
406        if did_pin_any {
407            self.engine
408                .compute_layout(layout_root, layout_width, layout_height)?;
409        }
410        // Collect the changed rects while holding the runtime borrow, but apply them (`sig.set`) only
411        // after the caller releases it: a set flushes effects, one of which may re-enter the runtime.
412        let mut updates: Vec<(RwSignal<Rect>, Rect)> = Vec::new();
413        // Only a full walk of a parent-less root runs from the window origin, so only then are the walked
414        // rects window-absolute. A sub-boundary or sub-root walk is root-local — don't capture those.
415        let is_window_walk = layout_root == root && !self.parents.contains_key(&root);
416        let mut abs_updates: Vec<(NodeId, f32, f32)> = Vec::new();
417        let registry = &self.registry;
418        let walk_result = self.engine.walk(layout_root, &mut |node_id, rect| {
419            if let Some(sig) = registry.get(&node_id) {
420                if sig.peek() != rect {
421                    updates.push((sig.clone(), rect));
422                }
423            }
424            if is_window_walk {
425                abs_updates.push((node_id, rect.x, rect.y));
426            }
427            true
428        });
429        for (n, x, y) in abs_updates {
430            self.abs_pos.insert(n, (x, y));
431        }
432        #[cfg(debug_assertions)]
433        {
434            self.is_computing = false;
435        }
436        walk_result.map(|()| updates)
437    }
438
439    fn find_boundary_root(
440        &self,
441        dirty_nodes: &[NodeId],
442        global_root: NodeId,
443        global_width: AvailableSpace,
444        global_height: AvailableSpace,
445    ) -> (NodeId, AvailableSpace, AvailableSpace) {
446        let candidate = dirty_nodes
447            .iter()
448            .find_map(|&node| self.find_nearest_boundary(node));
449        match candidate {
450            Some((boundary, boundary_width, boundary_height))
451                if dirty_nodes.iter().all(|&n| self.is_in_subtree(n, boundary)) =>
452            {
453                (
454                    boundary,
455                    AvailableSpace::Definite(boundary_width),
456                    AvailableSpace::Definite(boundary_height),
457                )
458            }
459            _ => (global_root, global_width, global_height),
460        }
461    }
462
463    fn find_nearest_boundary(&self, mut node: NodeId) -> Option<(NodeId, f32, f32)> {
464        loop {
465            if let Some(&(w, h)) = self.boundary_nodes.get(&node) {
466                return Some((node, w, h));
467            }
468            node = *self.parents.get(&node)?;
469        }
470    }
471
472    fn is_in_subtree(&self, mut node: NodeId, ancestor: NodeId) -> bool {
473        loop {
474            if node == ancestor {
475                return true;
476            }
477            match self.parents.get(&node) {
478                Some(&parent) => node = parent,
479                None => return false,
480            }
481        }
482    }
483
484    pub(crate) fn track_layout(&self, node: NodeId) -> Option<RwSignal<Rect>> {
485        self.registry.get(&node).cloned()
486    }
487
488    pub(crate) fn mark_dirty(&mut self, node: NodeId) -> Result<(), LayoutError> {
489        self.engine.mark_dirty(node)
490    }
491
492    pub(crate) fn set_display(&mut self, node: NodeId, visible: bool) {
493        self.engine.set_display(node, visible);
494    }
495
496    fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), LayoutError> {
497        self.engine.set_children(parent, children)?;
498        for &child in children {
499            self.parents.insert(child, parent);
500        }
501        self.engine.mark_dirty(parent).ok();
502        Ok(())
503    }
504
505    fn remove_node(&mut self, node: NodeId) {
506        self.engine.remove(node);
507        self.registry.remove(&node);
508        self.parents.remove(&node);
509        self.boundary_nodes.remove(&node);
510        self.last_space.remove(&node);
511        self.root_auto.remove(&node);
512        self.abs_pos.remove(&node);
513        self.constrained.retain(|(n, _, _)| *n != node);
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use geometry_core::Rect;
520    use layout_core::{LayoutStyle, SizeDimension};
521
522    use super::*;
523
524    // A flex-wrap row nested in a max-width box (the full-bleed-band + centered- content pattern) must reserve height for the lines it actually wraps into, even though taffy would otherwise size the box at its uncapped 1-line width.
525    #[test]
526    fn maxwidth_box_reserves_height_for_wrapped_content() {
527        reset_layout_runtime();
528        let mut items = Vec::new();
529        for _ in 0..4 {
530            let (n, _) = new_leaf(
531                LayoutStyle::new()
532                    .width(200.0)
533                    .height(100.0)
534                    .min_width(200.0)
535                    .flex_grow(1.0),
536            )
537            .unwrap();
538            items.push(n);
539        }
540        let row =
541            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &items).unwrap();
542        // Capped to 500 → 2 items per row → the 4 items wrap onto 2 lines.
543        let boxed = new_container(
544            LayoutStyle::new()
545                .flex_column()
546                .width(SizeDimension::Percent(1.0))
547                .max_width(500.0),
548            &[row],
549        )
550        .unwrap();
551        let page = new_container(
552            LayoutStyle::new()
553                .flex_column()
554                .width(SizeDimension::Percent(1.0)),
555            &[boxed],
556        )
557        .unwrap();
558        compute_layout(
559            page,
560            AvailableSpace::Definite(900.0),
561            AvailableSpace::MaxContent,
562        )
563        .unwrap();
564        let box_rect = track_layout(boxed).unwrap().get();
565        let row_rect = track_layout(row).unwrap().get();
566        assert!(
567            (box_rect.width - 500.0).abs() < 1.0,
568            "box not capped: {box_rect:?}"
569        );
570        assert!(
571            row_rect.height >= 200.0,
572            "row did not wrap to 2 lines: {row_rect:?}"
573        );
574        assert!(
575            box_rect.height >= row_rect.height - 0.5,
576            "box too short for wrapped content: box={box_rect:?} row={row_rect:?}"
577        );
578    }
579
580    // Re-running compute_layout against the SAME available space (root re-dirtied by an unrelated change) must keep the max-width box correctly sized: the idempotent undo must still lift and re-pin a previously pinned box so its wrapped height holds.
581    #[test]
582    fn maxwidth_box_stable_across_recompute() {
583        reset_layout_runtime();
584        let mut items = Vec::new();
585        for _ in 0..4 {
586            let (n, _) = new_leaf(
587                LayoutStyle::new()
588                    .width(200.0)
589                    .height(100.0)
590                    .min_width(200.0)
591                    .flex_grow(1.0),
592            )
593            .unwrap();
594            items.push(n);
595        }
596        let row =
597            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &items).unwrap();
598        let boxed = new_container(
599            LayoutStyle::new()
600                .flex_column()
601                .width(SizeDimension::Percent(1.0))
602                .max_width(500.0),
603            &[row],
604        )
605        .unwrap();
606        let page = new_container(
607            LayoutStyle::new()
608                .flex_column()
609                .width(SizeDimension::Percent(1.0)),
610            &[boxed],
611        )
612        .unwrap();
613
614        let space = (AvailableSpace::Definite(900.0), AvailableSpace::MaxContent);
615        compute_layout(page, space.0, space.1).unwrap();
616        let first = track_layout(boxed).unwrap().get();
617
618        // Re-dirty the root and recompute at the SAME space: exercises the idempotent undo on an already-pinned box.
619        mark_dirty(page).unwrap();
620        compute_layout(page, space.0, space.1).unwrap();
621        let second = track_layout(boxed).unwrap().get();
622
623        assert!(
624            (second.width - 500.0).abs() < 1.0,
625            "box not capped on recompute: {second:?}"
626        );
627        assert!(
628            (first.width - second.width).abs() < 0.5 && (first.height - second.height).abs() < 0.5,
629            "box layout drifted across recompute: first={first:?} second={second:?}"
630        );
631    }
632
633    // An auto-sized layout root fills the definite space it is computed in, so a page need not declare width:100% to avoid collapsing to its content width.
634    #[test]
635    fn auto_root_fills_definite_width() {
636        reset_layout_runtime();
637        let (child, _) = new_leaf(LayoutStyle::new().height(40.0)).unwrap();
638        // A column with auto width whose child is content-sized would otherwise shrink to the child; the root-fill rule stretches it to the given width.
639        let page = new_container(LayoutStyle::new().flex_column(), &[child]).unwrap();
640        compute_layout(
641            page,
642            AvailableSpace::Definite(1000.0),
643            AvailableSpace::MaxContent,
644        )
645        .unwrap();
646        let w = track_layout(page).unwrap().get().width;
647        assert!(
648            (w - 1000.0).abs() < 1.0,
649            "auto root did not fill width: {w}"
650        );
651    }
652
653    // Repro: an auto-width root that ALSO carries max_width must still fill the definite space (capped by max_width), not collapse to its content width.
654    #[test]
655    fn hidden_child_collapses_to_zero_rect() {
656        // A section toggled to `display:none` must collapse to a zero rect so its view draws nothing and
657        // does not overlap the visible section (the tab-switch mechanism in the sandbox relies on this).
658        reset_layout_runtime();
659        let (a, _) = new_leaf(LayoutStyle::new().width(50.0).height(30.0)).unwrap();
660        let (b, b_rect) = new_leaf(LayoutStyle::new().width(50.0).height(30.0)).unwrap();
661        let root = new_container(LayoutStyle::new().flex_column(), &[a, b]).unwrap();
662        compute_layout(
663            root,
664            AvailableSpace::Definite(200.0),
665            AvailableSpace::Definite(200.0),
666        )
667        .unwrap();
668        assert!(b_rect.get().height > 0.0, "b should start visible");
669
670        set_display(b, false);
671        mark_dirty(root).unwrap();
672        compute_layout(
673            root,
674            AvailableSpace::Definite(200.0),
675            AvailableSpace::Definite(200.0),
676        )
677        .unwrap();
678        let r = b_rect.get();
679        assert_eq!(
680            (r.width, r.height),
681            (0.0, 0.0),
682            "hidden child not collapsed: {r:?}"
683        );
684    }
685
686    // Hiding a section must collapse its whole subtree, not just the section node: taffy leaves stale
687    // layouts on descendants of a `display:none` node, so without zeroing them a Canvas (which paints at
688    // fixed coordinates) in a hidden section would still draw over the visible one.
689    #[test]
690    fn hidden_subtree_collapses_descendants() {
691        reset_layout_runtime();
692        let (grandchild, gc_rect) = new_leaf(LayoutStyle::new().width(40.0).height(20.0)).unwrap();
693        let section = new_container(LayoutStyle::new().flex_column(), &[grandchild]).unwrap();
694        let root = new_container(LayoutStyle::new().flex_column(), &[section]).unwrap();
695        compute_layout(
696            root,
697            AvailableSpace::Definite(200.0),
698            AvailableSpace::Definite(200.0),
699        )
700        .unwrap();
701        assert!(gc_rect.get().width > 0.0, "grandchild should start visible");
702
703        set_display(section, false);
704        mark_dirty(root).unwrap();
705        compute_layout(
706            root,
707            AvailableSpace::Definite(200.0),
708            AvailableSpace::Definite(200.0),
709        )
710        .unwrap();
711        let r = gc_rect.get();
712        assert_eq!(
713            (r.width, r.height),
714            (0.0, 0.0),
715            "descendant of hidden section not collapsed: {r:?}"
716        );
717    }
718
719    #[test]
720    fn auto_root_with_max_width_fills_capped() {
721        reset_layout_runtime();
722        let (child, _) = new_leaf(LayoutStyle::new().height(40.0)).unwrap();
723        let page =
724            new_container(LayoutStyle::new().flex_column().max_width(600.0), &[child]).unwrap();
725        // Wider than the cap: should fill up to max_width (600), not shrink to content (0).
726        compute_layout(
727            page,
728            AvailableSpace::Definite(1000.0),
729            AvailableSpace::MaxContent,
730        )
731        .unwrap();
732        let w = track_layout(page).unwrap().get().width;
733        assert!((w - 600.0).abs() < 1.0, "capped fill failed: {w}");
734        // Narrower than the cap: should fill the available width (400).
735        compute_layout(
736            page,
737            AvailableSpace::Definite(400.0),
738            AvailableSpace::MaxContent,
739        )
740        .unwrap();
741        let w = track_layout(page).unwrap().get().width;
742        assert!((w - 400.0).abs() < 1.0, "sub-cap fill failed: {w}");
743    }
744
745    // The landing/sandbox shell pattern: an auto-width outer that fills and centers a capped inner column.
746    #[test]
747    fn centered_capped_column_tracks_width() {
748        reset_layout_runtime();
749        let (child, _) = new_leaf(LayoutStyle::new().height(40.0)).unwrap();
750        let inner = new_container(
751            LayoutStyle::new()
752                .flex_column()
753                .width(SizeDimension::Percent(1.0))
754                .max_width(960.0),
755            &[child],
756        )
757        .unwrap();
758        let outer = new_container(
759            LayoutStyle::new()
760                .flex_column()
761                .align_items(layout_core::AlignItems::CENTER),
762            &[inner],
763        )
764        .unwrap();
765        let inner_rect = track_layout(inner).unwrap();
766        let outer_rect = track_layout(outer).unwrap();
767        // Wide window: outer fills 1400, inner caps at 960 and is centered ((1400-960)/2 = 220).
768        compute_layout(
769            outer,
770            AvailableSpace::Definite(1400.0),
771            AvailableSpace::MaxContent,
772        )
773        .unwrap();
774        assert!(
775            (outer_rect.get().width - 1400.0).abs() < 1.0,
776            "outer fill: {}",
777            outer_rect.get().width
778        );
779        assert!(
780            (inner_rect.get().width - 960.0).abs() < 1.0,
781            "inner cap: {}",
782            inner_rect.get().width
783        );
784        assert!(
785            (inner_rect.get().x - 220.0).abs() < 1.0,
786            "inner centered: {}",
787            inner_rect.get().x
788        );
789        // Narrow window: inner fills the full width and centering adds no margin.
790        compute_layout(
791            outer,
792            AvailableSpace::Definite(700.0),
793            AvailableSpace::MaxContent,
794        )
795        .unwrap();
796        assert!(
797            (inner_rect.get().width - 700.0).abs() < 1.0,
798            "inner tracks narrow: {}",
799            inner_rect.get().width
800        );
801        assert!(
802            inner_rect.get().x.abs() < 1.0,
803            "no margin when full: {}",
804            inner_rect.get().x
805        );
806    }
807
808    // set_min_height grows a content-measured leaf to fill a viewport it would otherwise underflow (the
809    // notebook editor's fill-the-viewport trick); a leaf whose content already exceeds the floor is untouched.
810    #[test]
811    fn set_min_height_grows_short_measured_leaf() {
812        reset_layout_runtime();
813        // A measured leaf reporting a fixed 20px content height, like a one-line text area.
814        let (leaf, rect) = new_measured_leaf(
815            LayoutStyle::new().width(SizeDimension::Percent(1.0)),
816            Box::new(|_w| (0.0, 20.0)),
817        )
818        .unwrap();
819        let root = new_container(
820            LayoutStyle::new()
821                .flex_column()
822                .width(SizeDimension::Percent(1.0)),
823            &[leaf],
824        )
825        .unwrap();
826        let space = (AvailableSpace::Definite(300.0), AvailableSpace::MaxContent);
827        compute_layout(root, space.0, space.1).unwrap();
828        assert!(
829            (rect.get().height - 20.0).abs() < 0.5,
830            "starts at its content height: {:?}",
831            rect.get()
832        );
833
834        // Grown to 200: the leaf now fills that height even though its content is only 20px tall.
835        set_min_height(leaf, 200.0);
836        compute_layout(root, space.0, space.1).unwrap();
837        assert!(
838            (rect.get().height - 200.0).abs() < 0.5,
839            "min_height fills the short leaf: {:?}",
840            rect.get()
841        );
842
843        // Cleared back to auto: the leaf collapses to its content height again.
844        set_min_height(leaf, 0.0);
845        compute_layout(root, space.0, space.1).unwrap();
846        assert!(
847            (rect.get().height - 20.0).abs() < 0.5,
848            "a zero floor restores the content height: {:?}",
849            rect.get()
850        );
851    }
852
853    #[test]
854    fn ctx_register_leaf_returns_ok() {
855        reset_layout_runtime();
856        let result = new_leaf(LayoutStyle::new());
857        assert!(result.is_ok());
858    }
859
860    #[test]
861    fn ctx_new_container_returns_ok() {
862        reset_layout_runtime();
863        let leaf_result = new_leaf(LayoutStyle::new());
864        assert!(leaf_result.is_ok());
865        let (leaf, _) = leaf_result.unwrap();
866        let container_result = new_container(LayoutStyle::new(), &[leaf]);
867        assert!(container_result.is_ok());
868    }
869
870    #[test]
871    fn ctx_register_leaf_returns_zero_rect() {
872        reset_layout_runtime();
873        let (_node, rect) = new_leaf(LayoutStyle::new()).unwrap();
874        assert_eq!(rect.get(), Rect::default());
875    }
876
877    #[test]
878    fn ctx_compute_updates_rect() {
879        reset_layout_runtime();
880        let (leaf, rect) = new_leaf(LayoutStyle::new().width(100.0).height(50.0)).unwrap();
881        let root = new_container(
882            LayoutStyle::new().flex_row().width(200.0).height(100.0),
883            &[leaf],
884        )
885        .unwrap();
886        compute_layout(
887            root,
888            AvailableSpace::Definite(200.0),
889            AvailableSpace::Definite(100.0),
890        )
891        .unwrap();
892        assert_eq!(rect.get().width, 100.0);
893        assert_eq!(rect.get().height, 50.0);
894    }
895
896    #[test]
897    fn setting_the_direction_signal_reaches_the_engine_on_the_next_layout_pass() {
898        // Nothing rebuilds, so the rect signals asserted here are the same ones the widgets already hold.
899        reset_layout_runtime();
900        crate::set_direction(layout_core::Direction::Ltr);
901        let (first, first_rect) = new_leaf(LayoutStyle::new().width(40.0).height(10.0)).unwrap();
902        let (second, second_rect) = new_leaf(LayoutStyle::new().width(40.0).height(10.0)).unwrap();
903        let root = new_container(
904            LayoutStyle::new().flex_row().width(200.0).height(100.0),
905            &[first, second],
906        )
907        .unwrap();
908        let space = || {
909            (
910                AvailableSpace::Definite(200.0),
911                AvailableSpace::Definite(100.0),
912            )
913        };
914        let (w, h) = space();
915        compute_layout(root, w, h).unwrap();
916        assert_eq!(first_rect.get().x, 0.0);
917        assert_eq!(second_rect.get().x, 40.0);
918
919        crate::set_direction(layout_core::Direction::Rtl);
920        mark_dirty(root).unwrap();
921        let (w, h) = space();
922        compute_layout(root, w, h).unwrap();
923        assert_eq!(first_rect.get().x, 160.0, "the row now starts at the right");
924        assert_eq!(second_rect.get().x, 120.0);
925        crate::set_direction(layout_core::Direction::Ltr);
926    }
927
928    // An overlay's content, attached to the host, fills the viewport — not the small box it was declared in.
929    #[test]
930    fn attached_overlay_fills_host_viewport_not_its_small_parent() {
931        reset_layout_runtime();
932        // Computing a parent-less root registers it as the overlay host (an 800×600 viewport).
933        let (small, _) = new_leaf(LayoutStyle::new().width(50.0).height(50.0)).unwrap();
934        let root = new_container(LayoutStyle::new().flex_column(), &[small]).unwrap();
935        compute_layout(
936            root,
937            AvailableSpace::Definite(800.0),
938            AvailableSpace::Definite(600.0),
939        )
940        .unwrap();
941
942        // Overlay content: an absolute-fill container with a 100%×100% inner leaf we can measure.
943        let (inner, inner_rect) = new_leaf(
944            LayoutStyle::new()
945                .width(SizeDimension::Percent(1.0))
946                .height(SizeDimension::Percent(1.0)),
947        )
948        .unwrap();
949        let content = new_container(LayoutStyle::new().absolute_fill(), &[inner]).unwrap();
950        assert!(
951            attach_overlay(content),
952            "the host must be set after the first compute"
953        );
954        relayout_if_dirty();
955
956        let r = inner_rect.get();
957        assert!(
958            (r.width - 800.0).abs() < 0.5 && (r.height - 600.0).abs() < 0.5,
959            "portal fills the viewport, not its 50px parent: {r:?}"
960        );
961
962        // Detaching and freeing the content must leave the host laying out cleanly (no panic, still valid).
963        detach_overlay(content);
964        remove_node(content);
965        relayout_if_dirty();
966    }
967
968    // Reproduces the sandbox shell's coordinate trap: a `[sidebar | content]` window root, then the `content`
969    // computed AGAIN as its own root (for scroll-height measurement) — which rewrites the content subtree's
970    // rect signals to content-local coords. `absolute_rect` must still report a trigger's WINDOW-absolute
971    // position (past the sidebar), so a portaled dropdown anchors correctly instead of landing over the sidebar.
972    #[test]
973    fn absolute_rect_stays_window_absolute_across_a_separate_content_root() {
974        reset_layout_runtime();
975        let (sidebar, _) = new_leaf(LayoutStyle::new().width(248.0).height(600.0)).unwrap();
976        let (trigger, trigger_sig) =
977            new_leaf(LayoutStyle::new().width(120.0).height(30.0)).unwrap();
978        let content =
979            new_container(LayoutStyle::new().flex_column().flex_grow(1.0), &[trigger]).unwrap();
980        let root = new_container(LayoutStyle::new().flex_row(), &[sidebar, content]).unwrap();
981        compute_layout(
982            root,
983            AvailableSpace::Definite(1000.0),
984            AvailableSpace::Definite(600.0),
985        )
986        .unwrap();
987        set_overlay_host(root);
988        // The trigger is at window x ≈ 248 (immediately right of the sidebar).
989        assert!(
990            (absolute_rect(trigger).unwrap().x - 248.0).abs() < 1.0,
991            "abs x should be past the 248px sidebar: {:?}",
992            absolute_rect(trigger)
993        );
994
995        // Compute `content` as its own root (the sandbox does this for scroll height): the SIGNAL goes local.
996        mark_dirty(content).unwrap();
997        compute_layout(
998            content,
999            AvailableSpace::Definite(752.0),
1000            AvailableSpace::MaxContent,
1001        )
1002        .unwrap();
1003        assert!(
1004            trigger_sig.get().x < 1.0,
1005            "the rect signal is now content-local (~0): {:?}",
1006            trigger_sig.get()
1007        );
1008        // But absolute_rect still reports window-absolute (past the sidebar) — this is the fix.
1009        assert!(
1010            (absolute_rect(trigger).unwrap().x - 248.0).abs() < 1.0,
1011            "absolute_rect must stay window-absolute across the sub-root compute: {:?}",
1012            absolute_rect(trigger)
1013        );
1014    }
1015}