Skip to main content

teksilo_core/widget_tree/
layout_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6impl WidgetTree {
7    /// Process dirty state bindings: mark bound widgets for repaint, relayout,
8    /// or rebuild. Called automatically at the start of layout().
9    pub(super) fn process_state_changes(&mut self, ops: &mut dyn crate::window::WindowOps) {
10        // Refresh the node-resident `effective_enabled_signal`s FIRST, so a
11        // widget bound to one is dirty-marked in time for the binding flush
12        // immediately below to drain it in this same pass, rather than a frame
13        // late. This is also where a signal seeded during `build()` — when the
14        // widget's parent was not yet wired, so the seed could only see its own
15        // `enabled` prop — is corrected against the now-complete tree.
16        self.flush_effective_enabled_signals();
17
18        // One unified flush: both visual buckets and the a11y flag
19        // are drained from the same walk, so a signal bound at both
20        // a visual level and `AccessibilityOnly` (e.g. a Button's
21        // `label` re-registers the same Signal at RepaintOnly
22        // *and* AccessibilityOnly) flips both. Two separate flushes
23        // would each advance this registry's last-seen generation for
24        // that source, so the second would find nothing to report.
25        let (dirty_widgets, a11y_binding_dirty) = self.binding_registry.flush_all_dirty();
26        for (id, level) in &dirty_widgets {
27            match level {
28                crate::binding::BindingLevel::RepaintOnly => {
29                    self.arena.mark_needs_paint(*id);
30                }
31                crate::binding::BindingLevel::SubtreeRepaint => {
32                    // Used by `enabled_when` so the leaves in the
33                    // disabled subtree re-resolve their role colors
34                    // via the paint walker's `effective_enabled`.
35                    // No layout work — geometry is unchanged.
36                    self.arena.mark_subtree_needs_paint(*id);
37                }
38                crate::binding::BindingLevel::Relayout => {
39                    self.arena.mark_needs_layout(*id);
40                    self.arena.mark_ancestors_need_layout(*id);
41                }
42                crate::binding::BindingLevel::Rebuild => {
43                    self.arena.mark_needs_rebuild(*id);
44                    self.arena.mark_ancestors_need_layout(*id);
45                }
46                crate::binding::BindingLevel::AccessibilityOnly => {
47                    // Drained into the boolean below — never appears in
48                    // the visual map, but kept in the match so a future
49                    // variant addition is a compile-time reminder.
50                }
51            }
52        }
53
54        // Orthogonal to the visual dirty pass: if any signal bound at
55        // `BindingLevel::AccessibilityOnly` fired, flip the tree-wide
56        // `a11y_dirty` flag so the next `sync_accessibility` rebuilds
57        // the AccessKit tree. Decoupled from layout / paint so a text
58        // edit that changes no visual geometry still reaches screen
59        // readers within one frame.
60        if a11y_binding_dirty {
61            self.a11y_dirty = true;
62        }
63
64        // Rebuild data-driven widgets whose data model changed.
65        self.process_pending_rebuilds(&mut *ops);
66
67        let mut to_dormant = Vec::new();
68        let mut to_activate = Vec::new();
69        for (id, is_active, should_be_visible) in self.arena.visibility_checks_iter() {
70            if is_active && !should_be_visible {
71                to_dormant.push(id);
72            } else if !is_active && should_be_visible {
73                // Only wake a `visible_when(true)` node whose parent is active.
74                // A gated node inside a dormant ancestor (e.g. a row in a
75                // closed popover / overflow menu) must NOT escape that
76                // ancestor's dormancy and render on its own. When the ancestor
77                // is later activated, `arena.activate` wakes this node via the
78                // cascade (its gate is true). The dormancy invariant — an
79                // active node has an active parent — makes the immediate-parent
80                // check sufficient.
81                let parent_active = self
82                    .arena
83                    .parent(id)
84                    .map(|p| self.arena.is_active(p))
85                    .unwrap_or(true);
86                if parent_active {
87                    to_activate.push(id);
88                }
89            }
90        }
91        // The accessibility walk skips dormant nodes, so any
92        // active↔dormant transition changes the AccessKit tree shape
93        // and must dirty the cached snapshot. Other Relayout-causing
94        // signal flips (a Switcher visibility binding that doesn't
95        // straddle activation, an opacity change) do not — the
96        // unconditional `a11y_dirty = true` was removed from `layout()`
97        // and is now set only by events that actually change the AT tree.
98        //
99        // A *resize* is one of them: since a label carries its text one
100        // run per visual line, re-wrapping it at a new width produces a
101        // different set of runs, not the same set somewhere else. A pure
102        // translation is absorbed by `sync_accessibility` instead, which
103        // re-places the cached nodes without walking.
104        if !to_dormant.is_empty() || !to_activate.is_empty() {
105            self.a11y_dirty = true;
106        }
107        for id in to_dormant {
108            self.arena.set_dormant(id);
109        }
110        for id in to_activate {
111            self.arena.activate(id);
112        }
113        // Fire activation_signal observers (e.g. a WebView's set_visible
114        // bridge) after the whole visibility pass has committed — not from
115        // inside the set_dormant/activate recursion above.
116        self.flush_activation_signals();
117
118        // Reclaim binding groups nothing points at any more. Deliberately
119        // last: `unregister_for_widget` leaves emptied groups in place so
120        // that a rebuild — which is unregister-then-re-register — keeps
121        // the group's `last_seen` ledger and cannot swallow a write its
122        // own `build()` made before re-binding. By here every rebuild in
123        // this pass has re-registered, so anything still empty belongs to
124        // a widget that is genuinely gone.
125        self.binding_registry.reclaim_empty_groups();
126    }
127
128    /// Dismiss any active overlay whose content widget is no longer
129    /// alive in the arena. An overlay's owner can be torn down
130    /// out-of-band: a data-driven rebuild destroys the widget that
131    /// showed it (clicking "mark all read" inside a notification popover
132    /// rebuilds the bell that owns the overlay; closing a document tears
133    /// down a still-open inline popover). The content then disappears
134    /// visually, but the overlay ENTRY survives in the manager and keeps
135    /// intercepting clicks (the click-outside scrim) until the user
136    /// clicks elsewhere. This GC removes such orphans immediately (no
137    /// fade — the content is already gone). A normally-open overlay's
138    /// content stays active (gated `true`), so it is never touched.
139    pub(super) fn gc_orphaned_overlays(&mut self) {
140        let orphaned: Vec<crate::overlay::OverlayId> = self
141            .overlay_manager
142            .active_ids()
143            .into_iter()
144            .filter(|&id| {
145                self.overlay_manager
146                    .overlay(id)
147                    .map(|o| !self.arena.is_active(o.content_id))
148                    .unwrap_or(false)
149            })
150            .collect();
151        for id in orphaned {
152            self.overlay_manager.dismiss_immediate(id);
153        }
154    }
155
156    /// Drain any widgets flagged `needs_rebuild` that are currently
157    /// active + have built children. Called from
158    /// `process_state_changes` after dirty bindings have been
159    /// flushed, and again after overlay / tooltip activation so that
160    /// widgets transitioning from dormant → active in the same
161    /// layout pass get rebuilt *this* frame rather than the next.
162    pub(super) fn process_pending_rebuilds(&mut self, ops: &mut dyn crate::window::WindowOps) {
163        // Defer *selected* rebuilds while a pointer capture is held:
164        // from `PointerDown` (which stores the press position in the
165        // captured widget's arena) until `PointerUp`. Rebuilding the
166        // captured widget, or any of its ancestors, would destroy that
167        // arena and lose the press state — the recognizer would never
168        // fire.
169        //
170        // The window really does last the whole gesture, NOT just up to
171        // `DragStarted`: a gesture drag auto-captures on `DragStarted`
172        // (`gesture_dispatch_impl`) and holds until `DragEnded`, and the
173        // only thing that lifts this filter is `active_drag`, which is
174        // the drag-and-DROP session set by `start_drag` — never a
175        // scrollbar thumb. So a widget holding a live gesture must not
176        // be a descendant of anything that rebuilds on data or scroll
177        // changes, or that rebuild is silently dropped until release.
178        //
179        // Rebuilds targeting widgets *outside* the captured widget's
180        // ancestor chain are safe: destroying sibling subtrees leaves
181        // the captured widget intact, so ongoing drags keep routing
182        // correctly. That is exactly why all five virtualized views
183        // (`ListView`, `TreeView`, `TableView`, `TreeTableView`,
184        // `GridView`) hoist their rows into a body pane that is a
185        // *sibling* of their scrollbar rather than realizing rows on
186        // the view root — see `common::thumb_drag_test` in
187        // `teksilo-widgets`, which asserts it for each of them.
188        //
189        // Once `active_drag` is set, the framework routes PointerMove /
190        // PointerUp via `handle_drag_move` / `handle_drag_drop` keyed
191        // on the `DragSession`, not on the captured widget's arena —
192        // so a mid-drag rebuild is safe regardless of topology. Post-
193        // rebuild, `revalidate_interaction_state` clears a now-stale
194        // `pointer_captured_by`; subsequent events hit-test normally.
195        let to_rebuild_all = self.arena.collect_needs_rebuild();
196        if to_rebuild_all.is_empty() {
197            self.revalidate_interaction_state(&mut *ops);
198            return;
199        }
200        let captured_ancestors: Option<Vec<WidgetId>> = if self.active_drag.is_none() {
201            self.pointer_captured_by.map(|cap| {
202                let mut ids = vec![cap];
203                let mut cur = self.arena.parent(cap);
204                while let Some(id) = cur {
205                    ids.push(id);
206                    cur = self.arena.parent(id);
207                }
208                ids
209            })
210        } else {
211            None
212        };
213        let to_rebuild: Vec<WidgetId> = match &captured_ancestors {
214            Some(chain) => to_rebuild_all
215                .into_iter()
216                .filter(|id| !chain.contains(id))
217                .collect(),
218            None => to_rebuild_all,
219        };
220        if to_rebuild.is_empty() {
221            self.revalidate_interaction_state(&mut *ops);
222            return;
223        }
224        // Does focus live inside a subtree we are about to rebuild? Its children
225        // are about to be destroyed and re-allocated with fresh ids, taking the
226        // focused node with them — and once that has happened there is no way
227        // back from the dead id to the subtree it belonged to. Work it out now.
228        let focus_owner: Option<WidgetId> = self.focused.and_then(|focused| {
229            let depth = |id: WidgetId| -> usize {
230                let mut d = 0;
231                let mut cur = id;
232                while let Some(parent) = self.arena.parent(cur) {
233                    d += 1;
234                    cur = parent;
235                }
236                d
237            };
238            // Every root containing `focused` sits on its ancestor chain, so the
239            // candidates are totally ordered by depth. Take the OUTERMOST: it is
240            // the only one sure to survive, since a rebuild destroys its children
241            // — an inner rebuild root nested inside an outer one is torn down by
242            // the outer's rebuild, and its id would be dead by restore time.
243            to_rebuild
244                .iter()
245                .copied()
246                .filter(|&root| self.is_descendant_of(focused, root))
247                .min_by_key(|&root| depth(root))
248        });
249
250        for widget_id in to_rebuild {
251            self.rebuild_single_widget(widget_id);
252        }
253        // A rebuild destroys old child subtrees and allocates fresh
254        // WidgetIds, so the AccessKit tree shape changed — dirty the cached
255        // snapshot so the next `sync_accessibility` re-walks. This is the one
256        // place every `BindingLevel::Rebuild` consumer converges (data-view
257        // model updates via the binding registry AND `with_widget_mut(Rebuild)`
258        // via `apply_tree_mutations`, both draining the same `needs_rebuild`
259        // arena flag) — without this, an ordinary `ListModel::push()` leaves
260        // screen readers on a stale tree indefinitely.
261        self.a11y_dirty = true;
262        // Rebuild destroys old child subtrees and allocates fresh WidgetIds;
263        // drop any focus/hover state whose target is no longer valid so we
264        // don't dispatch to dead widgets on the next event.
265        self.revalidate_interaction_state(&mut *ops);
266        // ...but "no longer valid" must not mean "gone". If focus lived in the
267        // subtree we just rebuilt, the drop above kicked the user clean out of
268        // the widget they were in: a popover that re-scans its content when it
269        // opens throws away the row the popover itself had just focused, and the
270        // menu comes up with nothing focused — no arrow keys, no Enter. Put focus
271        // back inside that subtree, at the end of the layout pass (the fresh
272        // children have no bounds yet, and the focus-driven scroll-into-view
273        // needs them). A rebuild that never held focus, or one whose focused node
274        // survived it (the rebuild root itself is not destroyed), records nothing.
275        if self.focused.is_none()
276            && let Some(root) = focus_owner
277        {
278            self.pending_focus_restore = Some(root);
279        }
280        // A rebuild's `build()` may arm new animations (looping or
281        // one-shot) by calling `signal.animate_to(...)` /
282        // `animate_looping(...)` — these set `pending` on the signal
283        // but don't enter the scheduler until `process_pending_animations`
284        // runs again. The early-frame `process_pending_animations`
285        // (`layout_impl::layout_with_ops`) already ran *before* this
286        // rebuild, so without this second drain the animation would
287        // wait for the next frame; if the rebuild also cancelled
288        // existing scheduler entries (`cancel_by_widget` is called by
289        // `rebuild_single_widget`), the scheduler ends up empty, no
290        // frame deadline is set, and the freshly-armed animation
291        // *never* gets picked up — the user sees animations freeze
292        // after any state-driven rebuild that re-arms them
293        // (e.g. SceneView's drag-end rebuild re-arming PulsingDot
294        // loopers via `register_bindings`).
295        self.process_pending_animations();
296    }
297
298    /// Run the layout pass with the given size proposal, using
299    /// [`NoopWindowOps`](crate::window::NoopWindowOps). Handlers
300    /// triggered from drag_tick / tooltip activation / etc cannot
301    /// call `ctx.open_window(...)` from this path.
302    ///
303    /// `teksilo-app` calls [`layout_with_ops`](Self::layout_with_ops)
304    /// with a real sink so those handlers can open windows.
305    pub fn layout(&mut self, proposal: SizeProposal) {
306        let mut noop = crate::window::NoopWindowOps;
307        self.layout_with_ops(proposal, &mut noop);
308    }
309
310    /// Measure the intrinsic size of the primary (non-overlay) content root(s)
311    /// at `proposal` — e.g. `{ width: Some(w), height: None }` for the natural
312    /// height at a fixed width. Mirrors the overlay intrinsic pass below: it
313    /// calls the root's `layout_response` *directly* — NOT the
314    /// activation-ignoring `WidgetArena::measure_intrinsic` — so a
315    /// `visible_when(false)` / parked-`Switcher` descendant is excluded exactly
316    /// as the real layout excludes it. A size-to-content window is therefore
317    /// sized to what is actually shown. Computes sizes only (never writes
318    /// bounds), so it is safe to call right after a layout pass.
319    ///
320    /// Drives size-to-content windows (see
321    /// [`WindowConfig::size_to_content`](crate::window::WindowConfig::size_to_content)):
322    /// the native-window path has no in-tree overlay to size to content, so
323    /// `teksilo-app` measures the root here and resizes the OS window to fit.
324    /// Returns `None` if there is no active primary root; with more than one
325    /// active primary root the per-axis maximum is returned (size-to-content is
326    /// intended for single-primary-root windows).
327    pub fn measure_root_intrinsic(&self, proposal: SizeProposal) -> Option<teksilo_canvas::Size> {
328        let overlay_content_ids = self.overlay_manager.active_content_ids();
329        let base_theme = self.effective_theme.clone();
330        let mut result: Option<teksilo_canvas::Size> = None;
331        for root_id in self.arena.roots() {
332            if overlay_content_ids.contains(&root_id) || !self.arena.is_active(root_id) {
333                continue;
334            }
335            let resolved_theme = self.arena.resolve_theme(root_id, &base_theme);
336            let extras = crate::widget::LayoutExtras {
337                focused: self.focused,
338                shortcut_registry: Some(&self.shortcut_registry),
339                overlay_manager: Some(&self.overlay_manager),
340            };
341            let ctx = LayoutContext {
342                theme: &resolved_theme,
343                layout_direction: self.layout_direction,
344                scale_factor: self.device_scale_factor,
345                text_scale: self.effective_text_scale,
346                text_backend: self.text_backend.as_ref(),
347                arena: Some(&self.arena),
348                extras: Some(extras),
349                stack_main_axis: None,
350            };
351            let Some(node) = self.arena.get(root_id) else {
352                continue;
353            };
354            // Direct `layout_response` (activation-respecting), like the overlay
355            // pass — dormant descendants fall out via `child_size` returning
356            // `None`, so we measure only what is actually shown.
357            let size = node.widget.layout_response(proposal, &ctx).size;
358            result = Some(match result {
359                Some(acc) => teksilo_canvas::Size::new(
360                    acc.width.max(size.width),
361                    acc.height.max(size.height),
362                ),
363                None => size,
364            });
365        }
366        result
367    }
368
369    /// Run the layout pass with the given size proposal, threading
370    /// the app's [`WindowOps`](crate::window::WindowOps) sink
371    /// through to drag_tick / tooltip / delayed-overlay handlers.
372    pub fn layout_with_ops(
373        &mut self,
374        proposal: SizeProposal,
375        ops: &mut dyn crate::window::WindowOps,
376    ) {
377        self.process_pending_animations();
378
379        let now = std::time::Instant::now();
380        // Deadline-driven wake-up: if a widget requested a future
381        // frame via `wake_at_handle()` and that deadline is now past,
382        // arm the frame tick so its effect runs on this layout pass.
383        // Used by the rich text editor's caret blink to avoid
384        // keeping winit in Poll mode.
385        if let Some(deadline) = self.pending_wake_at.get()
386            && deadline <= now
387        {
388            self.pending_wake_at.set(None);
389            self.frame_tick_requested.set(true);
390        }
391        self.advance_frame_tick(now);
392        self.animation_scheduler
393            .tick(now, &self.arena, self.paint_epoch);
394
395        // Fire on_drag_tick on the current drop target, if any. Runs once
396        // per layout pass so widgets can implement per-frame behaviours
397        // (viewport-edge auto-scroll, spring-loaded folders) without
398        // depending on pointer events — crucial when the user holds the
399        // cursor still at the edge or over a collapsed branch.
400        self.process_drag_tick(&mut *ops);
401
402        self.process_state_changes(&mut *ops);
403        // A drag owns the pointer. `handle_pointer_move` is short-circuited for
404        // the duration, so a dwell armed just before the drag started would sit
405        // frozen at its hover origin and then mature here — popping a tooltip
406        // over the drag. Keep the timers cleared instead of letting them ripen.
407        if self.active_drag.is_some() {
408            self.tooltip_cancel_pending_dwell();
409        }
410        self.process_tooltips_real();
411        self.process_delayed_overlays_real(&mut *ops);
412        self.process_pointer_leave_overlays_real(&mut *ops);
413        self.process_auto_dismiss_overlays_real(&mut *ops);
414        self.process_overlay_fade_dismissals_real(&mut *ops);
415        // The show paths above may arm a fade animation via
416        // `attach_overlay_fade` (plain tooltips, delayed overlays).
417        // That sets `pending` on the opacity signal but does NOT
418        // register the animation with the scheduler — registration
419        // happens via `process_pending_animations`, which already ran
420        // earlier in this layout pass. Without a second drain here,
421        // the fade only enters the scheduler on the *next* layout
422        // pass, and for surfaces with no further wake source (plain
423        // tooltips, no dwell timer) `next_deadline` returns `None`
424        // and the event loop sleeps with the fade stuck at opacity 0
425        // — the tooltip is "shown" but invisible until an unrelated
426        // input event forces another layout pass.
427        self.process_pending_animations();
428        // Overlay / tooltip activation may have flipped widgets from
429        // dormant → active; if any of those had `needs_rebuild`
430        // pending (e.g. a shortcut rebind happened while the tooltip
431        // was hidden), drain them now so the freshly-visible surface
432        // shows fresh content in the *same* layout pass rather than
433        // waiting for another paint-triggering event.
434        self.process_pending_rebuilds(&mut *ops);
435
436        // Now that any data-driven rebuilds have torn down their old
437        // subtrees, drop any overlay whose content was destroyed out-of-
438        // band (e.g. clicking "mark all read" inside a notification
439        // popover rebuilds the bell that owns it). Without this the
440        // overlay lingers as an invisible click-blocker. Runs before the
441        // early-return so it takes effect even on otherwise-idle passes.
442        self.gc_orphaned_overlays();
443
444        self.arena.refresh_roots();
445
446        let proposal_changed = self.last_proposal != proposal;
447        self.last_proposal = proposal;
448
449        if !proposal_changed && !self.arena.any_needs_layout() {
450            return;
451        }
452
453        // Per-pass layout memoization: a widget's `layout_response` is a pure
454        // function of (state, proposal) within a pass, so memoizing across the
455        // main-then-cross queries that height-for-width negotiation issues keeps
456        // the pass O(n). Cleared here — once, dominating both the main-tree and
457        // overlay root recursions below — because geometry may change between
458        // passes. See `WidgetArena::cached_layout_response`.
459        self.arena.clear_layout_cache();
460
461        // `effective_theme` carries the user/OS text-scale multiplier baked into
462        // its typography, so every text widget measures at the scaled size.
463        let base_theme = self.effective_theme.clone();
464
465        let overlay_content_ids = self.overlay_manager.active_content_ids();
466        let roots: Vec<WidgetId> = self.arena.roots();
467        let focused = self.focused;
468        for root_id in roots {
469            if overlay_content_ids.contains(&root_id) {
470                continue;
471            }
472            let extras = crate::widget::LayoutExtras {
473                focused,
474                shortcut_registry: Some(&self.shortcut_registry),
475                overlay_manager: Some(&self.overlay_manager),
476            };
477            layout_widget_recursive(
478                &mut self.arena,
479                root_id,
480                Rect::from_origin_size(Point::ZERO, proposal.resolve(0.0, 0.0)),
481                proposal,
482                &base_theme,
483                self.layout_direction,
484                self.device_scale_factor,
485                self.effective_text_scale,
486                self.text_backend.as_ref(),
487                Some(extras),
488            );
489        }
490
491        let anchor_bounds = |id: WidgetId| -> Option<Rect> {
492            self.arena.is_active(id).then(|| self.arena.bounds(id))
493        };
494        let viewport = (
495            proposal.width.unwrap_or(800.0),
496            proposal.height.unwrap_or(600.0),
497        );
498        self.overlay_manager
499            .position_overlays(anchor_bounds, viewport, self.layout_direction);
500        for content_id in &overlay_content_ids {
501            if !self.arena.is_active(*content_id) {
502                continue;
503            }
504            let overlay_id = self.overlay_manager.find_by_content(*content_id);
505            let intrinsic = {
506                let resolved_theme = self.arena.resolve_theme(*content_id, &base_theme);
507                let extras = crate::widget::LayoutExtras {
508                    focused: self.focused,
509                    shortcut_registry: Some(&self.shortcut_registry),
510                    overlay_manager: Some(&self.overlay_manager),
511                };
512                let ctx = LayoutContext {
513                    theme: &resolved_theme,
514                    layout_direction: self.layout_direction,
515                    scale_factor: self.device_scale_factor,
516                    text_scale: self.effective_text_scale,
517                    text_backend: self.text_backend.as_ref(),
518                    arena: Some(&self.arena),
519                    extras: Some(extras),
520                    stack_main_axis: None,
521                };
522                let node = self
523                    .arena
524                    .get(*content_id)
525                    .expect("content_id from active arena children");
526                node.widget
527                    .layout_response(
528                        SizeProposal {
529                            width: None,
530                            height: None,
531                        },
532                        &ctx,
533                    )
534                    .size
535            };
536            if let Some(overlay_id) = overlay_id {
537                self.overlay_manager
538                    .set_content_bounds(overlay_id, intrinsic);
539                let anchor_bounds = |id: WidgetId| -> Option<Rect> {
540                    self.arena.is_active(id).then(|| self.arena.bounds(id))
541                };
542                self.overlay_manager.position_overlays(
543                    anchor_bounds,
544                    viewport,
545                    self.layout_direction,
546                );
547            }
548            let overlay_bounds = overlay_id
549                .and_then(|overlay_id| {
550                    self.overlay_manager
551                        .stack
552                        .iter()
553                        .find(|overlay| overlay.id == overlay_id)
554                        .map(|overlay| overlay.bounds)
555                })
556                .unwrap_or(Rect::ZERO);
557            // Use the positioned overlay_bounds for layout, not the intrinsic
558            // size. For `BelowPreferred` (and any future placement that
559            // inflates the overlay rect beyond the content's intrinsic size
560            // to match an anchor, e.g. a combo-box dropdown that must be at
561            // least as wide as its trigger), this lets the content widget
562            // actually fill the overlay rather than sitting as a narrow
563            // strip inside it. All other placements return
564            // overlay_bounds.size() == intrinsic, so this is a no-op there.
565            let content_proposal = SizeProposal::exact(overlay_bounds.width, overlay_bounds.height);
566            let extras = crate::widget::LayoutExtras {
567                focused: self.focused,
568                shortcut_registry: Some(&self.shortcut_registry),
569                overlay_manager: Some(&self.overlay_manager),
570            };
571            layout_widget_recursive(
572                &mut self.arena,
573                *content_id,
574                overlay_bounds,
575                content_proposal,
576                &base_theme,
577                self.layout_direction,
578                self.device_scale_factor,
579                self.effective_text_scale,
580                self.text_backend.as_ref(),
581                Some(extras),
582            );
583        }
584
585        // Clear `needs_layout` for every active widget — layout just
586        // ran. `needs_rebuild` is NOT cleared here: `rebuild_single_widget`
587        // clears it for widgets it processes, and widgets whose rebuild
588        // was deferred (captured-pointer window) must keep the flag set
589        // so the next layout pass picks them up. Wiping it here caused
590        // a regression where a scroll-driven ListView rebuild, deferred
591        // during a scrollbar thumb drag, was silently dropped — the
592        // user saw the thumb move but the list view stayed frozen.
593        // Clear `needs_layout` on every active node. Mutation during
594        // iter — pull the snapshot via the reusable scratch.
595        self.arena.fill_active_ids(&mut self.active_ids_scratch);
596        let ids = std::mem::take(&mut self.active_ids_scratch);
597        for &id in &ids {
598            if let Some(node) = self.arena.get_mut(id) {
599                node.dirty.needs_layout = false;
600            }
601        }
602        self.active_ids_scratch = ids;
603
604        // Post-layout hover refresh. When a rebuild destroyed the
605        // hovered widget, `revalidate_interaction_state` cleared
606        // `hovered` to `None`. Now that widgets have fresh bounds
607        // from this layout pass, re-hit-test at the cached pointer
608        // position so the next wheel/pointer event routes to the
609        // widget the cursor is actually over. Without this, a
610        // virtualized list that materializes new rows under a
611        // stationary cursor would see the next `Scroll` fall through
612        // to `focused` and bubble to an ancestor scrollable.
613        if self.hovered.is_none()
614            && let Some(pos) = self.last_pointer_position
615        {
616            let new_target = self.hit_test(pos);
617            if new_target.is_some() {
618                if let Some(new) = new_target {
619                    self.dispatch_to_widget(new, &WidgetEvent::PointerEnter, &mut *ops);
620                    // Seed the tooltip dwell too, exactly as `handle_pointer_move`
621                    // pairs these two. The rebuild replaced the anchor's tooltip
622                    // entry with a fresh one whose `hover_start` is `None`, and
623                    // the pointer is not going to move again — so without this the
624                    // widget's tooltip is unreachable for the rest of the hover.
625                    self.tooltip_pointer_enter(new);
626                }
627                self.set_hovered(new_target);
628            }
629        }
630
631        // Post-layout focus refresh — the symmetric case to the hover refresh
632        // above. A rebuild destroyed the focused widget, so
633        // `revalidate_interaction_state` cleared `focused` to `None`; the
634        // subtree that owned it was recorded as `pending_focus_restore`. Now
635        // that its fresh children have bounds from this layout pass, land focus
636        // back inside it, so a rebuild keeps focus in the subtree that had it
637        // rather than dumping it out of the widget entirely.
638        //
639        // Deliberately conservative: only when nothing else has taken focus in
640        // the meantime, only into a subtree that is still active (a rebuild that
641        // also went dormant, e.g. a popover closing, must NOT drag focus back
642        // into hidden content — its own dismiss path restores focus to the
643        // trigger), and only if it still has somewhere to put it. Otherwise focus
644        // stays `None`, exactly as before.
645        if let Some(root) = self.pending_focus_restore.take()
646            && self.focused.is_none()
647            && self.arena.is_active(root)
648            && let Some(target) = self.first_focusable_descendant(root)
649        {
650            self.focus_ops(target, &mut *ops);
651        }
652
653        // A widget that changed size may have re-wrapped its text, and a
654        // wrapped label carries one accessibility text run per visual line
655        // — a different set of runs, not the same set somewhere else. Pure
656        // translations stay recorded on the arena for `sync_accessibility`
657        // to absorb without walking.
658        if self.arena.take_a11y_resized() {
659            self.a11y_dirty = true;
660        }
661    }
662}
663
664/// Recursive layout pass operating on the arena directly (avoids borrow conflicts).
665#[allow(clippy::too_many_arguments)]
666fn layout_widget_recursive(
667    arena: &mut WidgetArena,
668    id: WidgetId,
669    parent_bounds: Rect,
670    proposal: SizeProposal,
671    base_theme: &crate::styles::Theme,
672    layout_direction: crate::environment::LayoutDirection,
673    scale_factor: f32,
674    text_scale: f32,
675    text_backend: Option<&std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>>,
676    extras: Option<crate::widget::LayoutExtras<'_>>,
677) {
678    if !arena.is_active(id) {
679        return;
680    }
681
682    let resolved_theme = arena.resolve_theme(id, base_theme);
683
684    let desired_size = {
685        let ctx = LayoutContext {
686            theme: &resolved_theme,
687            layout_direction,
688            scale_factor,
689            text_scale,
690            text_backend,
691            arena: Some(arena),
692            extras,
693            stack_main_axis: None,
694        };
695        arena
696            .cached_layout_response(id, proposal, &ctx)
697            .map(|r| r.size)
698            .unwrap_or(teksilo_canvas::Size::ZERO)
699    };
700
701    let bounds = Rect::new(
702        parent_bounds.x,
703        parent_bounds.y,
704        proposal.width.unwrap_or(desired_size.width),
705        proposal.height.unwrap_or(desired_size.height),
706    );
707    let previous = arena.get_mut(id).and_then(|node| {
708        let previous = node.bounds;
709        (previous != bounds).then(|| {
710            node.cached_paint = None;
711            node.dirty.needs_paint = true;
712            node.bounds = bounds;
713            previous
714        })
715    });
716    if let Some(previous) = previous {
717        arena.note_bounds_change(id, previous, bounds);
718    }
719
720    let child_ids: Vec<WidgetId> = arena.children(id).to_vec();
721    let active_child_ids: Vec<WidgetId> = child_ids
722        .iter()
723        .copied()
724        .filter(|&child_id| arena.is_active(child_id))
725        .collect();
726
727    let mut placements: Vec<WidgetPlacement> = active_child_ids
728        .iter()
729        .map(|&child_id| WidgetPlacement {
730            id: child_id,
731            origin: bounds.origin(),
732            size: bounds.size(),
733        })
734        .collect();
735
736    // `place_children` is a widget's ONLY hook that receives its final,
737    // parent-assigned `bounds`, so it runs for EVERY active widget on every
738    // pass — including leaves, which get an empty `placements` slice. A widget
739    // whose paint depends on where the parent put it (a scene folding its
740    // origin into a view transform, a text engine sizing its viewport) can then
741    // read its bounds during *layout*, which is the only point early enough:
742    // the render walker pushes node-level transform scopes before `paint` runs.
743    {
744        let ctx = LayoutContext {
745            theme: &resolved_theme,
746            layout_direction,
747            scale_factor,
748            text_scale,
749            text_backend,
750            arena: Some(arena),
751            extras,
752            stack_main_axis: None,
753        };
754        let node = arena.get(id).expect("widget id is active in arena");
755        node.widget
756            .place_children(bounds, proposal, &mut placements, &ctx);
757    }
758
759    for placement in &placements {
760        let child_bounds = Rect::from_origin_size(placement.origin, placement.size);
761        let previous = arena.get_mut(placement.id).and_then(|child_node| {
762            let previous = child_node.bounds;
763            (previous != child_bounds).then(|| {
764                child_node.cached_paint = None;
765                child_node.dirty.needs_paint = true;
766                child_node.bounds = child_bounds;
767                previous
768            })
769        });
770        if let Some(previous) = previous {
771            arena.note_bounds_change(placement.id, previous, child_bounds);
772        }
773
774        let child_proposal = SizeProposal::exact(placement.size.width, placement.size.height);
775        let grandchild_ids: Vec<WidgetId> = arena.children(placement.id).to_vec();
776        if !grandchild_ids.is_empty() {
777            layout_widget_recursive(
778                arena,
779                placement.id,
780                child_bounds,
781                child_proposal,
782                base_theme,
783                layout_direction,
784                scale_factor,
785                text_scale,
786                text_backend,
787                extras,
788            );
789        } else {
790            // A childless child is never visited by the recursion above, so
791            // hand it its final bounds here — with an empty `placements` slice.
792            //
793            // Deliberately NOT a `layout_widget_recursive` call: that would
794            // re-measure the leaf against a fresh `exact` proposal (a memo miss,
795            // since the parent measured it under a different proposal), adding a
796            // redundant `layout_response` per leaf on every pass.
797            let ctx = LayoutContext {
798                theme: &resolved_theme,
799                layout_direction,
800                scale_factor,
801                text_scale,
802                text_backend,
803                arena: Some(arena),
804                extras,
805                stack_main_axis: None,
806            };
807            let node = arena.get(placement.id).expect("child id is active");
808            node.widget
809                .place_children(child_bounds, child_proposal, &mut [], &ctx);
810        }
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::test_widgets::{FillWidget, InsetWidget, StackWidget};
818    use teksilo_canvas::Size;
819    use teksilo_tokens::Color;
820
821    /// A leaf that records the bounds `place_children` hands it, and how often.
822    #[derive(Debug, Clone, Default)]
823    struct BoundsRecorder {
824        seen: std::rc::Rc<std::cell::RefCell<Vec<Rect>>>,
825    }
826
827    impl Widget for BoundsRecorder {
828        fn layout_response(
829            &self,
830            proposal: SizeProposal,
831            _ctx: &LayoutContext,
832        ) -> crate::widget::LayoutResponse {
833            Size::new(
834                proposal.width.unwrap_or(10.0),
835                proposal.height.unwrap_or(10.0),
836            )
837            .into()
838        }
839
840        fn place_children(
841            &self,
842            bounds: Rect,
843            _proposal: SizeProposal,
844            children: &mut [WidgetPlacement],
845            _ctx: &LayoutContext,
846        ) {
847            assert!(
848                children.is_empty(),
849                "a leaf must be handed an empty placements slice"
850            );
851            self.seen.borrow_mut().push(bounds);
852        }
853    }
854
855    /// The invariant `SceneView` (and both text engines) depend on: a widget with
856    /// NO children still gets `place_children`, carrying its final bounds.
857    ///
858    /// Before this was guaranteed, the walker skipped `place_children` whenever
859    /// there was nothing to place, so a leaf could only discover its bounds in
860    /// `paint`. That is too late for anything the renderer consumes *before*
861    /// paint — a `SceneView` folds `bounds.origin` into the transform scope the
862    /// walker pushes around its subtree, so a scene holding only lightweight
863    /// items (hence no arena children) painted its content offset by
864    /// `-bounds.origin`, an error that scaled with zoom.
865    #[test]
866    fn a_childless_widget_still_receives_its_bounds() {
867        let mut tree = WidgetTree::new();
868        let leaf = BoundsRecorder::default();
869        let seen = leaf.seen.clone();
870
871        // Nested inside an inset container, so a correct origin is non-zero and a
872        // stale/zero origin cannot pass by accident.
873        let leaf_id = tree.add(leaf);
874        let _root = tree.add(InsetWidget::new(12.0).set_child(leaf_id));
875        tree.layout(SizeProposal::exact(200.0, 100.0));
876
877        let bounds = seen.borrow();
878        assert_eq!(
879            bounds.len(),
880            1,
881            "the leaf must be placed exactly once per layout pass, got {bounds:?}"
882        );
883        assert_eq!(
884            (bounds[0].x, bounds[0].y),
885            (12.0, 12.0),
886            "the leaf must receive its real, parent-assigned origin"
887        );
888        assert_eq!(
889            (bounds[0].width, bounds[0].height),
890            (176.0, 76.0),
891            "the leaf must receive its real, parent-assigned size"
892        );
893    }
894
895    /// The same guarantee at the root: a tree whose root IS a leaf.
896    #[test]
897    fn a_childless_root_still_receives_its_bounds() {
898        let mut tree = WidgetTree::new();
899        let leaf = BoundsRecorder::default();
900        let seen = leaf.seen.clone();
901        let _id = tree.add(leaf);
902        tree.layout(SizeProposal::exact(320.0, 240.0));
903
904        let bounds = seen.borrow();
905        assert_eq!(bounds.len(), 1, "root leaf must be placed once");
906        assert_eq!((bounds[0].width, bounds[0].height), (320.0, 240.0));
907    }
908
909    #[derive(Debug)]
910    struct ShrinkWrapContainer {
911        child: WidgetId,
912        inset: f32,
913    }
914
915    impl Widget for ShrinkWrapContainer {
916        fn layout_response(
917            &self,
918            _proposal: SizeProposal,
919            ctx: &LayoutContext,
920        ) -> crate::widget::LayoutResponse {
921            let child_size = ctx
922                .child_size(self.child, SizeProposal::unspecified())
923                .unwrap_or(Size::ZERO);
924            Size::new(
925                child_size.width + self.inset * 2.0,
926                child_size.height + self.inset * 2.0,
927            )
928            .into()
929        }
930
931        fn place_children(
932            &self,
933            bounds: Rect,
934            _proposal: SizeProposal,
935            children: &mut [WidgetPlacement],
936            _ctx: &LayoutContext,
937        ) {
938            for child in children.iter_mut() {
939                child.origin = Point::new(bounds.x + self.inset, bounds.y + self.inset);
940                child.size = Size::new(
941                    (bounds.width - self.inset * 2.0).max(0.0),
942                    (bounds.height - self.inset * 2.0).max(0.0),
943                );
944            }
945        }
946
947        fn children(&self) -> Vec<WidgetId> {
948            vec![self.child]
949        }
950    }
951
952    // ── Per-pass layout memoization cache (Part C) ──────────────────────────
953
954    /// A childless leaf that counts how many times `layout_response` runs and
955    /// can opt out of caching. The driver does not recurse into a childless
956    /// leaf's placement, so the only calls come from a parent's `child_size`
957    /// queries — making the count a precise probe of the cache.
958    #[derive(Debug)]
959    struct CountingLeaf {
960        calls: std::rc::Rc<std::cell::Cell<u32>>,
961        cacheable: bool,
962    }
963
964    impl Widget for CountingLeaf {
965        fn layout_response(
966            &self,
967            _proposal: SizeProposal,
968            _ctx: &LayoutContext,
969        ) -> crate::widget::LayoutResponse {
970            self.calls.set(self.calls.get() + 1);
971            Size::new(50.0, 20.0).into()
972        }
973        fn cacheable_layout(&self) -> bool {
974            self.cacheable
975        }
976    }
977
978    /// Queries its single child with the *same* proposal in both
979    /// `layout_response` and `place_children` — the pattern real stacks use
980    /// for height-for-width. With caching the child computes once; without it,
981    /// twice.
982    #[derive(Debug)]
983    struct DoubleQueryContainer {
984        child: WidgetId,
985    }
986
987    impl Widget for DoubleQueryContainer {
988        fn layout_response(
989            &self,
990            _proposal: SizeProposal,
991            ctx: &LayoutContext,
992        ) -> crate::widget::LayoutResponse {
993            ctx.child_size(self.child, SizeProposal::exact(50.0, 20.0))
994                .unwrap_or(Size::ZERO)
995                .into()
996        }
997        fn place_children(
998            &self,
999            bounds: Rect,
1000            _proposal: SizeProposal,
1001            children: &mut [WidgetPlacement],
1002            ctx: &LayoutContext,
1003        ) {
1004            // Second query with the identical proposal.
1005            let _ = ctx.child_size(self.child, SizeProposal::exact(50.0, 20.0));
1006            for child in children.iter_mut() {
1007                child.origin = bounds.origin();
1008                child.size = bounds.size();
1009            }
1010        }
1011        fn children(&self) -> Vec<WidgetId> {
1012            vec![self.child]
1013        }
1014    }
1015
1016    #[test]
1017    fn cache_dedupes_identical_child_queries_within_a_pass() {
1018        let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1019        let mut tree = WidgetTree::new();
1020        let leaf = tree.add(CountingLeaf {
1021            calls: calls.clone(),
1022            cacheable: true,
1023        });
1024        let _root = tree.add(DoubleQueryContainer { child: leaf });
1025        tree.layout(SizeProposal::exact(100.0, 50.0));
1026        // Two identical `exact(50,20)` queries (layout_response + place_children)
1027        // collapse to one real call; the driver does not recurse into the
1028        // childless leaf.
1029        assert_eq!(calls.get(), 1, "cacheable leaf should be computed once");
1030    }
1031
1032    #[test]
1033    fn cache_opt_out_recomputes_every_query() {
1034        let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1035        let mut tree = WidgetTree::new();
1036        let leaf = tree.add(CountingLeaf {
1037            calls: calls.clone(),
1038            cacheable: false,
1039        });
1040        let _root = tree.add(DoubleQueryContainer { child: leaf });
1041        tree.layout(SizeProposal::exact(100.0, 50.0));
1042        assert_eq!(
1043            calls.get(),
1044            2,
1045            "opt-out leaf must run on every query (side effects preserved)"
1046        );
1047    }
1048
1049    #[test]
1050    fn cache_is_cleared_between_passes() {
1051        let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1052        let mut tree = WidgetTree::new();
1053        let leaf = tree.add(CountingLeaf {
1054            calls: calls.clone(),
1055            cacheable: true,
1056        });
1057        let _root = tree.add(DoubleQueryContainer { child: leaf });
1058        tree.layout(SizeProposal::exact(100.0, 50.0));
1059        // A second pass with a different proposal must re-run layout — proving
1060        // the cache is per-pass, not stale across passes (the `exact(50,20)`
1061        // child key is identical between passes).
1062        tree.layout(SizeProposal::exact(120.0, 60.0));
1063        assert_eq!(
1064            calls.get(),
1065            2,
1066            "each pass recomputes; cache cleared per pass"
1067        );
1068    }
1069
1070    // ── measure_intrinsic (Primitive 2) ─────────────────────────────────────
1071
1072    /// Probe: from its own `layout_response`, measures `target` two ways and
1073    /// stashes the results — the normal (activation-gated) query and the
1074    /// intrinsic (activation-ignoring) query.
1075    #[derive(Debug)]
1076    struct MeasureProbe {
1077        target: WidgetId,
1078        active_w: std::rc::Rc<std::cell::Cell<f32>>, // -1.0 == None
1079        intrinsic_w: std::rc::Rc<std::cell::Cell<f32>>,
1080    }
1081    impl Widget for MeasureProbe {
1082        fn layout_response(
1083            &self,
1084            p: SizeProposal,
1085            ctx: &LayoutContext,
1086        ) -> crate::widget::LayoutResponse {
1087            // Measure intrinsic FIRST, then the normal gated query: if the
1088            // measure had polluted the cache, the gated query could wrongly
1089            // return a size for the dormant target. `exact` because FillWidget
1090            // fills its proposal (it has no intrinsic size of its own).
1091            let probe = SizeProposal::exact(120.0, 30.0);
1092            let intrinsic = ctx
1093                .measure_intrinsic(self.target, probe)
1094                .map(|s| s.width)
1095                .unwrap_or(-1.0);
1096            let active = ctx
1097                .child_size(self.target, probe)
1098                .map(|s| s.width)
1099                .unwrap_or(-1.0);
1100            self.intrinsic_w.set(intrinsic);
1101            self.active_w.set(active);
1102            p.resolve(0.0, 0.0).into()
1103        }
1104        fn cacheable_layout(&self) -> bool {
1105            false
1106        }
1107    }
1108
1109    #[test]
1110    fn measure_intrinsic_sees_a_dormant_widget_normal_query_does_not() {
1111        let active = std::rc::Rc::new(std::cell::Cell::new(0.0));
1112        let intrinsic = std::rc::Rc::new(std::cell::Cell::new(0.0));
1113        let mut tree = WidgetTree::new();
1114        let leaf = tree.add(FillWidget::new());
1115        tree.set_dormant(leaf);
1116        let _probe = tree.add(MeasureProbe {
1117            target: leaf,
1118            active_w: active.clone(),
1119            intrinsic_w: intrinsic.clone(),
1120        });
1121        tree.layout(SizeProposal::exact(200.0, 50.0));
1122
1123        // measure_intrinsic measures the dormant widget (FillWidget fills the
1124        // 120px probe)…
1125        assert!(
1126            (intrinsic.get() - 120.0).abs() < 0.01,
1127            "measure_intrinsic should size the dormant widget, got {}",
1128            intrinsic.get()
1129        );
1130        // …and the normal gated query (run AFTER) still returns None — proving
1131        // the measure bypassed, and did not seed, the per-pass cache.
1132        assert_eq!(
1133            active.get(),
1134            -1.0,
1135            "child_size must stay None for a dormant widget (no cache pollution)"
1136        );
1137    }
1138
1139    /// A box whose height is driven by a signal and whose width echoes the
1140    /// proposed width (height-for-width) — models a widget (e.g. a `MessageBox`
1141    /// "Show details" expander) whose intrinsic height changes with content.
1142    /// Echoing the width lets a fixed-width intrinsic measurement be exercised.
1143    #[derive(Debug)]
1144    struct SignalBox {
1145        h: crate::signal::Signal<f32>,
1146    }
1147    impl Widget for SignalBox {
1148        fn layout_response(
1149            &self,
1150            p: SizeProposal,
1151            _ctx: &LayoutContext,
1152        ) -> crate::widget::LayoutResponse {
1153            teksilo_canvas::Size::new(p.width.unwrap_or(0.0), self.h.get()).into()
1154        }
1155        fn cacheable_layout(&self) -> bool {
1156            false
1157        }
1158    }
1159
1160    /// Sums the ACTIVE children's heights via `child_size` (which returns
1161    /// `None` for a dormant child, so a hidden child contributes nothing) —
1162    /// lets a test assert size-to-content excludes dormant subtrees.
1163    #[derive(Debug)]
1164    struct VSumBox {
1165        children: Vec<WidgetId>,
1166    }
1167    impl Widget for VSumBox {
1168        fn layout_response(
1169            &self,
1170            p: SizeProposal,
1171            ctx: &LayoutContext,
1172        ) -> crate::widget::LayoutResponse {
1173            let h: f32 = self
1174                .children
1175                .iter()
1176                .filter_map(|&c| ctx.child_size(c, p))
1177                .map(|s| s.height)
1178                .sum();
1179            teksilo_canvas::Size::new(p.width.unwrap_or(0.0), h).into()
1180        }
1181        fn children(&self) -> Vec<WidgetId> {
1182            self.children.clone()
1183        }
1184    }
1185
1186    #[test]
1187    fn measure_root_intrinsic_honors_fixed_width_and_tracks_content() {
1188        let h = crate::signal::Signal::new(140.0);
1189        let mut tree = WidgetTree::new();
1190        let _root = tree.add(SignalBox { h: h.clone() });
1191        // Lay the root out constrained to a fixed native-modal size.
1192        tree.layout(SizeProposal::exact(460.0, 140.0));
1193
1194        // Intrinsic measurement at a fixed width / unbounded height reports the
1195        // proposed width and the content's natural height — the size a
1196        // size-to-content window grows to, independent of the constrained pass.
1197        let m = tree
1198            .measure_root_intrinsic(SizeProposal {
1199                width: Some(460.0),
1200                height: None,
1201            })
1202            .expect("one active primary root");
1203        assert!(
1204            (m.width - 460.0).abs() < 0.01,
1205            "fixed width honored, got {}",
1206            m.width
1207        );
1208        assert!(
1209            (m.height - 140.0).abs() < 0.01,
1210            "natural height, got {}",
1211            m.height
1212        );
1213
1214        // A different fixed width flows through (the proposal really is used).
1215        let narrow = tree
1216            .measure_root_intrinsic(SizeProposal {
1217                width: Some(300.0),
1218                height: None,
1219            })
1220            .expect("root active");
1221        assert!(
1222            (narrow.width - 300.0).abs() < 0.01,
1223            "proposal width, got {}",
1224            narrow.width
1225        );
1226
1227        // Content growth (a "Show details" expander) is reflected.
1228        h.set(300.0);
1229        let grown = tree
1230            .measure_root_intrinsic(SizeProposal {
1231                width: Some(460.0),
1232                height: None,
1233            })
1234            .expect("root active");
1235        assert!(
1236            (grown.height - 300.0).abs() < 0.01,
1237            "grows with content, got {}",
1238            grown.height
1239        );
1240    }
1241
1242    #[test]
1243    fn measure_root_intrinsic_excludes_dormant_content() {
1244        let mut tree = WidgetTree::new();
1245        let shown = tree.add(SignalBox {
1246            h: crate::signal::Signal::new(200.0),
1247        });
1248        let hidden = tree.add(SignalBox {
1249            h: crate::signal::Signal::new(1000.0),
1250        });
1251        tree.set_dormant(hidden);
1252        let _root = tree.add(VSumBox {
1253            children: vec![shown, hidden],
1254        });
1255        tree.layout(SizeProposal::exact(460.0, 200.0));
1256
1257        // The dormant child must NOT contribute — a size-to-content window is
1258        // sized to what is actually shown. Regression guard for measuring via
1259        // `layout_response` (activation-respecting) rather than the
1260        // activation-ignoring `measure_intrinsic` (which would return 1200).
1261        let m = tree
1262            .measure_root_intrinsic(SizeProposal {
1263                width: Some(460.0),
1264                height: None,
1265            })
1266            .expect("one active primary root");
1267        assert!(
1268            (m.height - 200.0).abs() < 0.01,
1269            "dormant child must be excluded, got {}",
1270            m.height
1271        );
1272    }
1273
1274    #[test]
1275    fn single_widget_fills_proposal() {
1276        let mut tree = WidgetTree::new();
1277        let widget = tree.add(FillWidget::new().background(Color::RED));
1278        tree.layout(SizeProposal::exact(200.0, 40.0));
1279        let bounds = tree.bounds(widget);
1280        assert_eq!(bounds.width, 200.0);
1281        assert_eq!(bounds.height, 40.0);
1282    }
1283
1284    #[test]
1285    fn stack_children_overlap() {
1286        let mut tree = WidgetTree::new();
1287        let a = tree.add(FillWidget::new());
1288        let b = tree.add(FillWidget::new());
1289        let stack = tree.add(StackWidget::new().add_child(a).add_child(b));
1290        tree.layout(SizeProposal::exact(100.0, 50.0));
1291        let children = tree.children(stack);
1292        assert_eq!(children.len(), 2);
1293        let a_bounds = tree.bounds(children[0]);
1294        let b_bounds = tree.bounds(children[1]);
1295        assert_eq!(a_bounds.origin(), b_bounds.origin());
1296        assert_eq!(a_bounds.size(), b_bounds.size());
1297    }
1298
1299    #[test]
1300    fn inset_widget_insets_child() {
1301        let mut tree = WidgetTree::new();
1302        let child = tree.add(FillWidget::new());
1303        let parent = tree.add(InsetWidget::new(10.0).set_child(child));
1304        tree.layout(SizeProposal::exact(100.0, 50.0));
1305        let children = tree.children(parent);
1306        let child_bounds = tree.bounds(children[0]);
1307        assert_eq!(child_bounds.x, 10.0);
1308        assert_eq!(child_bounds.y, 10.0);
1309        assert_eq!(child_bounds.width, 80.0);
1310        assert_eq!(child_bounds.height, 30.0);
1311    }
1312
1313    #[test]
1314    fn recursive_layout_preserves_exact_parent_placement_for_containers() {
1315        let mut tree = WidgetTree::new();
1316        let leaf = tree.add(FillWidget::new());
1317        let shrink = tree.add(ShrinkWrapContainer {
1318            child: leaf,
1319            inset: 8.0,
1320        });
1321        let root = tree.add(StackWidget::new().add_child(shrink));
1322
1323        tree.layout(SizeProposal::exact(120.0, 80.0));
1324
1325        assert_eq!(tree.bounds(root), Rect::new(0.0, 0.0, 120.0, 80.0));
1326        assert_eq!(
1327            tree.bounds(shrink),
1328            Rect::new(0.0, 0.0, 120.0, 80.0),
1329            "child container should keep the exact size assigned by its parent"
1330        );
1331        assert_eq!(tree.bounds(leaf), Rect::new(8.0, 8.0, 104.0, 64.0));
1332    }
1333
1334    #[test]
1335    fn needs_paint_after_layout() {
1336        let mut tree = WidgetTree::new();
1337        tree.add(FillWidget::new());
1338        assert!(tree.needs_layout());
1339        tree.layout(SizeProposal::exact(100.0, 50.0));
1340        assert!(!tree.needs_layout());
1341    }
1342
1343    #[test]
1344    fn signal_binding_marks_widget_dirty_on_layout() {
1345        use crate::signal::Signal;
1346
1347        let mut tree = WidgetTree::new();
1348        let widget = tree.add(FillWidget::new().background(Color::RED));
1349        tree.layout(SizeProposal::exact(100.0, 50.0));
1350        tree.render();
1351
1352        assert!(!tree.needs_paint());
1353
1354        let visible = Signal::new(true);
1355        visible.bind_to(
1356            widget,
1357            tree.binding_registry(),
1358            crate::binding::BindingLevel::RepaintOnly,
1359        );
1360
1361        visible.set(false);
1362        tree.layout(SizeProposal::exact(100.0, 50.0));
1363        assert!(tree.needs_paint());
1364    }
1365}