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
40/// Lays out `root` against the given space and reflects the result into each node's rect signal.
41/// Collects the (signal, rect) updates while holding the runtime borrow, then applies them in a batch
42/// *after* releasing it — a rect `.set()` can flush effects, and one of those may itself touch the
43/// layout runtime (a reactive list), which would re-enter the borrow.
44pub fn compute_layout(
45    root: NodeId,
46    width: AvailableSpace,
47    height: AvailableSpace,
48) -> Result<(), LayoutError> {
49    // 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.
50    let direction = crate::direction::current_direction();
51    with_runtime(|rt| rt.engine.set_direction(direction));
52    let updates = with_runtime(|rt| rt.compute_layout(root, width, height))?;
53    batch(|| {
54        for (sig, rect) in updates {
55            if sig.peek() != rect {
56                sig.set(rect);
57            }
58        }
59    });
60    Ok(())
61}
62
63/// Re-lays out every root that has been computed at least once, picking up any nodes a reactive change
64/// dirtied since the last frame. Each `compute_layout` early-returns when its root is clean and the space
65/// is unchanged, so this is cheap on a still frame. The runtime calls it once per redraw (after flushing
66/// reactive effects, before rendering) so a data change deep in the tree — e.g. a reactive list adding an
67/// item — is reflected in layout without the app shell knowing about it. Node dirtiness propagates up to
68/// the root through taffy, so a dirtied list container makes its root recompute.
69pub fn relayout_if_dirty() {
70    let roots: Vec<(NodeId, AvailableSpace, AvailableSpace)> = with_runtime(|rt| {
71        rt.last_space
72            .iter()
73            .map(|(&n, &(w, h))| (n, w, h))
74            .collect()
75    });
76    for (root, width, height) in roots {
77        let _ = compute_layout(root, width, height);
78    }
79}
80
81pub fn track_layout(node: NodeId) -> Option<RwSignal<Rect>> {
82    with_runtime(|rt| rt.track_layout(node))
83}
84
85/// The node's WINDOW-absolute rect (top-left from the top-level walk, size from its layout), or `None` if it
86/// has not been laid out under a window root yet. Unlike `track_layout`, this is correct even for a node in a
87/// sub-root computed separately (whose rect signal is root-local) — use it to anchor a portaled overlay to a
88/// trigger, since the portal hoists out of ancestor transforms and needs absolute coordinates.
89///
90/// This is the trigger's *laid-out* position. Scrolling moves content by a render transform, not by relaying
91/// it out, so a node inside a scrolled viewport appears somewhere else on screen — `ui_core::visible_rect`
92/// applies the offsets on top of this, which is what an anchored overlay wants.
93pub fn absolute_rect(node: NodeId) -> Option<Rect> {
94    with_runtime(|rt| {
95        let &(x, y) = rt.abs_pos.get(&node)?;
96        let size = rt.registry.get(&node).map(|s| s.peek()).unwrap_or_default();
97        Some(Rect::new(x, y, size.width, size.height))
98    })
99}
100
101/// Whether `node` is `ancestor` or sits anywhere beneath it. Follows the parent links the runtime records, so
102/// it crosses into a separately-computed sub-root (a scroll's content) the way the layout tree does.
103pub fn is_descendant_of(node: NodeId, ancestor: NodeId) -> bool {
104    with_runtime(|rt| rt.is_in_subtree(node, ancestor))
105}
106
107/// Whether `node` is out of layout flow, by its own `display:none` or any ancestor's.
108///
109/// The climb is the point: taffy stops laying out under a hidden node, so a descendant keeps the size it had
110/// when it was last shown and looks perfectly ordinary to anyone reading its rect. Only the chain says it is
111/// gone. Follows the same parent links as [`is_descendant_of`], so it crosses into a portaled sub-root.
112pub fn is_hidden(node: NodeId) -> bool {
113    with_runtime(|rt| rt.is_hidden_by_display(node))
114}
115
116pub fn mark_dirty(node: NodeId) -> Result<(), LayoutError> {
117    with_runtime(|rt| rt.engine.mark_dirty(node))
118}
119
120/// Replaces `node`'s layout style and dirties it, so the next pass lays it out again.
121///
122/// What a widget whose style is *derived* from reactive state calls when that state moves — a theme's metric
123/// tokens, today. Unlike rebuilding the widget it keeps the node, its children, and everything they hold; the
124/// engine re-resolves the new style against the current direction exactly as it would at construction.
125pub fn set_layout_style(node: NodeId, style: LayoutStyle) -> Result<(), LayoutError> {
126    with_runtime(|rt| {
127        rt.engine.set_style(node, style)?;
128        rt.engine.mark_dirty(node)
129    })
130}
131
132/// 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).
133pub fn set_display(node: NodeId, visible: bool) {
134    with_runtime(|rt| rt.engine.set_display(node, visible))
135}
136
137/// Lays `node`'s children along the horizontal axis, after the node was built as a column. A reconciling
138/// list boxed inside a `row` calls this: its own node exists before it is attached, so the direction it
139/// should have cannot be known at construction.
140pub fn set_container_row(node: NodeId) {
141    with_runtime(|rt| rt.engine.make_flex_row(node));
142    mark_dirty(node).ok();
143}
144
145/// Whether `node` is a flex row (main axis horizontal). A transparent `for … gap:N` fragment reads its host
146/// container's axis to know which edge the per-item gap margin sits on.
147pub fn container_is_row(node: NodeId) -> bool {
148    with_runtime(|rt| rt.engine.is_row(node))
149}
150
151/// Sets `node`'s leading main-axis margin (`left` for a row host, `top` for a column) to `px` — the primitive
152/// a transparent `for … gap:N` uses to space its items without a container of its own. Marks the node dirty.
153pub fn set_leading_margin(node: NodeId, is_row: bool, px: f32) {
154    with_runtime(|rt| rt.engine.set_leading_margin(node, is_row, px))
155}
156
157/// Sets `node`'s minimum height to `px` after the initial layout (dirtying it, which propagates up), so a
158/// content-measured leaf grows to at least `px` even when its content is shorter. A scrolling editor uses it
159/// to fill its viewport so a click anywhere in the empty area — not just over the text — lands on the leaf.
160pub fn set_min_height(node: NodeId, px: f32) {
161    with_runtime(|rt| rt.engine.set_min_height(node, Some(px)))
162}
163
164/// Replaces `parent`'s children with `children`, in order, marking `parent` dirty. Operates on the
165/// thread-local runtime; `parent` must be a container already registered in the runtime.
166pub fn set_children(parent: NodeId, children: &[NodeId]) -> Result<(), LayoutError> {
167    with_runtime(|rt| rt.set_children(parent, children))
168}
169
170/// Detaches and frees `node` (a former list item) from the runtime: removes it from the layout tree and
171/// drops its rect signal and bookkeeping. The caller must have removed it from its parent's child list
172/// (via [`set_children`]) first.
173pub fn remove_node(node: NodeId) {
174    with_runtime(|rt| rt.remove_node(node))
175}
176
177/// Pins the overlay host to `node` — the app's window-spanning root — so overlays always fill the viewport
178/// even when the app computes several independent layout roots (e.g. a shell with a separate sidebar root
179/// computed after the main one, which the auto-detection would otherwise pick as the host). Call it each
180/// relayout with the current main root (it survives hot-reload rebuilds, which mint a new root node). Once
181/// pinned, auto-detection no longer overrides the host.
182/// The area an overlay may occupy: the laid-out rect of the host its content is attached to, which is the
183/// window (or the surface) it will be composed into.
184///
185/// What a panel needs to stay on screen. Without it an anchored bubble is placed from its trigger alone and
186/// runs off whichever edge the trigger happens to be near — which is not a rare case but the common one, a
187/// tooltip on the rightmost button of a toolbar.
188pub fn overlay_viewport() -> Option<geometry_core::Rect> {
189    with_runtime(|rt| {
190        let host = rt.overlay_host?;
191        rt.engine.layout(host).ok()
192    })
193}
194
195pub fn set_overlay_host(node: NodeId) {
196    with_runtime(|rt| {
197        rt.overlay_host = Some(node);
198        rt.host_pinned = true;
199    });
200}
201
202/// Attaches `node` (an overlay's out-of-flow content) as an extra child of the current layout host — the
203/// top-level root computed against the window — so it fills the viewport regardless of where the `overlay`
204/// was declared in the tree. Returns `true` when attached; `false` when no host has been computed yet (the
205/// caller then falls back to normal in-tree layout). The host is marked dirty so the next frame lays the
206/// portal out.
207pub fn attach_overlay(node: NodeId) -> bool {
208    with_runtime(|rt| {
209        let Some(host) = rt.overlay_host else {
210            return false;
211        };
212        if rt.engine.add_child(host, node).is_err() {
213            return false;
214        }
215        rt.parents.insert(node, host);
216        rt.engine.mark_dirty(host).ok();
217        true
218    })
219}
220
221/// Detaches an overlay's content from the layout host (inverse of [`attach_overlay`]); the caller frees it
222/// afterwards with [`remove_node`]. A no-op if the host is gone.
223pub fn detach_overlay(node: NodeId) {
224    with_runtime(|rt| {
225        // Remove from the host the overlay actually attached to (recorded in `parents` at attach), NOT the
226        // current `overlay_host`: auto-detection may have moved the host to another root (e.g. a nested
227        // scroll's content root) since attach, and taffy panics if `node` is not a child of the node removed.
228        if let Some(host) = rt.parents.remove(&node) {
229            rt.engine.remove_child(host, node).ok();
230            rt.engine.mark_dirty(host).ok();
231        }
232    });
233}
234
235struct LayoutRuntime {
236    engine: LayoutEngine,
237    registry: FxHashMap<NodeId, RwSignal<Rect>>,
238    parents: FxHashMap<NodeId, NodeId>,
239    boundary_nodes: FxHashMap<NodeId, (f32, f32)>,
240    // 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.
241    last_space: FxHashMap<NodeId, (AvailableSpace, AvailableSpace)>,
242    // 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.
243    constrained: Vec<(NodeId, LayoutStyle, Option<f32>)>,
244    // 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.
245    root_auto: FxHashMap<NodeId, (bool, bool)>,
246    // The parent-less (top-level) root last computed against the window — the layout host that `overlay`s
247    // attach their out-of-flow content to, so a portal fills the viewport regardless of where it is declared.
248    overlay_host: Option<NodeId>,
249    // When set, `overlay_host` was pinned by the app via `set_overlay_host` and auto-detection (last
250    // parent-less root wins) must NOT override it. An app with several independent roots (e.g. a shell with a
251    // separate sidebar root computed after the main one) needs this: the window-spanning root is the host,
252    // not whichever root happened to be computed last.
253    host_pinned: bool,
254    // Window-absolute top-left of each node, captured during the top-level (parent-less) root's walk (which
255    // runs from the window origin, so its rects ARE window-absolute). Node rect SIGNALS stay root-local (a
256    // sub-root computed separately, like the sandbox's scrolling `content`, leaves them content-local); this
257    // map is the ONE place with window-absolute positions, so `absolute_rect` can anchor a portaled overlay
258    // (which hoists out of ancestor transforms → needs absolute coords) to a trigger in a sub-root.
259    abs_pos: FxHashMap<NodeId, (f32, f32)>,
260    // 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.
261    #[cfg(debug_assertions)]
262    is_computing: bool,
263}
264
265impl LayoutRuntime {
266    fn new() -> Self {
267        Self {
268            engine: LayoutEngine::new(),
269            registry: FxHashMap::default(),
270            parents: FxHashMap::default(),
271            boundary_nodes: FxHashMap::default(),
272            last_space: FxHashMap::default(),
273            constrained: Vec::new(),
274            root_auto: FxHashMap::default(),
275            overlay_host: None,
276            host_pinned: false,
277            abs_pos: FxHashMap::default(),
278            #[cfg(debug_assertions)]
279            is_computing: false,
280        }
281    }
282
283    fn track_constrained(&mut self, node: NodeId, style: &LayoutStyle) {
284        if style.max_width_px().is_some() {
285            self.constrained.push((node, style.clone(), None));
286        }
287    }
288
289    pub(crate) fn new_leaf(
290        &mut self,
291        style: LayoutStyle,
292    ) -> Result<(NodeId, RwSignal<Rect>), LayoutError> {
293        let node = self.engine.new_leaf(style.clone())?;
294        let signal = signal(Rect::default());
295        self.registry.insert(node, signal.clone());
296        if let Some(dimensions) = self.engine.is_fixed_size(node) {
297            self.boundary_nodes.insert(node, dimensions);
298        }
299        self.track_constrained(node, &style);
300        Ok((node, signal))
301    }
302
303    pub(crate) fn new_measured_leaf(
304        &mut self,
305        style: LayoutStyle,
306        measure: MeasureFn,
307    ) -> Result<(NodeId, RwSignal<Rect>), LayoutError> {
308        let node = self.engine.new_measured_leaf(style.clone(), measure)?;
309        let signal = signal(Rect::default());
310        self.registry.insert(node, signal.clone());
311        self.track_constrained(node, &style);
312        Ok((node, signal))
313    }
314
315    pub(crate) fn new_container(
316        &mut self,
317        style: LayoutStyle,
318        children: &[NodeId],
319    ) -> Result<NodeId, LayoutError> {
320        let node = self.engine.new_container(style.clone(), children)?;
321        let signal = signal(Rect::default());
322        self.registry.insert(node, signal);
323        for &child in children {
324            self.parents.insert(child, node);
325        }
326        if let Some(dimensions) = self.engine.is_fixed_size(node) {
327            self.boundary_nodes.insert(node, dimensions);
328        }
329        self.track_constrained(node, &style);
330        Ok(node)
331    }
332
333    fn compute_layout(
334        &mut self,
335        root: NodeId,
336        width: AvailableSpace,
337        height: AvailableSpace,
338    ) -> Result<Vec<(RwSignal<Rect>, Rect)>, LayoutError> {
339        // A top-level root (no parent) computed against the window is the overlay host: overlays attach
340        // their content here so a portal fills the viewport wherever it is declared. Refreshed each compute
341        // so it stays current across a hot-reload rebuild (which mints a new root node). A definite height
342        // marks the surface/window root; a detached sub-root laid out for its intrinsic height (a scroll's
343        // content, computed with `MaxContent`) must NOT become the host, or a portal declared inside a scroll
344        // would attach to that scroll and be torn down (and mis-detached) with it.
345        if !self.host_pinned
346            && !self.parents.contains_key(&root)
347            && matches!(height, AvailableSpace::Definite(_))
348        {
349            self.overlay_host = Some(root);
350        }
351        // 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.
352        let is_space_changed = self.last_space.get(&root) != Some(&(width, height));
353        if is_space_changed {
354            self.engine.mark_dirty(root).ok();
355            self.last_space.insert(root, (width, height));
356        } else if !self.engine.is_dirty(root) {
357            return Ok(Vec::new());
358        }
359        // 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.
360        let (width_auto, height_auto) = match self.root_auto.get(&root).copied() {
361            Some(v) => v,
362            None => {
363                let v = self.engine.is_size_auto(root);
364                self.root_auto.insert(root, v);
365                v
366            }
367        };
368        // 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.
369        for i in 0..self.constrained.len() {
370            let node = self.constrained[i].0;
371            let had_pin = self.constrained[i].2.is_some();
372            if !is_space_changed && !had_pin {
373                continue;
374            }
375            let style = self.constrained[i].1.clone();
376            self.engine.set_style(node, style).ok();
377            self.engine.mark_dirty(node).ok();
378            self.constrained[i].2 = None;
379        }
380        let mut did_fill_root = false;
381        if width_auto {
382            let w = match width {
383                AvailableSpace::Definite(w) => Some(w),
384                _ => None,
385            };
386            self.engine.set_width(root, w);
387            did_fill_root = true;
388        }
389        if height_auto {
390            let h = match height {
391                AvailableSpace::Definite(h) => Some(h),
392                _ => None,
393            };
394            self.engine.set_height(root, h);
395            did_fill_root = true;
396        }
397        if did_fill_root {
398            self.engine.mark_dirty(root).ok();
399        }
400        let mut dirty_nodes = Vec::new();
401        self.engine.collect_dirty_nodes(root, &mut dirty_nodes);
402        if dirty_nodes.is_empty() {
403            return Ok(Vec::new());
404        }
405        #[cfg(debug_assertions)]
406        {
407            assert!(
408                !self.is_computing,
409                "[rsx layout] cycle detected: compute_layout() called recursively. \
410                 An effect is reading a layout signal and then calling compute_layout() again inside its body. \
411                 This causes an infinite re-layout loop (capped by MAX_FLUSH_ITERATIONS). \
412                 Move style mutations outside of layout-observing effects."
413            );
414            self.is_computing = true;
415        }
416        let (layout_root, layout_width, layout_height) =
417            self.find_boundary_root(&dirty_nodes, root, width, height);
418        self.engine
419            .compute_layout(layout_root, layout_width, layout_height)?;
420        // 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.
421        let mut did_pin_any = false;
422        for i in 0..self.constrained.len() {
423            let node = self.constrained[i].0;
424            let style = self.constrained[i].1.clone();
425            let Some(max_w) = style.max_width_px() else {
426                continue;
427            };
428            if !self.is_in_subtree(node, layout_root) {
429                continue;
430            }
431            if let Ok(layout) = self.engine.layout(node) {
432                if layout.width > 0.0 && layout.width <= max_w + 0.5 {
433                    self.engine.set_style(node, style.width(layout.width)).ok();
434                    self.engine.mark_dirty(node).ok();
435                    self.constrained[i].2 = Some(layout.width);
436                    did_pin_any = true;
437                }
438            }
439        }
440        if did_pin_any {
441            self.engine
442                .compute_layout(layout_root, layout_width, layout_height)?;
443        }
444        // Collect the changed rects while holding the runtime borrow, but apply them (`sig.set`) only
445        // after the caller releases it: a set flushes effects, one of which may re-enter the runtime.
446        let mut updates: Vec<(RwSignal<Rect>, Rect)> = Vec::new();
447        // Only a full walk of a parent-less root runs from the window origin, so only then are the walked
448        // rects window-absolute. A sub-boundary or sub-root walk is root-local — don't capture those.
449        let is_window_walk = layout_root == root && !self.parents.contains_key(&root);
450        let mut abs_updates: Vec<(NodeId, f32, f32)> = Vec::new();
451        let registry = &self.registry;
452        let walk_result = self.engine.walk(layout_root, &mut |node_id, rect| {
453            if let Some(sig) = registry.get(&node_id) {
454                if sig.peek() != rect {
455                    updates.push((sig.clone(), rect));
456                }
457            }
458            if is_window_walk {
459                abs_updates.push((node_id, rect.x, rect.y));
460            }
461            true
462        });
463        for (n, x, y) in abs_updates {
464            self.abs_pos.insert(n, (x, y));
465        }
466        #[cfg(debug_assertions)]
467        {
468            self.is_computing = false;
469        }
470        walk_result.map(|()| updates)
471    }
472
473    fn find_boundary_root(
474        &self,
475        dirty_nodes: &[NodeId],
476        global_root: NodeId,
477        global_width: AvailableSpace,
478        global_height: AvailableSpace,
479    ) -> (NodeId, AvailableSpace, AvailableSpace) {
480        let candidate = dirty_nodes
481            .iter()
482            .find_map(|&node| self.find_nearest_boundary(node));
483        match candidate {
484            Some((boundary, boundary_width, boundary_height))
485                if dirty_nodes.iter().all(|&n| self.is_in_subtree(n, boundary)) =>
486            {
487                (
488                    boundary,
489                    AvailableSpace::Definite(boundary_width),
490                    AvailableSpace::Definite(boundary_height),
491                )
492            }
493            _ => (global_root, global_width, global_height),
494        }
495    }
496
497    fn find_nearest_boundary(&self, mut node: NodeId) -> Option<(NodeId, f32, f32)> {
498        loop {
499            if let Some(&(w, h)) = self.boundary_nodes.get(&node) {
500                return Some((node, w, h));
501            }
502            node = *self.parents.get(&node)?;
503        }
504    }
505
506    fn is_hidden_by_display(&self, mut node: NodeId) -> bool {
507        loop {
508            if self.engine.is_display_none(node) {
509                return true;
510            }
511            match self.parents.get(&node) {
512                Some(&parent) => node = parent,
513                None => return false,
514            }
515        }
516    }
517
518    fn is_in_subtree(&self, mut node: NodeId, ancestor: NodeId) -> bool {
519        loop {
520            if node == ancestor {
521                return true;
522            }
523            match self.parents.get(&node) {
524                Some(&parent) => node = parent,
525                None => return false,
526            }
527        }
528    }
529
530    pub(crate) fn track_layout(&self, node: NodeId) -> Option<RwSignal<Rect>> {
531        self.registry.get(&node).cloned()
532    }
533
534    fn set_children(&mut self, parent: NodeId, children: &[NodeId]) -> Result<(), LayoutError> {
535        self.engine.set_children(parent, children)?;
536        for &child in children {
537            self.parents.insert(child, parent);
538        }
539        self.engine.mark_dirty(parent).ok();
540        Ok(())
541    }
542
543    fn remove_node(&mut self, node: NodeId) {
544        self.engine.remove(node);
545        self.registry.remove(&node);
546        self.parents.remove(&node);
547        self.boundary_nodes.remove(&node);
548        self.last_space.remove(&node);
549        self.root_auto.remove(&node);
550        self.abs_pos.remove(&node);
551        self.constrained.retain(|(n, _, _)| *n != node);
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use geometry_core::Rect;
558    use layout_core::{LayoutStyle, SizeDimension};
559
560    use super::*;
561
562    // The premise the whole `constrained` two-pass exists for, and the only test that reaches it: a child whose HEIGHT depends on the width it is given. The fixed-size children below cannot show it — their height is the same at every width — so until this existed the mechanism had no guard at all.
563    //
564    // It passes with the pin pass disabled, on taffy 0.13: the workaround was written against 0.11 and the behaviour appears to be gone, which would make the undo pass, the re-pin pass and the extra `compute_layout` dead weight. Not deleted on that evidence alone — one synthetic measure fn is not `apps/landing`'s wrapping bands, and the failure mode is a layout quietly wrong rather than one that fails to build.
565    #[test]
566    fn a_maxwidth_box_measures_its_content_at_the_capped_width() {
567        reset_layout_runtime();
568        const TOTAL: f32 = 1200.0;
569        const LINE: f32 = 20.0;
570        // Wrapping text: at whatever width it is offered, it needs `TOTAL / width` lines of `LINE` height.
571        let measure: layout_core::MeasureFn = Box::new(|available: f32| {
572            let width = if available > 0.0 { available } else { TOTAL };
573            (width, (TOTAL / width).ceil() * LINE)
574        });
575        let (text, text_rect) = new_measured_leaf(LayoutStyle::new(), measure).unwrap();
576        let box_node =
577            new_container(LayoutStyle::new().flex_column().max_width(400.0), &[text]).unwrap();
578        let root = new_container(LayoutStyle::new().flex_column(), &[box_node]).unwrap();
579        let box_rect = track_layout(box_node).unwrap();
580
581        compute_layout(
582            root,
583            AvailableSpace::Definite(1000.0),
584            AvailableSpace::Definite(1000.0),
585        )
586        .unwrap();
587
588        assert_eq!(text_rect.get().width, 400.0);
589        assert_eq!(
590            box_rect.get().height,
591            3.0 * LINE,
592            "the box has to reserve the height its content takes at the width it was capped to"
593        );
594    }
595
596    // 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.
597    #[test]
598    fn maxwidth_box_reserves_height_for_wrapped_content() {
599        reset_layout_runtime();
600        let mut items = Vec::new();
601        for _ in 0..4 {
602            let (n, _) = new_leaf(
603                LayoutStyle::new()
604                    .width(200.0)
605                    .height(100.0)
606                    .min_width(200.0)
607                    .flex_grow(1.0),
608            )
609            .unwrap();
610            items.push(n);
611        }
612        let row =
613            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &items).unwrap();
614        // Capped to 500 → 2 items per row → the 4 items wrap onto 2 lines.
615        let boxed = new_container(
616            LayoutStyle::new()
617                .flex_column()
618                .width(SizeDimension::Percent(1.0))
619                .max_width(500.0),
620            &[row],
621        )
622        .unwrap();
623        let page = new_container(
624            LayoutStyle::new()
625                .flex_column()
626                .width(SizeDimension::Percent(1.0)),
627            &[boxed],
628        )
629        .unwrap();
630        compute_layout(
631            page,
632            AvailableSpace::Definite(900.0),
633            AvailableSpace::MaxContent,
634        )
635        .unwrap();
636        let box_rect = track_layout(boxed).unwrap().get();
637        let row_rect = track_layout(row).unwrap().get();
638        assert!(
639            (box_rect.width - 500.0).abs() < 1.0,
640            "box not capped: {box_rect:?}"
641        );
642        assert!(
643            row_rect.height >= 200.0,
644            "row did not wrap to 2 lines: {row_rect:?}"
645        );
646        assert!(
647            box_rect.height >= row_rect.height - 0.5,
648            "box too short for wrapped content: box={box_rect:?} row={row_rect:?}"
649        );
650    }
651
652    // A wrapping flex row must reserve height for ALL its lines so a following sibling sits below it instead
653    // of overlapping. Reproduces the "next section positions as if the wrapped card didn't exist" report.
654    #[test]
655    fn wrapped_flex_row_reserves_height_for_all_lines() {
656        reset_layout_runtime();
657        let mut cards = Vec::new();
658        for _ in 0..4 {
659            let (n, _) = new_leaf(
660                LayoutStyle::new()
661                    .min_width(260.0)
662                    .height(100.0)
663                    .flex_grow(1.0),
664            )
665            .unwrap();
666            cards.push(n);
667        }
668        let row =
669            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &cards).unwrap();
670        let (marker, _) = new_leaf(LayoutStyle::new().height(50.0)).unwrap();
671        let col =
672            new_container(LayoutStyle::new().flex_column().gap(20.0), &[row, marker]).unwrap();
673        // 900px wide → 3 cards on line 1, the 4th wraps to line 2.
674        compute_layout(
675            col,
676            AvailableSpace::Definite(900.0),
677            AvailableSpace::MaxContent,
678        )
679        .unwrap();
680        let row_rect = track_layout(row).unwrap().get();
681        let marker_rect = track_layout(marker).unwrap().get();
682        assert!(
683            row_rect.height >= 220.0,
684            "wrapped row height {} should cover 2 lines (~224)",
685            row_rect.height
686        );
687        assert!(
688            marker_rect.y >= row_rect.y + row_rect.height - 0.5,
689            "marker overlaps wrapped row: row.y={} row.h={} marker.y={}",
690            row_rect.y,
691            row_rect.height,
692            marker_rect.y
693        );
694    }
695
696    // Same as above but the cards are content-sized containers (a column whose height comes from its
697    // children) with grow:1 — the real feature-card shape.
698    #[test]
699    fn wrapped_content_sized_cards_reserve_height() {
700        reset_layout_runtime();
701        let mut cards = Vec::new();
702        for _ in 0..4 {
703            let (inner, _) = new_leaf(LayoutStyle::new().height(100.0)).unwrap();
704            let card = new_container(
705                LayoutStyle::new()
706                    .flex_column()
707                    .min_width(260.0)
708                    .flex_grow(1.0),
709                &[inner],
710            )
711            .unwrap();
712            cards.push(card);
713        }
714        let row =
715            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &cards).unwrap();
716        let (marker, _) = new_leaf(LayoutStyle::new().height(50.0)).unwrap();
717        let col =
718            new_container(LayoutStyle::new().flex_column().gap(20.0), &[row, marker]).unwrap();
719        compute_layout(
720            col,
721            AvailableSpace::Definite(900.0),
722            AvailableSpace::MaxContent,
723        )
724        .unwrap();
725        let row_rect = track_layout(row).unwrap().get();
726        let marker_rect = track_layout(marker).unwrap().get();
727        assert!(
728            row_rect.height >= 220.0,
729            "wrapped content-sized row height {} should cover 2 lines (~224)",
730            row_rect.height
731        );
732        assert!(
733            marker_rect.y >= row_rect.y + row_rect.height - 0.5,
734            "marker overlaps row: row.y={} row.h={} marker.y={}",
735            row_rect.y,
736            row_rect.height,
737            marker_rect.y
738        );
739    }
740
741    // 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.
742    #[test]
743    fn maxwidth_box_stable_across_recompute() {
744        reset_layout_runtime();
745        let mut items = Vec::new();
746        for _ in 0..4 {
747            let (n, _) = new_leaf(
748                LayoutStyle::new()
749                    .width(200.0)
750                    .height(100.0)
751                    .min_width(200.0)
752                    .flex_grow(1.0),
753            )
754            .unwrap();
755            items.push(n);
756        }
757        let row =
758            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &items).unwrap();
759        let boxed = new_container(
760            LayoutStyle::new()
761                .flex_column()
762                .width(SizeDimension::Percent(1.0))
763                .max_width(500.0),
764            &[row],
765        )
766        .unwrap();
767        let page = new_container(
768            LayoutStyle::new()
769                .flex_column()
770                .width(SizeDimension::Percent(1.0)),
771            &[boxed],
772        )
773        .unwrap();
774
775        let space = (AvailableSpace::Definite(900.0), AvailableSpace::MaxContent);
776        compute_layout(page, space.0, space.1).unwrap();
777        let first = track_layout(boxed).unwrap().get();
778
779        // Re-dirty the root and recompute at the SAME space: exercises the idempotent undo on an already-pinned box.
780        mark_dirty(page).unwrap();
781        compute_layout(page, space.0, space.1).unwrap();
782        let second = track_layout(boxed).unwrap().get();
783
784        assert!(
785            (second.width - 500.0).abs() < 1.0,
786            "box not capped on recompute: {second:?}"
787        );
788        assert!(
789            (first.width - second.width).abs() < 0.5 && (first.height - second.height).abs() < 0.5,
790            "box layout drifted across recompute: first={first:?} second={second:?}"
791        );
792    }
793
794    // Regression: the resize undo pass used to overwrite a constrained node's whole style from its construction-time LayoutStyle, silently reverting an out-of-band set_display(false) along with the width pin.
795    #[test]
796    fn hidden_maxwidth_box_stays_hidden_after_a_resize() {
797        reset_layout_runtime();
798        let mut items = Vec::new();
799        for _ in 0..4 {
800            let (n, _) = new_leaf(
801                LayoutStyle::new()
802                    .width(200.0)
803                    .height(100.0)
804                    .min_width(200.0)
805                    .flex_grow(1.0),
806            )
807            .unwrap();
808            items.push(n);
809        }
810        let row =
811            new_container(LayoutStyle::new().flex_row().flex_wrap().gap(24.0), &items).unwrap();
812        let boxed = new_container(
813            LayoutStyle::new()
814                .flex_column()
815                .width(SizeDimension::Percent(1.0))
816                .max_width(500.0),
817            &[row],
818        )
819        .unwrap();
820        let page = new_container(
821            LayoutStyle::new()
822                .flex_column()
823                .width(SizeDimension::Percent(1.0)),
824            &[boxed],
825        )
826        .unwrap();
827
828        compute_layout(
829            page,
830            AvailableSpace::Definite(900.0),
831            AvailableSpace::MaxContent,
832        )
833        .unwrap();
834
835        set_display(boxed, false);
836        mark_dirty(page).unwrap();
837        assert!(is_hidden(boxed), "hidden right after set_display");
838
839        // The undo pass lifts boxed's previous width pin here (available space changed); must not also un-hide it.
840        compute_layout(
841            page,
842            AvailableSpace::Definite(700.0),
843            AvailableSpace::MaxContent,
844        )
845        .unwrap();
846        assert!(
847            is_hidden(boxed),
848            "resize must not revert the out-of-band hide"
849        );
850    }
851
852    // 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.
853    #[test]
854    fn auto_root_fills_definite_width() {
855        reset_layout_runtime();
856        let (child, _) = new_leaf(LayoutStyle::new().height(40.0)).unwrap();
857        // 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.
858        let page = new_container(LayoutStyle::new().flex_column(), &[child]).unwrap();
859        compute_layout(
860            page,
861            AvailableSpace::Definite(1000.0),
862            AvailableSpace::MaxContent,
863        )
864        .unwrap();
865        let w = track_layout(page).unwrap().get().width;
866        assert!(
867            (w - 1000.0).abs() < 1.0,
868            "auto root did not fill width: {w}"
869        );
870    }
871
872    // 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.
873    #[test]
874    fn hidden_child_collapses_to_zero_rect() {
875        // A section toggled to `display:none` must collapse to a zero rect so its view draws nothing and
876        // does not overlap the visible section (the tab-switch mechanism in the sandbox relies on this).
877        reset_layout_runtime();
878        let (a, _) = new_leaf(LayoutStyle::new().width(50.0).height(30.0)).unwrap();
879        let (b, b_rect) = new_leaf(LayoutStyle::new().width(50.0).height(30.0)).unwrap();
880        let root = new_container(LayoutStyle::new().flex_column(), &[a, b]).unwrap();
881        compute_layout(
882            root,
883            AvailableSpace::Definite(200.0),
884            AvailableSpace::Definite(200.0),
885        )
886        .unwrap();
887        assert!(b_rect.get().height > 0.0, "b should start visible");
888
889        set_display(b, false);
890        mark_dirty(root).unwrap();
891        compute_layout(
892            root,
893            AvailableSpace::Definite(200.0),
894            AvailableSpace::Definite(200.0),
895        )
896        .unwrap();
897        let r = b_rect.get();
898        assert_eq!(
899            (r.width, r.height),
900            (0.0, 0.0),
901            "hidden child not collapsed: {r:?}"
902        );
903    }
904
905    // Hiding a section must collapse its whole subtree, not just the section node: taffy leaves stale
906    // layouts on descendants of a `display:none` node, so without zeroing them a Canvas (which paints at
907    // fixed coordinates) in a hidden section would still draw over the visible one.
908    #[test]
909    fn hidden_subtree_collapses_descendants() {
910        reset_layout_runtime();
911        let (grandchild, gc_rect) = new_leaf(LayoutStyle::new().width(40.0).height(20.0)).unwrap();
912        let section = new_container(LayoutStyle::new().flex_column(), &[grandchild]).unwrap();
913        let root = new_container(LayoutStyle::new().flex_column(), &[section]).unwrap();
914        compute_layout(
915            root,
916            AvailableSpace::Definite(200.0),
917            AvailableSpace::Definite(200.0),
918        )
919        .unwrap();
920        assert!(gc_rect.get().width > 0.0, "grandchild should start visible");
921
922        set_display(section, false);
923        mark_dirty(root).unwrap();
924        compute_layout(
925            root,
926            AvailableSpace::Definite(200.0),
927            AvailableSpace::Definite(200.0),
928        )
929        .unwrap();
930        let r = gc_rect.get();
931        assert_eq!(
932            (r.width, r.height),
933            (0.0, 0.0),
934            "descendant of hidden section not collapsed: {r:?}"
935        );
936    }
937
938    #[test]
939    fn auto_root_with_max_width_fills_capped() {
940        reset_layout_runtime();
941        let (child, _) = new_leaf(LayoutStyle::new().height(40.0)).unwrap();
942        let page =
943            new_container(LayoutStyle::new().flex_column().max_width(600.0), &[child]).unwrap();
944        // Wider than the cap: should fill up to max_width (600), not shrink to content (0).
945        compute_layout(
946            page,
947            AvailableSpace::Definite(1000.0),
948            AvailableSpace::MaxContent,
949        )
950        .unwrap();
951        let w = track_layout(page).unwrap().get().width;
952        assert!((w - 600.0).abs() < 1.0, "capped fill failed: {w}");
953        // Narrower than the cap: should fill the available width (400).
954        compute_layout(
955            page,
956            AvailableSpace::Definite(400.0),
957            AvailableSpace::MaxContent,
958        )
959        .unwrap();
960        let w = track_layout(page).unwrap().get().width;
961        assert!((w - 400.0).abs() < 1.0, "sub-cap fill failed: {w}");
962    }
963
964    // The landing/sandbox shell pattern: an auto-width outer that fills and centers a capped inner column.
965    #[test]
966    fn centered_capped_column_tracks_width() {
967        reset_layout_runtime();
968        let (child, _) = new_leaf(LayoutStyle::new().height(40.0)).unwrap();
969        let inner = new_container(
970            LayoutStyle::new()
971                .flex_column()
972                .width(SizeDimension::Percent(1.0))
973                .max_width(960.0),
974            &[child],
975        )
976        .unwrap();
977        let outer = new_container(
978            LayoutStyle::new()
979                .flex_column()
980                .align_items(layout_core::AlignItems::CENTER),
981            &[inner],
982        )
983        .unwrap();
984        let inner_rect = track_layout(inner).unwrap();
985        let outer_rect = track_layout(outer).unwrap();
986        // Wide window: outer fills 1400, inner caps at 960 and is centered ((1400-960)/2 = 220).
987        compute_layout(
988            outer,
989            AvailableSpace::Definite(1400.0),
990            AvailableSpace::MaxContent,
991        )
992        .unwrap();
993        assert!(
994            (outer_rect.get().width - 1400.0).abs() < 1.0,
995            "outer fill: {}",
996            outer_rect.get().width
997        );
998        assert!(
999            (inner_rect.get().width - 960.0).abs() < 1.0,
1000            "inner cap: {}",
1001            inner_rect.get().width
1002        );
1003        assert!(
1004            (inner_rect.get().x - 220.0).abs() < 1.0,
1005            "inner centered: {}",
1006            inner_rect.get().x
1007        );
1008        // Narrow window: inner fills the full width and centering adds no margin.
1009        compute_layout(
1010            outer,
1011            AvailableSpace::Definite(700.0),
1012            AvailableSpace::MaxContent,
1013        )
1014        .unwrap();
1015        assert!(
1016            (inner_rect.get().width - 700.0).abs() < 1.0,
1017            "inner tracks narrow: {}",
1018            inner_rect.get().width
1019        );
1020        assert!(
1021            inner_rect.get().x.abs() < 1.0,
1022            "no margin when full: {}",
1023            inner_rect.get().x
1024        );
1025    }
1026
1027    // set_min_height grows a content-measured leaf to fill a viewport it would otherwise underflow (the
1028    // notebook editor's fill-the-viewport trick); a leaf whose content already exceeds the floor is untouched.
1029    #[test]
1030    fn set_min_height_grows_short_measured_leaf() {
1031        reset_layout_runtime();
1032        // A measured leaf reporting a fixed 20px content height, like a one-line text area.
1033        let (leaf, rect) = new_measured_leaf(
1034            LayoutStyle::new().width(SizeDimension::Percent(1.0)),
1035            Box::new(|_w| (0.0, 20.0)),
1036        )
1037        .unwrap();
1038        let root = new_container(
1039            LayoutStyle::new()
1040                .flex_column()
1041                .width(SizeDimension::Percent(1.0)),
1042            &[leaf],
1043        )
1044        .unwrap();
1045        let space = (AvailableSpace::Definite(300.0), AvailableSpace::MaxContent);
1046        compute_layout(root, space.0, space.1).unwrap();
1047        assert!(
1048            (rect.get().height - 20.0).abs() < 0.5,
1049            "starts at its content height: {:?}",
1050            rect.get()
1051        );
1052
1053        // Grown to 200: the leaf now fills that height even though its content is only 20px tall.
1054        set_min_height(leaf, 200.0);
1055        compute_layout(root, space.0, space.1).unwrap();
1056        assert!(
1057            (rect.get().height - 200.0).abs() < 0.5,
1058            "min_height fills the short leaf: {:?}",
1059            rect.get()
1060        );
1061
1062        // Cleared back to auto: the leaf collapses to its content height again.
1063        set_min_height(leaf, 0.0);
1064        compute_layout(root, space.0, space.1).unwrap();
1065        assert!(
1066            (rect.get().height - 20.0).abs() < 0.5,
1067            "a zero floor restores the content height: {:?}",
1068            rect.get()
1069        );
1070    }
1071
1072    // Regression: set_layout_style (what a styled_by closure calls on every reactive re-run) used to overwrite the node's whole style, discarding an unrelated out-of-band set_min_height. No max_width involved.
1073    #[test]
1074    fn min_height_survives_a_later_set_layout_style() {
1075        reset_layout_runtime();
1076        let (leaf, rect) = new_measured_leaf(
1077            LayoutStyle::new().width(SizeDimension::Percent(1.0)),
1078            Box::new(|_w| (0.0, 20.0)),
1079        )
1080        .unwrap();
1081        let root = new_container(
1082            LayoutStyle::new()
1083                .flex_column()
1084                .width(SizeDimension::Percent(1.0)),
1085            &[leaf],
1086        )
1087        .unwrap();
1088        let space = (AvailableSpace::Definite(300.0), AvailableSpace::MaxContent);
1089        compute_layout(root, space.0, space.1).unwrap();
1090
1091        set_min_height(leaf, 200.0);
1092        compute_layout(root, space.0, space.1).unwrap();
1093        assert!(
1094            (rect.get().height - 200.0).abs() < 0.5,
1095            "min_height applied: {:?}",
1096            rect.get()
1097        );
1098
1099        // A wholesale restyle unrelated to min_height must not discard the floor.
1100        set_layout_style(leaf, LayoutStyle::new().width(SizeDimension::Percent(1.0))).unwrap();
1101        compute_layout(root, space.0, space.1).unwrap();
1102        assert!(
1103            (rect.get().height - 200.0).abs() < 0.5,
1104            "min_height survives a later set_layout_style: {:?}",
1105            rect.get()
1106        );
1107    }
1108
1109    #[test]
1110    fn ctx_register_leaf_returns_ok() {
1111        reset_layout_runtime();
1112        let result = new_leaf(LayoutStyle::new());
1113        assert!(result.is_ok());
1114    }
1115
1116    #[test]
1117    fn ctx_new_container_returns_ok() {
1118        reset_layout_runtime();
1119        let leaf_result = new_leaf(LayoutStyle::new());
1120        assert!(leaf_result.is_ok());
1121        let (leaf, _) = leaf_result.unwrap();
1122        let container_result = new_container(LayoutStyle::new(), &[leaf]);
1123        assert!(container_result.is_ok());
1124    }
1125
1126    #[test]
1127    fn ctx_register_leaf_returns_zero_rect() {
1128        reset_layout_runtime();
1129        let (_node, rect) = new_leaf(LayoutStyle::new()).unwrap();
1130        assert_eq!(rect.get(), Rect::default());
1131    }
1132
1133    #[test]
1134    fn ctx_compute_updates_rect() {
1135        reset_layout_runtime();
1136        let (leaf, rect) = new_leaf(LayoutStyle::new().width(100.0).height(50.0)).unwrap();
1137        let root = new_container(
1138            LayoutStyle::new().flex_row().width(200.0).height(100.0),
1139            &[leaf],
1140        )
1141        .unwrap();
1142        compute_layout(
1143            root,
1144            AvailableSpace::Definite(200.0),
1145            AvailableSpace::Definite(100.0),
1146        )
1147        .unwrap();
1148        assert_eq!(rect.get().width, 100.0);
1149        assert_eq!(rect.get().height, 50.0);
1150    }
1151
1152    #[test]
1153    fn setting_the_direction_signal_reaches_the_engine_on_the_next_layout_pass() {
1154        // Nothing rebuilds, so the rect signals asserted here are the same ones the widgets already hold.
1155        reset_layout_runtime();
1156        crate::set_direction(layout_core::Direction::Ltr);
1157        let (first, first_rect) = new_leaf(LayoutStyle::new().width(40.0).height(10.0)).unwrap();
1158        let (second, second_rect) = new_leaf(LayoutStyle::new().width(40.0).height(10.0)).unwrap();
1159        let root = new_container(
1160            LayoutStyle::new().flex_row().width(200.0).height(100.0),
1161            &[first, second],
1162        )
1163        .unwrap();
1164        let space = || {
1165            (
1166                AvailableSpace::Definite(200.0),
1167                AvailableSpace::Definite(100.0),
1168            )
1169        };
1170        let (w, h) = space();
1171        compute_layout(root, w, h).unwrap();
1172        assert_eq!(first_rect.get().x, 0.0);
1173        assert_eq!(second_rect.get().x, 40.0);
1174
1175        crate::set_direction(layout_core::Direction::Rtl);
1176        mark_dirty(root).unwrap();
1177        let (w, h) = space();
1178        compute_layout(root, w, h).unwrap();
1179        assert_eq!(first_rect.get().x, 160.0, "the row now starts at the right");
1180        assert_eq!(second_rect.get().x, 120.0);
1181        crate::set_direction(layout_core::Direction::Ltr);
1182    }
1183
1184    // An overlay's content, attached to the host, fills the viewport — not the small box it was declared in.
1185    #[test]
1186    fn attached_overlay_fills_host_viewport_not_its_small_parent() {
1187        reset_layout_runtime();
1188        // Computing a parent-less root registers it as the overlay host (an 800×600 viewport).
1189        let (small, _) = new_leaf(LayoutStyle::new().width(50.0).height(50.0)).unwrap();
1190        let root = new_container(LayoutStyle::new().flex_column(), &[small]).unwrap();
1191        compute_layout(
1192            root,
1193            AvailableSpace::Definite(800.0),
1194            AvailableSpace::Definite(600.0),
1195        )
1196        .unwrap();
1197
1198        // Overlay content: an absolute-fill container with a 100%×100% inner leaf we can measure.
1199        let (inner, inner_rect) = new_leaf(
1200            LayoutStyle::new()
1201                .width(SizeDimension::Percent(1.0))
1202                .height(SizeDimension::Percent(1.0)),
1203        )
1204        .unwrap();
1205        let content = new_container(LayoutStyle::new().absolute_fill(), &[inner]).unwrap();
1206        assert!(
1207            attach_overlay(content),
1208            "the host must be set after the first compute"
1209        );
1210        relayout_if_dirty();
1211
1212        let r = inner_rect.get();
1213        assert!(
1214            (r.width - 800.0).abs() < 0.5 && (r.height - 600.0).abs() < 0.5,
1215            "portal fills the viewport, not its 50px parent: {r:?}"
1216        );
1217
1218        // Detaching and freeing the content must leave the host laying out cleanly (no panic, still valid).
1219        detach_overlay(content);
1220        remove_node(content);
1221        relayout_if_dirty();
1222    }
1223
1224    // Reproduces the sandbox shell's coordinate trap: a `[sidebar | content]` window root, then the `content`
1225    // computed AGAIN as its own root (for scroll-height measurement) — which rewrites the content subtree's
1226    // rect signals to content-local coords. `absolute_rect` must still report a trigger's WINDOW-absolute
1227    // position (past the sidebar), so a portaled dropdown anchors correctly instead of landing over the sidebar.
1228    #[test]
1229    fn absolute_rect_stays_window_absolute_across_a_separate_content_root() {
1230        reset_layout_runtime();
1231        let (sidebar, _) = new_leaf(LayoutStyle::new().width(248.0).height(600.0)).unwrap();
1232        let (trigger, trigger_sig) =
1233            new_leaf(LayoutStyle::new().width(120.0).height(30.0)).unwrap();
1234        let content =
1235            new_container(LayoutStyle::new().flex_column().flex_grow(1.0), &[trigger]).unwrap();
1236        let root = new_container(LayoutStyle::new().flex_row(), &[sidebar, content]).unwrap();
1237        compute_layout(
1238            root,
1239            AvailableSpace::Definite(1000.0),
1240            AvailableSpace::Definite(600.0),
1241        )
1242        .unwrap();
1243        set_overlay_host(root);
1244        // The trigger is at window x ≈ 248 (immediately right of the sidebar).
1245        assert!(
1246            (absolute_rect(trigger).unwrap().x - 248.0).abs() < 1.0,
1247            "abs x should be past the 248px sidebar: {:?}",
1248            absolute_rect(trigger)
1249        );
1250
1251        // Compute `content` as its own root (the sandbox does this for scroll height): the SIGNAL goes local.
1252        mark_dirty(content).unwrap();
1253        compute_layout(
1254            content,
1255            AvailableSpace::Definite(752.0),
1256            AvailableSpace::MaxContent,
1257        )
1258        .unwrap();
1259        assert!(
1260            trigger_sig.get().x < 1.0,
1261            "the rect signal is now content-local (~0): {:?}",
1262            trigger_sig.get()
1263        );
1264        // But absolute_rect still reports window-absolute (past the sidebar) — this is the fix.
1265        assert!(
1266            (absolute_rect(trigger).unwrap().x - 248.0).abs() < 1.0,
1267            "absolute_rect must stay window-absolute across the sub-root compute: {:?}",
1268            absolute_rect(trigger)
1269        );
1270    }
1271}