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