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