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        // Whether a command would be handled, for the menu rows that grey out
11        // from it, first: a row's enabled state is bound to it, and both the
12        // effective-enabled refresh and the binding flush below must see the
13        // new value in this pass.
14        self.flush_command_availability();
15
16        // Refresh the node-resident `effective_enabled_signal`s FIRST, so a
17        // widget bound to one is dirty-marked in time for the binding flush
18        // immediately below to drain it in this same pass, rather than a frame
19        // late. This is also where a signal seeded during `build()` — when the
20        // widget's parent was not yet wired, so the seed could only see its own
21        // `enabled` prop — is corrected against the now-complete tree.
22        self.flush_effective_enabled_signals();
23
24        // One unified flush: both visual buckets and the a11y flag
25        // are drained from the same walk, so a signal bound at both
26        // a visual level and `AccessibilityOnly` (e.g. a Button's
27        // `label` re-registers the same Signal at RepaintOnly
28        // *and* AccessibilityOnly) flips both. Two separate flushes
29        // would each advance this registry's last-seen generation for
30        // that source, so the second would find nothing to report.
31        let (dirty_widgets, a11y_binding_dirty) = self.binding_registry.flush_all_dirty();
32        for (id, level) in &dirty_widgets {
33            match level {
34                crate::binding::BindingLevel::RepaintOnly => {
35                    self.arena.mark_needs_paint(*id);
36                }
37                crate::binding::BindingLevel::SubtreeRepaint => {
38                    // Used by `enabled_when` so the leaves in the
39                    // disabled subtree re-resolve their role colors
40                    // via the paint walker's `effective_enabled`.
41                    // No layout work — geometry is unchanged.
42                    self.arena.mark_subtree_needs_paint(*id);
43                }
44                crate::binding::BindingLevel::Relayout => {
45                    self.arena.mark_needs_layout(*id);
46                    self.arena.mark_ancestors_need_layout(*id);
47                }
48                crate::binding::BindingLevel::Rebuild => {
49                    self.arena.mark_needs_rebuild(*id);
50                    self.arena.mark_ancestors_need_layout(*id);
51                }
52                crate::binding::BindingLevel::AccessibilityOnly => {
53                    // Drained into the boolean below — never appears in
54                    // the visual map, but kept in the match so a future
55                    // variant addition is a compile-time reminder.
56                }
57            }
58        }
59
60        // Orthogonal to the visual dirty pass: if any signal bound at
61        // `BindingLevel::AccessibilityOnly` fired, flip the tree-wide
62        // `a11y_dirty` flag so the next `sync_accessibility` rebuilds
63        // the AccessKit tree. Decoupled from layout / paint so a text
64        // edit that changes no visual geometry still reaches screen
65        // readers within one frame.
66        if a11y_binding_dirty {
67            self.a11y_dirty = true;
68        }
69
70        // Rebuild data-driven widgets whose data model changed.
71        self.process_pending_rebuilds(&mut *ops);
72
73        let mut to_dormant = Vec::new();
74        let mut to_activate = Vec::new();
75        for (id, is_active, should_be_visible) in self.arena.visibility_checks_iter() {
76            if is_active && !should_be_visible {
77                to_dormant.push(id);
78            } else if !is_active && should_be_visible {
79                // Only wake a `visible_when(true)` node whose parent is active.
80                // A gated node inside a dormant ancestor (e.g. a row in a
81                // closed popover / overflow menu) must NOT escape that
82                // ancestor's dormancy and render on its own. When the ancestor
83                // is later activated, `arena.activate` wakes this node via the
84                // cascade (its gate is true). The dormancy invariant — an
85                // active node has an active parent — makes the immediate-parent
86                // check sufficient.
87                let parent_active = self
88                    .arena
89                    .parent(id)
90                    .map(|p| self.arena.is_active(p))
91                    .unwrap_or(true);
92                if parent_active {
93                    to_activate.push(id);
94                }
95            }
96        }
97        // The accessibility walk skips dormant nodes, so any
98        // active↔dormant transition changes the AccessKit tree shape
99        // and must dirty the cached snapshot. Other Relayout-causing
100        // signal flips (a Switcher visibility binding that doesn't
101        // straddle activation, an opacity change) do not — the
102        // unconditional `a11y_dirty = true` was removed from `layout()`
103        // and is now set only by events that actually change the AT tree.
104        //
105        // A *resize* is one of them: since a label carries its text one
106        // run per visual line, re-wrapping it at a new width produces a
107        // different set of runs, not the same set somewhere else. A pure
108        // translation is absorbed by `sync_accessibility` instead, which
109        // re-places the cached nodes without walking.
110        if !to_dormant.is_empty() || !to_activate.is_empty() {
111            self.a11y_dirty = true;
112        }
113        for id in to_dormant {
114            // Through the tree-level door, so a pointer working inside a
115            // `visible_when` branch that just flipped false is told its
116            // interaction is over rather than left holding a widget the
117            // dispatcher will no longer reach.
118            self.park_subtree_with_ops(id, &mut *ops);
119        }
120        for id in to_activate {
121            self.arena.activate(id);
122        }
123        // Fire activation_signal observers (e.g. a WebView's set_visible
124        // bridge) after the whole visibility pass has committed — not from
125        // inside the set_dormant/activate recursion above.
126        self.flush_activation_signals();
127
128        // Reclaim binding groups nothing points at any more. Deliberately
129        // last: `unregister_for_widget` leaves emptied groups in place so
130        // that a rebuild — which is unregister-then-re-register — keeps
131        // the group's `last_seen` ledger and cannot swallow a write its
132        // own `build()` made before re-binding. By here every rebuild in
133        // this pass has re-registered, so anything still empty belongs to
134        // a widget that is genuinely gone.
135        self.binding_registry.reclaim_empty_groups();
136    }
137
138    /// Destroy the content of dismissed overlays that asked for it
139    /// ([`OverlayRequest::destroy_content_on_dismiss`](crate::overlay::OverlayRequest::destroy_content_on_dismiss)),
140    /// outside any dispatch, so a `Drop` that touches app state never runs
141    /// while a handler of the overlay is on the stack.
142    ///
143    /// Content that is gone already, or shown again by an overlay still up,
144    /// is skipped. So is content someone activated without an overlay: it is
145    /// theirs now, and a debug build says so.
146    fn destroy_dismissed_content(&mut self, ops: &mut dyn crate::window::WindowOps) {
147        let mut queued = self.overlay_manager.take_pending_destroy();
148        if queued.is_empty() {
149            return;
150        }
151        // `on_dismiss` may still read the content: run any callback a
152        // re-entrant dismissal left parked before it goes.
153        self.run_pending_dismiss_callbacks(&mut *ops);
154        queued.sort_unstable();
155        queued.dedup();
156        let mut destroyed = false;
157        for id in queued {
158            if self.arena.get(id).is_none() || self.overlay_manager.find_by_content(id).is_some() {
159                continue;
160            }
161            if self.arena.is_active(id) {
162                #[cfg(debug_assertions)]
163                eprintln!(
164                    "teksilo: overlay content {id:?} was activated outside an overlay after its \
165                     dismissal; it is no longer destroyed by the framework"
166                );
167                continue;
168            }
169            self.destroy_subtree(id);
170            destroyed = true;
171        }
172        if destroyed {
173            // A `Drop` may have set a signal this pass has already flushed.
174            self.request_frame();
175        }
176    }
177
178    /// Dismiss any active overlay whose content widget is no longer
179    /// alive in the arena. An overlay's owner can be torn down
180    /// out-of-band: a data-driven rebuild destroys the widget that
181    /// showed it (clicking "mark all read" inside a notification popover
182    /// rebuilds the bell that owns the overlay; closing a document tears
183    /// down a still-open inline popover). The content then disappears
184    /// visually, but the overlay ENTRY survives in the manager and keeps
185    /// intercepting clicks (the click-outside scrim) until the user
186    /// clicks elsewhere. This GC removes such orphans immediately (no
187    /// fade — the content is already gone). A normally-open overlay's
188    /// content stays active (gated `true`), so it is never touched.
189    pub(super) fn gc_orphaned_overlays(&mut self) {
190        let orphaned: Vec<crate::overlay::OverlayId> = self
191            .overlay_manager
192            .active_ids()
193            .into_iter()
194            .filter(|&id| {
195                self.overlay_manager
196                    .overlay(id)
197                    .map(|o| !self.arena.is_active(o.content_id))
198                    .unwrap_or(false)
199            })
200            .collect();
201        if orphaned.is_empty() {
202            return;
203        }
204        for id in orphaned {
205            // The content widget is already destroyed, so this is bookkeeping
206            // rather than anything the user did.
207            self.overlay_manager
208                .dismiss_immediate(id, crate::overlay::DismissReason::Programmatic);
209        }
210        // This is one of the two dismissal paths that does NOT park its
211        // content — the content is already gone — so it never reaches
212        // `dormant_dismissed_content`, where the dismissal callbacks are
213        // normally run. Draining here rather than leaving it to a later pass
214        // is what keeps an anchor's "is my overlay up?" state honest: the
215        // touch-selection handles re-raise on the next hold only because this
216        // tells the field its previous layer went away.
217        //
218        // `NoopWindowOps` because a GC runs from the layout pass, outside any
219        // dispatch — the same reason `dismiss_overlay` uses one.
220        let mut noop = crate::window::NoopWindowOps;
221        self.run_pending_dismiss_callbacks(&mut noop);
222    }
223
224    /// Every widget holding live interaction state a park would destroy:
225    /// the focused node, each live pointer's captor, and the source of an
226    /// in-flight drag.
227    ///
228    /// Handed to the layout pass as `LayoutExtras::interaction_anchors` so a
229    /// container deciding what to keep can protect what the user is in the
230    /// middle of. Almost always empty or a single id, so the `Vec` is one
231    /// small allocation per pass on a tree that is being interacted with.
232    fn collect_interaction_anchors(&self) -> Vec<WidgetId> {
233        let mut out: Vec<WidgetId> = Vec::new();
234        let push = |id: WidgetId, out: &mut Vec<WidgetId>| {
235            if !out.contains(&id) {
236                out.push(id);
237            }
238        };
239        if let Some(id) = self.focused {
240            push(id, &mut out);
241        }
242        for entry in self.pointers.iter() {
243            if let Some(id) = entry.captured_by {
244                push(id, &mut out);
245            }
246        }
247        if let Some(id) = self.active_drag.as_ref().and_then(|d| d.source_widget) {
248            push(id, &mut out);
249        }
250        out
251    }
252
253    /// Re-ask every `culls_children` parent whose decision the user's own
254    /// position is an input to, when that position has changed since the last
255    /// walk.
256    ///
257    /// A culling parent decides which of its children exist, and the contract
258    /// says it must keep the one the user is in the middle of — it reads the
259    /// anchors through `LayoutContext::for_each_interaction_ancestor`. But it
260    /// is asked *during layout*, and the idle early-return below skips the
261    /// walk whenever nothing needs laying out. Focus moving from one card to
262    /// another moves nothing and resizes nothing, so without this the parent
263    /// keeps answering with the anchor set from whenever the camera last
264    /// moved: it would go on pinning the card the user has left, and — worse
265    /// for anything that publishes a narrower set than it keeps alive, as
266    /// `SceneView` does — would not pin the card the user has just arrived in.
267    ///
268    /// Scoped so that a tree with no culling container pays nothing but a
269    /// parent-chain walk per changed anchor, and forces no pass at all. Anchors
270    /// are almost always empty or a single id.
271    fn invalidate_culls_for_moved_interaction(&mut self) {
272        let anchors = self.collect_interaction_anchors();
273        if anchors == self.last_interaction_anchors {
274            return;
275        }
276        // Both directions: an anchor that arrived needs its ancestors to start
277        // pinning it, one that left needs them to stop.
278        let mut moved: Vec<WidgetId> = anchors
279            .iter()
280            .filter(|id| !self.last_interaction_anchors.contains(id))
281            .copied()
282            .collect();
283        moved.extend(
284            self.last_interaction_anchors
285                .iter()
286                .filter(|id| !anchors.contains(id))
287                .copied(),
288        );
289        self.last_interaction_anchors = anchors;
290        for id in moved {
291            // From the parent up: the anchor itself culling its own children
292            // is not affected by being an anchor.
293            let mut current = self.arena.parent(id);
294            while let Some(curr) = current {
295                if self
296                    .arena
297                    .get(curr)
298                    .is_some_and(|node| node.widget.culls_children())
299                {
300                    self.arena.mark_needs_layout(curr);
301                    // A culling parent may publish a narrower accessibility
302                    // set than it keeps alive, and pinning an anchor into that
303                    // set widens it without parking or waking anything — so
304                    // the park/wake invalidation below cannot see it. Without
305                    // this, `sync_accessibility` serves a cached tree that
306                    // omits the newly-pinned node and names an ancestor as the
307                    // focus. `WidgetTree::focus` happens to set the same flag
308                    // for its own reasons, but whichever sync runs first
309                    // consumes it, and that sync precedes this pass.
310                    self.a11y_dirty = true;
311                }
312                current = self.arena.parent(curr);
313            }
314        }
315    }
316
317    /// Drain any widgets flagged `needs_rebuild` that are currently
318    /// active + have built children. Called from
319    /// `process_state_changes` after dirty bindings have been
320    /// flushed, and again after overlay / tooltip activation so that
321    /// widgets transitioning from dormant → active in the same
322    /// layout pass get rebuilt *this* frame rather than the next.
323    pub(super) fn process_pending_rebuilds(&mut self, ops: &mut dyn crate::window::WindowOps) {
324        // Defer *selected* rebuilds while a pointer capture is held:
325        // from `PointerDown` (which stores the press position in the
326        // captured widget's arena) until `PointerUp`. Rebuilding the
327        // captured widget, or any of its ancestors, would destroy that
328        // arena and lose the press state — the recognizer would never
329        // fire.
330        //
331        // The window really does last the whole gesture, NOT just up to
332        // `DragStarted`: a gesture drag auto-captures on `DragStarted`
333        // (`gesture_dispatch_impl`) and holds until `DragEnded`, and the
334        // only thing that lifts this filter is `active_drag`, which is
335        // the drag-and-DROP session set by `start_drag` — never a
336        // scrollbar thumb. So a widget holding a live gesture must not
337        // be a descendant of anything that rebuilds on data or scroll
338        // changes, or that rebuild is silently dropped until release.
339        //
340        // Rebuilds targeting widgets *outside* the captured widget's
341        // ancestor chain are safe: destroying sibling subtrees leaves
342        // the captured widget intact, so ongoing drags keep routing
343        // correctly. That is exactly why all five virtualized views
344        // (`ListView`, `TreeView`, `TableView`, `TreeTableView`,
345        // `GridView`) hoist their rows into a body pane that is a
346        // *sibling* of their scrollbar rather than realizing rows on
347        // the view root — see `common::thumb_drag_test` in
348        // `teksilo-widgets`, which asserts it for each of them.
349        //
350        // Once `active_drag` is set, the framework routes PointerMove /
351        // PointerUp via `handle_drag_move` / `handle_drag_drop` keyed
352        // on the `DragSession`, not on the captured widget's arena —
353        // so a mid-drag rebuild is safe regardless of topology. Post-
354        // rebuild, `revalidate_interaction_state` clears a now-stale
355        // `pointer_captured_by`; subsequent events hit-test normally.
356        let to_rebuild_all = self.arena.collect_needs_rebuild();
357        if to_rebuild_all.is_empty() {
358            self.revalidate_interaction_state(&mut *ops);
359            return;
360        }
361        let captured_ancestors: Option<Vec<WidgetId>> = if self.active_drag.is_none() {
362            // Every captured widget, not just the primary pointer's: two
363            // contacts can hold two captures, and rebuilding either one's
364            // ancestors mid-gesture is what this guard exists to prevent.
365            let captors: Vec<WidgetId> = self
366                .pointers
367                .iter()
368                .filter_map(|entry| entry.captured_by)
369                .collect();
370            (!captors.is_empty()).then(|| {
371                let mut ids = Vec::new();
372                for cap in captors {
373                    let mut cur = Some(cap);
374                    while let Some(id) = cur {
375                        if !ids.contains(&id) {
376                            ids.push(id);
377                        }
378                        cur = self.arena.parent(id);
379                    }
380                }
381                ids
382            })
383        } else {
384            None
385        };
386        let to_rebuild: Vec<WidgetId> = match &captured_ancestors {
387            Some(chain) => to_rebuild_all
388                .into_iter()
389                .filter(|id| !chain.contains(id))
390                .collect(),
391            None => to_rebuild_all,
392        };
393        if to_rebuild.is_empty() {
394            self.revalidate_interaction_state(&mut *ops);
395            return;
396        }
397        // Does focus live inside a subtree we are about to rebuild? Its children
398        // are about to be destroyed and re-allocated with fresh ids, taking the
399        // focused node with them — and once that has happened there is no way
400        // back from the dead id to the subtree it belonged to. Work it out now.
401        let focus_owner: Option<WidgetId> = self.focused.and_then(|focused| {
402            let depth = |id: WidgetId| -> usize {
403                let mut d = 0;
404                let mut cur = id;
405                while let Some(parent) = self.arena.parent(cur) {
406                    d += 1;
407                    cur = parent;
408                }
409                d
410            };
411            // Every root containing `focused` sits on its ancestor chain, so the
412            // candidates are totally ordered by depth. Take the OUTERMOST: it is
413            // the only one sure to survive, since a rebuild destroys its children
414            // — an inner rebuild root nested inside an outer one is torn down by
415            // the outer's rebuild, and its id would be dead by restore time.
416            to_rebuild
417                .iter()
418                .copied()
419                .filter(|&root| self.is_descendant_of(focused, root))
420                .min_by_key(|&root| depth(root))
421        });
422
423        // The focused node's ancestors up to that root, innermost first, each
424        // with the focused node's position among its focusable descendants.
425        // The restore below goes into the innermost one the rebuild left
426        // alive, at the same position.
427        let focus_ancestors: Vec<(WidgetId, usize)> = match (self.focused, focus_owner) {
428            (Some(focused), Some(owner)) => {
429                let mut chain = Vec::new();
430                let mut cur = self.arena.parent(focused);
431                while let Some(id) = cur {
432                    let position = self
433                        .focusable_descendants(id)
434                        .iter()
435                        .position(|&f| f == focused)
436                        .unwrap_or(0);
437                    chain.push((id, position));
438                    if id == owner {
439                        break;
440                    }
441                    cur = self.arena.parent(id);
442                }
443                chain
444            }
445            _ => Vec::new(),
446        };
447
448        for widget_id in to_rebuild {
449            self.rebuild_single_widget(widget_id);
450        }
451        // A rebuild destroys old child subtrees and allocates fresh
452        // WidgetIds, so the AccessKit tree shape changed — dirty the cached
453        // snapshot so the next `sync_accessibility` re-walks. This is the one
454        // place every `BindingLevel::Rebuild` consumer converges (data-view
455        // model updates via the binding registry AND `with_widget_mut(Rebuild)`
456        // via `apply_tree_mutations`, both draining the same `needs_rebuild`
457        // arena flag) — without this, an ordinary `ListModel::push()` leaves
458        // screen readers on a stale tree indefinitely.
459        self.a11y_dirty = true;
460        // Rebuild destroys old child subtrees and allocates fresh WidgetIds;
461        // drop any focus/hover state whose target is no longer valid so we
462        // don't dispatch to dead widgets on the next event.
463        self.revalidate_interaction_state(&mut *ops);
464        // ...but "no longer valid" must not mean "gone". If focus lived in the
465        // subtree we just rebuilt, the drop above kicked the user clean out of
466        // the widget they were in: a popover that re-scans its content when it
467        // opens throws away the row the popover itself had just focused, and the
468        // menu comes up with nothing focused — no arrow keys, no Enter. Put focus
469        // back inside that subtree, at the end of the layout pass (the fresh
470        // children have no bounds yet, and the focus-driven scroll-into-view
471        // needs them). A rebuild that never held focus, or one whose focused node
472        // survived it (the rebuild root itself is not destroyed), records nothing.
473        //
474        // Into the innermost ancestor that survived, not the root: a reconciling
475        // rebuild re-attaches the children it keeps, so the toast, tab or group
476        // the user was in is usually still there, and the root's first focusable
477        // descendant is the first of them, not theirs.
478        if self.focused.is_none()
479            && let Some(owner) = focus_owner
480        {
481            self.pending_focus_restore = focus_ancestors
482                .into_iter()
483                .filter(|&(id, _)| {
484                    self.arena.get(id).is_some()
485                        && (id == owner || self.is_descendant_of(id, owner))
486                })
487                .collect();
488        }
489        // A rebuild's `build()` may arm new animations (looping or
490        // one-shot) by calling `signal.animate_to(...)` /
491        // `animate_looping(...)` — these set `pending` on the signal
492        // but don't enter the scheduler until `process_pending_animations`
493        // runs again. The early-frame `process_pending_animations`
494        // (`layout_impl::layout_with_ops`) already ran *before* this
495        // rebuild, so without this second drain the animation would
496        // wait for the next frame; if the rebuild also cancelled
497        // existing scheduler entries (`cancel_by_widget` is called by
498        // `rebuild_single_widget`), the scheduler ends up empty, no
499        // frame deadline is set, and the freshly-armed animation
500        // *never* gets picked up — the user sees animations freeze
501        // after any state-driven rebuild that re-arms them
502        // (e.g. SceneView's drag-end rebuild re-arming PulsingDot
503        // loopers via `register_bindings`).
504        self.process_pending_animations();
505    }
506
507    /// Run the layout pass with the given size proposal, using
508    /// [`NoopWindowOps`](crate::window::NoopWindowOps). Handlers
509    /// triggered from drag_tick / tooltip activation / etc cannot
510    /// call `ctx.open_window(...)` from this path.
511    ///
512    /// `teksilo-app` calls [`layout_with_ops`](Self::layout_with_ops)
513    /// with a real sink so those handlers can open windows.
514    pub fn layout(&mut self, proposal: SizeProposal) {
515        let mut noop = crate::window::NoopWindowOps;
516        self.layout_with_ops(proposal, &mut noop);
517    }
518
519    /// Measure the intrinsic size of the primary (non-overlay) content root(s)
520    /// at `proposal` — e.g. `{ width: Some(w), height: None }` for the natural
521    /// height at a fixed width. Mirrors the overlay intrinsic pass below: it
522    /// calls the root's `layout_response` *directly* — NOT the
523    /// activation-ignoring `WidgetArena::measure_intrinsic` — so a
524    /// `visible_when(false)` / parked-`Switcher` descendant is excluded exactly
525    /// as the real layout excludes it. A size-to-content window is therefore
526    /// sized to what is actually shown. Computes sizes only (never writes
527    /// bounds), so it is safe to call right after a layout pass.
528    ///
529    /// Drives size-to-content windows (see
530    /// [`WindowConfig::size_to_content`](crate::window::WindowConfig::size_to_content)):
531    /// the native-window path has no in-tree overlay to size to content, so
532    /// `teksilo-app` measures the root here and resizes the OS window to fit.
533    /// Returns `None` if there is no active primary root; with more than one
534    /// active primary root the per-axis maximum is returned (size-to-content is
535    /// intended for single-primary-root windows).
536    pub fn measure_root_intrinsic(&self, proposal: SizeProposal) -> Option<teksilo_canvas::Size> {
537        let overlay_content_ids = self.overlay_manager.active_content_ids();
538        let base_theme = self.effective_theme.clone();
539        let mut result: Option<teksilo_canvas::Size> = None;
540        for root_id in self.arena.roots() {
541            if overlay_content_ids.contains(&root_id) || !self.arena.is_active(root_id) {
542                continue;
543            }
544            let resolved_theme = self.arena.resolve_theme(root_id, &base_theme);
545            let extras = crate::widget::LayoutExtras {
546                focused: self.focused,
547                // A measurement, not a pass: nothing is parked off the back of
548                // it, so there is nothing for an anchor to protect.
549                interaction_anchors: &[],
550                shortcut_registry: Some(&self.shortcut_registry),
551                overlay_manager: Some(&self.overlay_manager),
552            };
553            let ctx = LayoutContext {
554                theme: &resolved_theme,
555                layout_direction: self.layout_direction,
556                scale_factor: self.device_scale_factor,
557                text_scale: self.effective_text_scale,
558                text_backend: self.text_backend.as_ref(),
559                arena: Some(&self.arena),
560                extras: Some(extras),
561                stack_main_axis: None,
562            };
563            let Some(node) = self.arena.get(root_id) else {
564                continue;
565            };
566            // Direct `layout_response` (activation-respecting), like the overlay
567            // pass — dormant descendants fall out via `child_size` returning
568            // `None`, so we measure only what is actually shown.
569            let size = node.widget.layout_response(proposal, &ctx).size;
570            result = Some(match result {
571                Some(acc) => teksilo_canvas::Size::new(
572                    acc.width.max(size.width),
573                    acc.height.max(size.height),
574                ),
575                None => size,
576            });
577        }
578        result
579    }
580
581    /// Run the layout pass with the given size proposal, threading
582    /// the app's [`WindowOps`](crate::window::WindowOps) sink
583    /// through to drag_tick / tooltip / delayed-overlay handlers.
584    pub fn layout_with_ops(
585        &mut self,
586        proposal: SizeProposal,
587        ops: &mut dyn crate::window::WindowOps,
588    ) {
589        self.process_pending_animations();
590
591        let now = std::time::Instant::now();
592        // Deadline-driven wake-up: if a widget requested a future
593        // frame via `wake_at_handle()` and that deadline is now past,
594        // arm the frame tick so its effect runs on this layout pass.
595        // Used by the rich text editor's caret blink to avoid
596        // keeping winit in Poll mode.
597        if let Some(deadline) = self.pending_wake_at.get()
598            && deadline <= now
599        {
600            self.pending_wake_at.set(None);
601            self.frame_tick_requested.set(true);
602        }
603        self.advance_frame_tick(now);
604        // Ticked on the same clock the animations were *promoted* against
605        // (`process_pending_animations` immediately above reads it too). While
606        // the tree runs on real time that is `now`; while an automation
607        // operation has time taken over it is `sim_clock`, and ticking at
608        // `Instant::now()` there would hand every animation an elapsed time of
609        // the tree's whole wall-clock age and complete it on its first layout
610        // pass. The operation gives the clock back when it ends, rebasing the
611        // scheduler as it goes, so this reads the wall clock again from the
612        // next frame on — see `WidgetTree::resume_real_time`.
613        let animation_now = self.animation_clock();
614        self.animation_scheduler
615            .tick(animation_now, &self.arena, self.paint_epoch);
616
617        // Fire on_drag_tick on the current drop target, if any. Runs once
618        // per layout pass so widgets can implement per-frame behaviours
619        // (viewport-edge auto-scroll, spring-loaded folders) without
620        // depending on pointer events — crucial when the user holds the
621        // cursor still at the edge or over a collapsed branch.
622        self.process_drag_tick(&mut *ops);
623
624        // What content updated off the UI thread asked for since the last
625        // frame: before the state changes it may cause are processed, and
626        // before the idle early return below, which its marks defeat.
627        self.poll_off_thread();
628
629        // Restore targets set straight on the overlay manager (teksilo-app's
630        // modal presentation) get their fallback record now, while the
631        // ancestors it names still exist.
632        self.sync_focus_returns();
633        self.process_state_changes(&mut *ops);
634        // A drag owns the pointer. `handle_pointer_move` is short-circuited for
635        // the duration, so a dwell armed just before the drag started would sit
636        // frozen at its hover origin and then mature here — popping a tooltip
637        // over the drag. Keep the timers cleared instead of letting them ripen.
638        if self.active_drag.is_some() {
639            self.tooltip_cancel_pending_dwell();
640        }
641        self.process_tooltips_real();
642        self.process_delayed_overlays_real(&mut *ops);
643        self.process_pointer_leave_overlays_real(&mut *ops);
644        self.process_auto_dismiss_overlays_real(&mut *ops);
645        self.process_overlay_fade_dismissals_real(&mut *ops);
646        // The show paths above may arm a fade animation via
647        // `attach_overlay_fade` (plain tooltips, delayed overlays).
648        // That sets `pending` on the opacity signal but does NOT
649        // register the animation with the scheduler — registration
650        // happens via `process_pending_animations`, which already ran
651        // earlier in this layout pass. Without a second drain here,
652        // the fade only enters the scheduler on the *next* layout
653        // pass, and for surfaces with no further wake source (plain
654        // tooltips, no dwell timer) `next_deadline` returns `None`
655        // and the event loop sleeps with the fade stuck at opacity 0
656        // — the tooltip is "shown" but invisible until an unrelated
657        // input event forces another layout pass.
658        self.process_pending_animations();
659        // Overlay / tooltip activation may have flipped widgets from
660        // dormant → active; if any of those had `needs_rebuild`
661        // pending (e.g. a shortcut rebind happened while the tooltip
662        // was hidden), drain them now so the freshly-visible surface
663        // shows fresh content in the *same* layout pass rather than
664        // waiting for another paint-triggering event.
665        self.process_pending_rebuilds(&mut *ops);
666
667        // Now that any data-driven rebuilds have torn down their old
668        // subtrees, drop any overlay whose content was destroyed out-of-
669        // band (e.g. clicking "mark all read" inside a notification
670        // popover rebuilds the bell that owns it). Without this the
671        // overlay lingers as an invisible click-blocker. Runs before the
672        // early-return so it takes effect even on otherwise-idle passes.
673        //
674        // Content the framework built for one overlay alone (a deferred
675        // modal's, its scrim, a context menu) is destroyed first, so an
676        // overlay opened from inside it whose content goes with it is
677        // collected right below in the same pass.
678        self.destroy_dismissed_content(&mut *ops);
679        self.gc_orphaned_overlays();
680
681        self.arena.refresh_roots();
682
683        // Before the idle early-return, because this is precisely the case it
684        // would swallow: the user moved and nothing else did.
685        self.invalidate_culls_for_moved_interaction();
686
687        let proposal_changed = self.last_proposal != proposal;
688        self.last_proposal = proposal;
689
690        if !proposal_changed && !self.arena.any_needs_layout() {
691            // Nothing to lay out, so nothing left to wake: a focus request
692            // waiting on a dormant node is decided now, as at the tail below.
693            self.settle_pending_focus_request(&mut *ops);
694            // A pass with nothing to lay out still processed state changes,
695            // and one of them may have disabled the focused control.
696            self.move_focus_off_disabled(&mut *ops);
697            return;
698        }
699
700        // Per-pass layout memoization: a widget's `layout_response` is a pure
701        // function of (state, proposal) within a pass, so memoizing across the
702        // main-then-cross queries that height-for-width negotiation issues keeps
703        // the pass O(n). Cleared here — once, dominating both the main-tree and
704        // overlay root recursions below — because geometry may change between
705        // passes. See `WidgetArena::cached_layout_response`.
706        self.arena.clear_layout_cache();
707
708        // `effective_theme` carries the user/OS text-scale multiplier baked into
709        // its typography, so every text widget measures at the scaled size.
710        let base_theme = self.effective_theme.clone();
711
712        let overlay_content_ids = self.overlay_manager.active_content_ids();
713        let roots: Vec<WidgetId> = self.arena.roots();
714        let focused = self.focused;
715        // Everything a park would take away from the user, gathered once so a
716        // culling container can ask
717        // `LayoutContext::for_each_interaction_ancestor` without the tree.
718        // Empty on an idle tree.
719        let interaction_anchors = self.collect_interaction_anchors();
720        // What the `culls_children` parents decided about their children
721        // during this walk — parked and woken alike. Settled after it, because
722        // parking goes through the tree-level door and both halves change the
723        // AccessKit tree.
724        let mut culled = CullTransitions::default();
725        // A widget a culling parent wakes during this walk takes its pending
726        // off-thread relayout in `activate`; see the re-mark after the walk.
727        self.arena.relayout_rewake.clear();
728        for root_id in roots {
729            if overlay_content_ids.contains(&root_id) {
730                continue;
731            }
732            let extras = crate::widget::LayoutExtras {
733                focused,
734                interaction_anchors: &interaction_anchors,
735                shortcut_registry: Some(&self.shortcut_registry),
736                overlay_manager: Some(&self.overlay_manager),
737            };
738            layout_widget_recursive(
739                &mut self.arena,
740                root_id,
741                Rect::from_origin_size(Point::ZERO, proposal.resolve(0.0, 0.0)),
742                proposal,
743                &base_theme,
744                self.layout_direction,
745                self.device_scale_factor,
746                self.effective_text_scale,
747                self.text_backend.as_ref(),
748                Some(extras),
749                &mut culled,
750            );
751        }
752
753        let anchor_bounds = |id: WidgetId| -> Option<Rect> {
754            self.arena.is_active(id).then(|| self.arena.bounds(id))
755        };
756        // The window, less the platform safe area, less whatever is covering
757        // it. Both are `ZERO`/`None` unless something supplied them, so a
758        // desktop frame produces exactly the bare `(width, height)` this used
759        // to pass.
760        let viewport = self.overlay_viewport_for(teksilo_canvas::Size::new(
761            proposal.width.unwrap_or(800.0),
762            proposal.height.unwrap_or(600.0),
763        ));
764        self.overlay_manager
765            .position_overlays(anchor_bounds, viewport, self.layout_direction);
766        for content_id in &overlay_content_ids {
767            if !self.arena.is_active(*content_id) {
768                continue;
769            }
770            let overlay_id = self.overlay_manager.find_by_content(*content_id);
771            let intrinsic = {
772                let resolved_theme = self.arena.resolve_theme(*content_id, &base_theme);
773                let extras = crate::widget::LayoutExtras {
774                    focused: self.focused,
775                    interaction_anchors: &interaction_anchors,
776                    shortcut_registry: Some(&self.shortcut_registry),
777                    overlay_manager: Some(&self.overlay_manager),
778                };
779                let ctx = LayoutContext {
780                    theme: &resolved_theme,
781                    layout_direction: self.layout_direction,
782                    scale_factor: self.device_scale_factor,
783                    text_scale: self.effective_text_scale,
784                    text_backend: self.text_backend.as_ref(),
785                    arena: Some(&self.arena),
786                    extras: Some(extras),
787                    stack_main_axis: None,
788                };
789                let node = self
790                    .arena
791                    .get(*content_id)
792                    .expect("content_id from active arena children");
793                node.widget
794                    .layout_response(
795                        SizeProposal {
796                            width: None,
797                            height: None,
798                        },
799                        &ctx,
800                    )
801                    .size
802            };
803            if let Some(overlay_id) = overlay_id {
804                self.overlay_manager
805                    .set_content_bounds(overlay_id, intrinsic);
806                let anchor_bounds = |id: WidgetId| -> Option<Rect> {
807                    self.arena.is_active(id).then(|| self.arena.bounds(id))
808                };
809                self.overlay_manager.position_overlays(
810                    anchor_bounds,
811                    viewport,
812                    self.layout_direction,
813                );
814            }
815            let overlay_bounds = overlay_id
816                .and_then(|overlay_id| {
817                    self.overlay_manager
818                        .stack
819                        .iter()
820                        .find(|overlay| overlay.id == overlay_id)
821                        .map(|overlay| overlay.bounds)
822                })
823                .unwrap_or(Rect::ZERO);
824            // Use the positioned overlay_bounds for layout, not the intrinsic
825            // size. For `Below` / `BelowPreferred` (and any other placement
826            // that inflates the overlay rect beyond the content's intrinsic
827            // size to match an anchor, e.g. a combo-box dropdown that must be
828            // at least as wide as its trigger), this lets the content widget
829            // actually fill the overlay rather than sitting as a narrow
830            // strip inside it. It carries the placements that *shrink* the
831            // rect just as well — `Above`/`BelowPreferred` to the room they
832            // found, `Centered`/`BottomCenter`/`ViewportCorner` to the usable
833            // area, `FullViewport` to the whole window — so the content is
834            // laid out at the rectangle it was actually given, whichever
835            // placement decided it.
836            let content_proposal = SizeProposal::exact(overlay_bounds.width, overlay_bounds.height);
837            let extras = crate::widget::LayoutExtras {
838                focused: self.focused,
839                interaction_anchors: &interaction_anchors,
840                shortcut_registry: Some(&self.shortcut_registry),
841                overlay_manager: Some(&self.overlay_manager),
842            };
843            layout_widget_recursive(
844                &mut self.arena,
845                *content_id,
846                overlay_bounds,
847                content_proposal,
848                &base_theme,
849                self.layout_direction,
850                self.device_scale_factor,
851                self.effective_text_scale,
852                self.text_backend.as_ref(),
853                Some(extras),
854                &mut culled,
855            );
856        }
857
858        // ── Settle what a culling parent decided ──────────────────────
859        // A widget that culls its children decides during layout which of them
860        // exist, and it can only decide once it knows its own bounds and — for
861        // a scene — the camera it is looking through. Waking has already
862        // happened inline, so a child brought back was laid out this pass;
863        // parking is here because it has to go through the tree-level door,
864        // which tells any pointer working inside the subtree that its
865        // interaction is over rather than leaving it holding a widget the
866        // dispatcher will no longer reach.
867        //
868        // Both halves land here, and neither is the special case. The
869        // accessibility walk skips dormant nodes, so an active↔dormant
870        // transition in EITHER direction changes the AT tree's shape; and
871        // `activation_signal` is the hook a native subview (a `WebView`'s
872        // `set_visible` bridge) hangs its own visibility on, so a queued
873        // `true` that nothing drains is a subview that never comes back. This
874        // is the same rule the `visible_when` sweep at the top of
875        // `process_state_changes` applies to `to_dormant` / `to_activate`;
876        // writing it for parking alone made waking a silent no-op that the
877        // usual probe cannot see, because a card waking *into* the viewport
878        // resizes from `Size::ZERO` and a resize dirties the tree on its own.
879        // A card that wakes and stays zero-sized — which is every card in the
880        // band `A11yOffScreenMode::ViewportPlusN` promises to enumerate — does
881        // not.
882        if !culled.is_empty() {
883            self.a11y_dirty = true;
884            // The composed frame holds what the walk drew, parked children
885            // included, and a render replays it when no active node is marked
886            // for paint. A pass can end that way: a handled scroll marks only
887            // its target, and the parent may park that very target. Dropping
888            // it is what `set_dormant` and `activate` do; the next render
889            // reassembles the frame from the nodes' own caches.
890            self.cached_frame = None;
891            // `revalidate_interaction_state` follows the parks immediately:
892            // focus must never survive a pass pointing at a node this just
893            // parked, because dispatch rejects inactive targets and a
894            // keystroke into the void is worse than a focus loss the user can
895            // see. A culling parent is expected to pin what the user is using
896            // (see `LayoutContext::for_each_interaction_ancestor`), so this is
897            // the backstop, not the plan.
898            let parked = !culled.park.is_empty();
899            for id in std::mem::take(&mut culled.park) {
900                self.park_subtree_with_ops(id, &mut *ops);
901            }
902            self.flush_activation_signals();
903            if parked {
904                self.revalidate_interaction_state(&mut *ops);
905            }
906        }
907
908        // Clear `needs_layout` for every active widget — layout just
909        // ran. `needs_rebuild` is NOT cleared here: `rebuild_single_widget`
910        // clears it for widgets it processes, and widgets whose rebuild
911        // was deferred (captured-pointer window) must keep the flag set
912        // so the next layout pass picks them up. Wiping it here caused
913        // a regression where a scroll-driven ListView rebuild, deferred
914        // during a scrollbar thumb drag, was silently dropped — the
915        // user saw the thumb move but the list view stayed frozen.
916        // Clear `needs_layout` on every active node. Mutation during
917        // iter — pull the snapshot via the reusable scratch.
918        self.arena.fill_active_ids(&mut self.active_ids_scratch);
919        let ids = std::mem::take(&mut self.active_ids_scratch);
920        for &id in &ids {
921            if let Some(node) = self.arena.get_mut(id) {
922                node.dirty.needs_layout = false;
923            }
924        }
925        self.active_ids_scratch = ids;
926        // A widget woken inside the walk with an off-thread relayout pending
927        // was measured before it was woken, so possibly at the size before
928        // the request: lay it out again next pass. One extra pass, only then.
929        for id in std::mem::take(&mut self.arena.relayout_rewake) {
930            if self.arena.is_active(id) {
931                self.arena.mark_needs_layout(id);
932                self.arena.mark_ancestors_need_layout(id);
933            }
934        }
935
936        // Post-layout hover refresh. When a rebuild destroyed the
937        // hovered widget, `revalidate_interaction_state` cleared
938        // `hovered` to `None`. Now that widgets have fresh bounds
939        // from this layout pass, re-hit-test at the cached pointer
940        // position so the next wheel/pointer event routes to the
941        // widget the cursor is actually over. Without this, a
942        // virtualized list that materializes new rows under a
943        // stationary cursor would see the next `Scroll` fall through
944        // to `focused` and bubble to an ancestor scrollable.
945        // Hover recovery is the **hover owner**'s business: re-deriving hover
946        // from the primary would invent one on a touch-only device, where the
947        // primary is a finger and nothing hovers at all.
948        // Not while the hover owner is outside the window: its cached position
949        // is the last one inside, and hover re-derived from it would light the
950        // widget up and start its tooltip dwell for a pointer that is gone.
951        if self.hovered_id().is_none()
952            && !self.hover_owner_outside_window
953            && let Some(pos) = self.hover_owner_position()
954        {
955            let new_target = self.hit_test(pos);
956            if new_target.is_some() {
957                if let Some(new) = new_target {
958                    // Credited to the hover owner, whose cached position is
959                    // what re-derived the target — no sample raised this.
960                    let enter = WidgetEvent::PointerEnter {
961                        pointer: self.hover_transition_pointer(),
962                    };
963                    self.dispatch_to_widget(new, &enter, &mut *ops);
964                    // Seed the tooltip dwell too, exactly as `handle_pointer_move`
965                    // pairs these two. The rebuild replaced the anchor's tooltip
966                    // entry with a fresh one whose `hover_start` is `None`, and
967                    // the pointer is not going to move again — so without this the
968                    // widget's tooltip is unreachable for the rest of the hover.
969                    // ...unless a press cancelled the dwell: the click that
970                    // rebuilt the anchor answered the tooltip's question.
971                    if !self.tooltip_dwell_held {
972                        self.tooltip_pointer_enter(new);
973                    }
974                }
975                self.set_hovered(new_target);
976            }
977        }
978
979        // Post-layout focus refresh — the symmetric case to the hover refresh
980        // above. A rebuild destroyed the focused widget, so
981        // `revalidate_interaction_state` cleared `focused` to `None`; the
982        // subtree that owned it was recorded as `pending_focus_restore`. Now
983        // that its fresh children have bounds from this layout pass, land focus
984        // back inside it, so a rebuild keeps focus in the subtree that had it
985        // rather than dumping it out of the widget entirely.
986        //
987        // Deliberately conservative: only when nothing else has taken focus in
988        // the meantime, only into a subtree that is still active (a rebuild that
989        // also went dormant, e.g. a popover closing, must NOT drag focus back
990        // into hidden content — its own dismiss path restores focus to the
991        // trigger). When the subtree is still active but nothing in it can
992        // take focus (the focused node was not focusable itself, a tree row a
993        // screen reader acted on), the nearest focusable above it takes it,
994        // else the scope's first Tab stop (`rebuild_focus_fallback`); with the
995        // subtree gone dormant, focus stays `None`.
996        //
997        // An explicit request that was waiting for its target to wake goes
998        // first. Activation is settled — the `visible_when` sweep and the
999        // culling parents have both run — and the bounds are fresh. Landing it
1000        // makes `focused` non-`None`, so the restore below then stands down: a
1001        // rebuild's guess never overrides what a handler asked for.
1002        self.settle_pending_focus_request(&mut *ops);
1003        let restore = std::mem::take(&mut self.pending_focus_restore);
1004        // Into the rebuilt subtree at the position focus had: the same widget
1005        // restored, so `Restored`. The fallback below lands on another widget,
1006        // so `Programmatic`.
1007        let mut origin = crate::focus::FocusOrigin::Restored;
1008        if self.focused.is_none()
1009            && let Some(target) = restore
1010                .iter()
1011                .find_map(|&(root, position)| {
1012                    if !self.arena.is_active(root) {
1013                        return None;
1014                    }
1015                    // The node at the position focus had. A rebuild that left
1016                    // fewer focusables than that takes the last one: the
1017                    // nearest to where the user was.
1018                    let focusables = self.focusable_descendants(root);
1019                    focusables.get(position).or(focusables.last()).copied()
1020                })
1021                // Nothing focusable left inside: the node that had focus was
1022                // not a focusable itself (a tree row a screen reader acted
1023                // on), or its whole subtree went. The nearest focusable above
1024                // the innermost surviving root then, else the scope's first
1025                // Tab stop, rather than nothing.
1026                .or_else(|| {
1027                    let roots: Vec<WidgetId> = restore.iter().map(|&(root, _)| root).collect();
1028                    origin = crate::focus::FocusOrigin::Programmatic;
1029                    (!roots.is_empty())
1030                        .then(|| self.rebuild_focus_fallback(&roots))
1031                        .flatten()
1032                })
1033        {
1034            // `Restored`, not `Programmatic`: nobody moved focus, the rebuild
1035            // replaced the widget that had it. A text field takes this as the
1036            // same edit going on, not as a new arrival.
1037            self.focus_with_origin_ops(target, origin, &mut *ops);
1038        }
1039        // Last, once every rebuild, park and restore of the pass has settled:
1040        // a focused control that has become disabled hands focus on.
1041        self.move_focus_off_disabled(&mut *ops);
1042
1043        // A widget that changed size may have re-wrapped its text, and a
1044        // wrapped label carries one accessibility text run per visual line
1045        // — a different set of runs, not the same set somewhere else. Pure
1046        // translations stay recorded on the arena for `sync_accessibility`
1047        // to absorb without walking.
1048        if self.arena.take_a11y_resized() {
1049            self.a11y_dirty = true;
1050        }
1051
1052        // Backstop. Every dismissal path is supposed to drain its callbacks
1053        // itself — `dormant_dismissed_content` for the ones that park content,
1054        // `gc_orphaned_overlays` for the one whose content is already gone —
1055        // because draining *there* is what keeps the documented ordering
1056        // (during dismissal, before focus returns to the trigger). This exists
1057        // for the path nobody thought of: a parked callback that reaches the
1058        // end of a frame has been stranded, and running it a frame late beats
1059        // never. Costs a bool test when the queue is empty, which is always.
1060        self.run_pending_dismiss_callbacks(&mut *ops);
1061    }
1062}
1063
1064/// What the `culls_children` parents in one layout walk decided about their
1065/// children, collected here because settling either half needs `WidgetTree`,
1066/// which the free function below does not have.
1067///
1068/// Both halves are recorded, not just the parks. A wake is applied inline —
1069/// the child has to be laid out in the same pass or a camera that jumps shows
1070/// a hole — but it still owes the tree the two things a park owes it: an
1071/// invalidated AccessKit cache and a drained `activation_signal` queue.
1072#[derive(Default)]
1073pub(super) struct CullTransitions {
1074    /// Children to park, applied once the walk is over so parking can go
1075    /// through `WidgetTree::park_subtree_with_ops`.
1076    park: Vec<WidgetId>,
1077    /// Children already woken during the walk, kept so the settle step can
1078    /// tell "nothing happened" from "something came back".
1079    woke: Vec<WidgetId>,
1080}
1081
1082impl CullTransitions {
1083    /// Whether this walk changed any child's activation, either way.
1084    fn is_empty(&self) -> bool {
1085        self.park.is_empty() && self.woke.is_empty()
1086    }
1087}
1088
1089/// Recursive layout pass operating on the arena directly (avoids borrow conflicts).
1090#[allow(clippy::too_many_arguments)]
1091fn layout_widget_recursive(
1092    arena: &mut WidgetArena,
1093    id: WidgetId,
1094    parent_bounds: Rect,
1095    proposal: SizeProposal,
1096    base_theme: &crate::styles::Theme,
1097    layout_direction: crate::environment::LayoutDirection,
1098    scale_factor: f32,
1099    text_scale: f32,
1100    text_backend: Option<&std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>>,
1101    extras: Option<crate::widget::LayoutExtras<'_>>,
1102    // Every activation change a culling parent asked for during this walk.
1103    // Settled by the caller once the walk is over.
1104    culled: &mut CullTransitions,
1105) {
1106    if !arena.is_active(id) {
1107        return;
1108    }
1109
1110    let resolved_theme = arena.resolve_theme(id, base_theme);
1111
1112    let desired_size = {
1113        let ctx = LayoutContext {
1114            theme: &resolved_theme,
1115            layout_direction,
1116            scale_factor,
1117            text_scale,
1118            text_backend,
1119            arena: Some(arena),
1120            extras,
1121            stack_main_axis: None,
1122        };
1123        arena
1124            .cached_layout_response(id, proposal, &ctx)
1125            .map(|r| r.size)
1126            .unwrap_or(teksilo_canvas::Size::ZERO)
1127    };
1128
1129    let bounds = Rect::new(
1130        parent_bounds.x,
1131        parent_bounds.y,
1132        proposal.width.unwrap_or(desired_size.width),
1133        proposal.height.unwrap_or(desired_size.height),
1134    );
1135    let previous = arena.get_mut(id).and_then(|node| {
1136        let previous = node.bounds;
1137        (previous != bounds).then(|| {
1138            node.cached_paint = None;
1139            node.dirty.needs_paint = true;
1140            node.bounds = bounds;
1141            previous
1142        })
1143    });
1144    if let Some(previous) = previous {
1145        arena.note_bounds_change(id, previous, bounds);
1146    }
1147
1148    let child_ids: Vec<WidgetId> = arena.children(id).to_vec();
1149    // A widget that culls its children is handed the dormant ones too — it is
1150    // the only way it can ask for one back, having parked it. Every other
1151    // widget sees its active children and nothing else, exactly as before.
1152    let culls_children = arena
1153        .get(id)
1154        .is_some_and(|node| node.widget.culls_children());
1155    let mut placements: Vec<WidgetPlacement> = child_ids
1156        .iter()
1157        .copied()
1158        .filter_map(|child_id| {
1159            let active = arena.is_active(child_id);
1160            (active || culls_children).then_some(WidgetPlacement {
1161                id: child_id,
1162                origin: bounds.origin(),
1163                size: bounds.size(),
1164                dormant: !active,
1165            })
1166        })
1167        .collect();
1168
1169    // `place_children` is a widget's ONLY hook that receives its final,
1170    // parent-assigned `bounds`, so it runs for EVERY active widget on every
1171    // pass — including leaves, which get an empty `placements` slice. A widget
1172    // whose paint depends on where the parent put it (a scene folding its
1173    // origin into a view transform, a text engine sizing its viewport) can then
1174    // read its bounds during *layout*, which is the only point early enough:
1175    // the render walker pushes node-level transform scopes before `paint` runs.
1176    {
1177        let ctx = LayoutContext {
1178            theme: &resolved_theme,
1179            layout_direction,
1180            scale_factor,
1181            text_scale,
1182            text_backend,
1183            arena: Some(arena),
1184            extras,
1185            stack_main_axis: None,
1186        };
1187        let node = arena
1188            .get(id)
1189            .expect("widget id is not in the arena (destroyed?)");
1190        node.widget
1191            .place_children(bounds, proposal, &mut placements, &ctx);
1192    }
1193
1194    for placement in &placements {
1195        if culls_children {
1196            // Apply the parent's decision before anything reads the child's
1197            // state. Waking is immediate — the child is laid out below, this
1198            // pass, so a camera that jumps shows no hole. Parking is recorded
1199            // and applied once the walk is over, because it has to go through
1200            // the tree-level door (`WidgetTree::park_subtree_with_ops`) to
1201            // tell any pointer working inside that its interaction is over.
1202            // Either way the child's bounds are written first, so a parked
1203            // card keeps the coordinate that `scroll_into_view` and
1204            // focus-follow read.
1205            let active = arena.is_active(placement.id);
1206            if !placement.dormant && !active {
1207                arena.activate(placement.id);
1208                // `activate` only *queues* the `(id, true)` transition; the
1209                // caller's settle step drains it. Recorded so that step can
1210                // run at all — a pass that woke a child and parked none used
1211                // to leave the queue and the AT cache untouched.
1212                culled.woke.push(placement.id);
1213            } else if placement.dormant && active {
1214                culled.park.push(placement.id);
1215            }
1216        }
1217
1218        let child_bounds = Rect::from_origin_size(placement.origin, placement.size);
1219        let previous = arena.get_mut(placement.id).and_then(|child_node| {
1220            let previous = child_node.bounds;
1221            (previous != child_bounds).then(|| {
1222                child_node.cached_paint = None;
1223                child_node.dirty.needs_paint = true;
1224                child_node.bounds = child_bounds;
1225                previous
1226            })
1227        });
1228        if let Some(previous) = previous {
1229            arena.note_bounds_change(placement.id, previous, child_bounds);
1230        }
1231
1232        // Gated on `culls_children`, because that is the contract: for every
1233        // other widget `dormant` arrives `false` and is not read back, and a
1234        // widget that set it anyway must not be able to take a child out of
1235        // the pass by writing a field it was told does nothing.
1236        if culls_children && placement.dormant {
1237            // Nothing below a parked child is laid out, painted, walked for
1238            // accessibility or reachable by Tab. That is the whole point.
1239            continue;
1240        }
1241
1242        let child_proposal = SizeProposal::exact(placement.size.width, placement.size.height);
1243        let grandchild_ids: Vec<WidgetId> = arena.children(placement.id).to_vec();
1244        if !grandchild_ids.is_empty() {
1245            layout_widget_recursive(
1246                arena,
1247                placement.id,
1248                child_bounds,
1249                child_proposal,
1250                base_theme,
1251                layout_direction,
1252                scale_factor,
1253                text_scale,
1254                text_backend,
1255                extras,
1256                culled,
1257            );
1258        } else {
1259            // A childless child is never visited by the recursion above, so
1260            // hand it its final bounds here — with an empty `placements` slice.
1261            //
1262            // Deliberately NOT a `layout_widget_recursive` call: that would
1263            // re-measure the leaf against a fresh `exact` proposal (a memo miss,
1264            // since the parent measured it under a different proposal), adding a
1265            // redundant `layout_response` per leaf on every pass.
1266            let ctx = LayoutContext {
1267                theme: &resolved_theme,
1268                layout_direction,
1269                scale_factor,
1270                text_scale,
1271                text_backend,
1272                arena: Some(arena),
1273                extras,
1274                stack_main_axis: None,
1275            };
1276            let node = arena.get(placement.id).expect("child id is active");
1277            node.widget
1278                .place_children(child_bounds, child_proposal, &mut [], &ctx);
1279        }
1280    }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use crate::test_widgets::{FillWidget, InsetWidget, StackWidget};
1287    use teksilo_canvas::Size;
1288    use teksilo_tokens::Color;
1289
1290    /// A leaf of a fixed intrinsic size, so a `Centered` overlay has something
1291    /// to centre.
1292    #[derive(Debug)]
1293    struct Sized(f32, f32);
1294
1295    impl Widget for Sized {
1296        fn layout_response(
1297            &self,
1298            _proposal: SizeProposal,
1299            _ctx: &LayoutContext,
1300        ) -> crate::widget::LayoutResponse {
1301            Size::new(self.0, self.1).into()
1302        }
1303    }
1304
1305    fn tree_with_centred_modal(content_height: f32) -> (WidgetTree, crate::overlay::OverlayId) {
1306        let mut tree = WidgetTree::new();
1307        let anchor = tree.add(FillWidget::new());
1308        let content = tree.add(Sized(200.0, content_height));
1309        let id = tree.show_overlay(crate::overlay::OverlayRequest::new(
1310            content,
1311            anchor,
1312            crate::overlay::OverlayPlacement::Centered,
1313            crate::overlay::DismissBehavior::Manual,
1314        ));
1315        (tree, id)
1316    }
1317
1318    /// The supply this package exists to add: with nothing covering the window
1319    /// and no safe area, the viewport is the whole window — byte for byte the
1320    /// bare `(width, height)` tuple that used to be passed.
1321    #[test]
1322    fn a_bare_window_is_usable_to_its_last_pixel() {
1323        let (mut tree, id) = tree_with_centred_modal(100.0);
1324        tree.layout(SizeProposal::exact(400.0, 300.0));
1325        assert_eq!(tree.usable_viewport(), Rect::new(0.0, 0.0, 400.0, 300.0));
1326        let bounds = tree.overlay_content_bounds(id).expect("placed");
1327        assert_eq!(bounds.y, 100.0, "centred in 300: (300 - 100) / 2");
1328    }
1329
1330    /// A soft keyboard covering the bottom band shrinks the viewport, and the
1331    /// modal recomputes against what is left instead of centring behind it.
1332    #[test]
1333    fn an_occluding_band_shrinks_the_viewport_and_moves_the_modal() {
1334        let (mut tree, id) = tree_with_centred_modal(100.0);
1335        // The bottom 140 of a 300-tall window: a keyboard.
1336        tree.set_occluded_inset(Some(Rect::new(0.0, 160.0, 400.0, 140.0)));
1337        tree.layout(SizeProposal::exact(400.0, 300.0));
1338
1339        assert_eq!(
1340            tree.usable_viewport(),
1341            Rect::new(0.0, 0.0, 400.0, 160.0),
1342            "the largest free slab is the band above the keyboard"
1343        );
1344        let bounds = tree.overlay_content_bounds(id).expect("placed");
1345        assert_eq!(bounds.y, 30.0, "centred in 160: (160 - 100) / 2");
1346        assert!(
1347            bounds.y + bounds.height <= 160.0,
1348            "and the whole modal clears the keyboard"
1349        );
1350    }
1351
1352    /// When the content is taller than what is left, centring would push it off
1353    /// the top. It pins to the top of the usable area instead, so the first
1354    /// line stays reachable and the rest is scrolled to.
1355    #[test]
1356    fn a_modal_taller_than_the_usable_area_pins_to_its_top() {
1357        let (mut tree, id) = tree_with_centred_modal(240.0);
1358        tree.set_occluded_inset(Some(Rect::new(0.0, 160.0, 400.0, 140.0)));
1359        tree.layout(SizeProposal::exact(400.0, 300.0));
1360        let bounds = tree.overlay_content_bounds(id).expect("placed");
1361        assert_eq!(bounds.y, 0.0, "pinned to the top of the usable band");
1362    }
1363
1364    /// A safe area does the same for the reason a notch exists.
1365    #[test]
1366    fn a_safe_area_insets_the_viewport() {
1367        let (mut tree, id) = tree_with_centred_modal(100.0);
1368        tree.set_safe_area(teksilo_canvas::EdgeInsets {
1369            top: 40.0,
1370            bottom: 20.0,
1371            leading: 10.0,
1372            trailing: 10.0,
1373        });
1374        tree.layout(SizeProposal::exact(400.0, 300.0));
1375        assert_eq!(tree.usable_viewport(), Rect::new(10.0, 40.0, 380.0, 240.0));
1376        let bounds = tree.overlay_content_bounds(id).expect("placed");
1377        assert_eq!(bounds.y, 40.0 + (240.0 - 100.0) / 2.0);
1378    }
1379
1380    /// The scrim is deliberately not inset: one that respected the safe area
1381    /// would leave the notch undimmed and the content behind it legible.
1382    #[test]
1383    fn the_supply_does_not_move_the_root_layout() {
1384        // Occlusion reaches overlay placement and nothing else. A keyboard
1385        // rising must not reflow the document behind it — that is a scroll, not
1386        // a resize, and this is where the difference is decided.
1387        let mut tree = WidgetTree::new();
1388        let root = tree.add(FillWidget::new());
1389        tree.set_occluded_inset(Some(Rect::new(0.0, 160.0, 400.0, 140.0)));
1390        tree.set_safe_area(teksilo_canvas::EdgeInsets {
1391            top: 40.0,
1392            bottom: 0.0,
1393            leading: 0.0,
1394            trailing: 0.0,
1395        });
1396        tree.layout(SizeProposal::exact(400.0, 300.0));
1397        assert_eq!(
1398            tree.bounds(root),
1399            Rect::new(0.0, 0.0, 400.0, 300.0),
1400            "the root still owns the whole window"
1401        );
1402    }
1403
1404    /// A pending soft-keyboard request is recorded on the tree and taken
1405    /// exactly once, by the app layer, after its IME reconcile.
1406    #[test]
1407    fn a_soft_keyboard_request_is_taken_once() {
1408        let mut tree = WidgetTree::new();
1409        assert_eq!(tree.take_soft_keyboard_request(), None);
1410        tree.request_soft_keyboard(true);
1411        assert_eq!(tree.take_soft_keyboard_request(), Some(true));
1412        assert_eq!(
1413            tree.take_soft_keyboard_request(),
1414            None,
1415            "a request is a one-shot; a second take must not re-ask"
1416        );
1417    }
1418
1419    /// A leaf that records the bounds `place_children` hands it, and how often.
1420    #[derive(Debug, Clone, Default)]
1421    struct BoundsRecorder {
1422        seen: std::rc::Rc<std::cell::RefCell<Vec<Rect>>>,
1423    }
1424
1425    impl Widget for BoundsRecorder {
1426        fn layout_response(
1427            &self,
1428            proposal: SizeProposal,
1429            _ctx: &LayoutContext,
1430        ) -> crate::widget::LayoutResponse {
1431            Size::new(
1432                proposal.width.unwrap_or(10.0),
1433                proposal.height.unwrap_or(10.0),
1434            )
1435            .into()
1436        }
1437
1438        fn place_children(
1439            &self,
1440            bounds: Rect,
1441            _proposal: SizeProposal,
1442            children: &mut [WidgetPlacement],
1443            _ctx: &LayoutContext,
1444        ) {
1445            assert!(
1446                children.is_empty(),
1447                "a leaf must be handed an empty placements slice"
1448            );
1449            self.seen.borrow_mut().push(bounds);
1450        }
1451    }
1452
1453    /// The invariant `SceneView` (and both text engines) depend on: a widget with
1454    /// NO children still gets `place_children`, carrying its final bounds.
1455    ///
1456    /// Before this was guaranteed, the walker skipped `place_children` whenever
1457    /// there was nothing to place, so a leaf could only discover its bounds in
1458    /// `paint`. That is too late for anything the renderer consumes *before*
1459    /// paint — a `SceneView` folds `bounds.origin` into the transform scope the
1460    /// walker pushes around its subtree, so a scene holding only lightweight
1461    /// items (hence no arena children) painted its content offset by
1462    /// `-bounds.origin`, an error that scaled with zoom.
1463    #[test]
1464    fn a_childless_widget_still_receives_its_bounds() {
1465        let mut tree = WidgetTree::new();
1466        let leaf = BoundsRecorder::default();
1467        let seen = leaf.seen.clone();
1468
1469        // Nested inside an inset container, so a correct origin is non-zero and a
1470        // stale/zero origin cannot pass by accident.
1471        let leaf_id = tree.add(leaf);
1472        let _root = tree.add(InsetWidget::new(12.0).set_child(leaf_id));
1473        tree.layout(SizeProposal::exact(200.0, 100.0));
1474
1475        let bounds = seen.borrow();
1476        assert_eq!(
1477            bounds.len(),
1478            1,
1479            "the leaf must be placed exactly once per layout pass, got {bounds:?}"
1480        );
1481        assert_eq!(
1482            (bounds[0].x, bounds[0].y),
1483            (12.0, 12.0),
1484            "the leaf must receive its real, parent-assigned origin"
1485        );
1486        assert_eq!(
1487            (bounds[0].width, bounds[0].height),
1488            (176.0, 76.0),
1489            "the leaf must receive its real, parent-assigned size"
1490        );
1491    }
1492
1493    /// The same guarantee at the root: a tree whose root IS a leaf.
1494    #[test]
1495    fn a_childless_root_still_receives_its_bounds() {
1496        let mut tree = WidgetTree::new();
1497        let leaf = BoundsRecorder::default();
1498        let seen = leaf.seen.clone();
1499        let _id = tree.add(leaf);
1500        tree.layout(SizeProposal::exact(320.0, 240.0));
1501
1502        let bounds = seen.borrow();
1503        assert_eq!(bounds.len(), 1, "root leaf must be placed once");
1504        assert_eq!((bounds[0].width, bounds[0].height), (320.0, 240.0));
1505    }
1506
1507    #[derive(Debug)]
1508    struct ShrinkWrapContainer {
1509        child: WidgetId,
1510        inset: f32,
1511    }
1512
1513    impl Widget for ShrinkWrapContainer {
1514        fn layout_response(
1515            &self,
1516            _proposal: SizeProposal,
1517            ctx: &LayoutContext,
1518        ) -> crate::widget::LayoutResponse {
1519            let child_size = ctx
1520                .child_size(self.child, SizeProposal::unspecified())
1521                .unwrap_or(Size::ZERO);
1522            Size::new(
1523                child_size.width + self.inset * 2.0,
1524                child_size.height + self.inset * 2.0,
1525            )
1526            .into()
1527        }
1528
1529        fn place_children(
1530            &self,
1531            bounds: Rect,
1532            _proposal: SizeProposal,
1533            children: &mut [WidgetPlacement],
1534            _ctx: &LayoutContext,
1535        ) {
1536            for child in children.iter_mut() {
1537                child.origin = Point::new(bounds.x + self.inset, bounds.y + self.inset);
1538                child.size = Size::new(
1539                    (bounds.width - self.inset * 2.0).max(0.0),
1540                    (bounds.height - self.inset * 2.0).max(0.0),
1541                );
1542            }
1543        }
1544
1545        fn children(&self) -> Vec<WidgetId> {
1546            vec![self.child]
1547        }
1548    }
1549
1550    // ── Per-pass layout memoization cache (Part C) ──────────────────────────
1551
1552    /// A childless leaf that counts how many times `layout_response` runs and
1553    /// can opt out of caching. The driver does not recurse into a childless
1554    /// leaf's placement, so the only calls come from a parent's `child_size`
1555    /// queries — making the count a precise probe of the cache.
1556    #[derive(Debug)]
1557    struct CountingLeaf {
1558        calls: std::rc::Rc<std::cell::Cell<u32>>,
1559        cacheable: bool,
1560    }
1561
1562    impl Widget for CountingLeaf {
1563        fn layout_response(
1564            &self,
1565            _proposal: SizeProposal,
1566            _ctx: &LayoutContext,
1567        ) -> crate::widget::LayoutResponse {
1568            self.calls.set(self.calls.get() + 1);
1569            Size::new(50.0, 20.0).into()
1570        }
1571        fn cacheable_layout(&self) -> bool {
1572            self.cacheable
1573        }
1574    }
1575
1576    /// Queries its single child with the *same* proposal in both
1577    /// `layout_response` and `place_children` — the pattern real stacks use
1578    /// for height-for-width. With caching the child computes once; without it,
1579    /// twice.
1580    #[derive(Debug)]
1581    struct DoubleQueryContainer {
1582        child: WidgetId,
1583    }
1584
1585    impl Widget for DoubleQueryContainer {
1586        fn layout_response(
1587            &self,
1588            _proposal: SizeProposal,
1589            ctx: &LayoutContext,
1590        ) -> crate::widget::LayoutResponse {
1591            ctx.child_size(self.child, SizeProposal::exact(50.0, 20.0))
1592                .unwrap_or(Size::ZERO)
1593                .into()
1594        }
1595        fn place_children(
1596            &self,
1597            bounds: Rect,
1598            _proposal: SizeProposal,
1599            children: &mut [WidgetPlacement],
1600            ctx: &LayoutContext,
1601        ) {
1602            // Second query with the identical proposal.
1603            let _ = ctx.child_size(self.child, SizeProposal::exact(50.0, 20.0));
1604            for child in children.iter_mut() {
1605                child.origin = bounds.origin();
1606                child.size = bounds.size();
1607            }
1608        }
1609        fn children(&self) -> Vec<WidgetId> {
1610            vec![self.child]
1611        }
1612    }
1613
1614    #[test]
1615    fn cache_dedupes_identical_child_queries_within_a_pass() {
1616        let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1617        let mut tree = WidgetTree::new();
1618        let leaf = tree.add(CountingLeaf {
1619            calls: calls.clone(),
1620            cacheable: true,
1621        });
1622        let _root = tree.add(DoubleQueryContainer { child: leaf });
1623        tree.layout(SizeProposal::exact(100.0, 50.0));
1624        // Two identical `exact(50,20)` queries (layout_response + place_children)
1625        // collapse to one real call; the driver does not recurse into the
1626        // childless leaf.
1627        assert_eq!(calls.get(), 1, "cacheable leaf should be computed once");
1628    }
1629
1630    #[test]
1631    fn cache_opt_out_recomputes_every_query() {
1632        let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1633        let mut tree = WidgetTree::new();
1634        let leaf = tree.add(CountingLeaf {
1635            calls: calls.clone(),
1636            cacheable: false,
1637        });
1638        let _root = tree.add(DoubleQueryContainer { child: leaf });
1639        tree.layout(SizeProposal::exact(100.0, 50.0));
1640        assert_eq!(
1641            calls.get(),
1642            2,
1643            "opt-out leaf must run on every query (side effects preserved)"
1644        );
1645    }
1646
1647    #[test]
1648    fn cache_is_cleared_between_passes() {
1649        let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1650        let mut tree = WidgetTree::new();
1651        let leaf = tree.add(CountingLeaf {
1652            calls: calls.clone(),
1653            cacheable: true,
1654        });
1655        let _root = tree.add(DoubleQueryContainer { child: leaf });
1656        tree.layout(SizeProposal::exact(100.0, 50.0));
1657        // A second pass with a different proposal must re-run layout — proving
1658        // the cache is per-pass, not stale across passes (the `exact(50,20)`
1659        // child key is identical between passes).
1660        tree.layout(SizeProposal::exact(120.0, 60.0));
1661        assert_eq!(
1662            calls.get(),
1663            2,
1664            "each pass recomputes; cache cleared per pass"
1665        );
1666    }
1667
1668    // ── measure_intrinsic (Primitive 2) ─────────────────────────────────────
1669
1670    /// Probe: from its own `layout_response`, measures `target` two ways and
1671    /// stashes the results — the normal (activation-gated) query and the
1672    /// intrinsic (activation-ignoring) query.
1673    #[derive(Debug)]
1674    struct MeasureProbe {
1675        target: WidgetId,
1676        active_w: std::rc::Rc<std::cell::Cell<f32>>, // -1.0 == None
1677        intrinsic_w: std::rc::Rc<std::cell::Cell<f32>>,
1678    }
1679    impl Widget for MeasureProbe {
1680        fn layout_response(
1681            &self,
1682            p: SizeProposal,
1683            ctx: &LayoutContext,
1684        ) -> crate::widget::LayoutResponse {
1685            // Measure intrinsic FIRST, then the normal gated query: if the
1686            // measure had polluted the cache, the gated query could wrongly
1687            // return a size for the dormant target. `exact` because FillWidget
1688            // fills its proposal (it has no intrinsic size of its own).
1689            let probe = SizeProposal::exact(120.0, 30.0);
1690            let intrinsic = ctx
1691                .measure_intrinsic(self.target, probe)
1692                .map(|s| s.width)
1693                .unwrap_or(-1.0);
1694            let active = ctx
1695                .child_size(self.target, probe)
1696                .map(|s| s.width)
1697                .unwrap_or(-1.0);
1698            self.intrinsic_w.set(intrinsic);
1699            self.active_w.set(active);
1700            p.resolve(0.0, 0.0).into()
1701        }
1702        fn cacheable_layout(&self) -> bool {
1703            false
1704        }
1705    }
1706
1707    #[test]
1708    fn measure_intrinsic_sees_a_dormant_widget_normal_query_does_not() {
1709        let active = std::rc::Rc::new(std::cell::Cell::new(0.0));
1710        let intrinsic = std::rc::Rc::new(std::cell::Cell::new(0.0));
1711        let mut tree = WidgetTree::new();
1712        let leaf = tree.add(FillWidget::new());
1713        tree.set_dormant(leaf);
1714        let _probe = tree.add(MeasureProbe {
1715            target: leaf,
1716            active_w: active.clone(),
1717            intrinsic_w: intrinsic.clone(),
1718        });
1719        tree.layout(SizeProposal::exact(200.0, 50.0));
1720
1721        // measure_intrinsic measures the dormant widget (FillWidget fills the
1722        // 120px probe)…
1723        assert!(
1724            (intrinsic.get() - 120.0).abs() < 0.01,
1725            "measure_intrinsic should size the dormant widget, got {}",
1726            intrinsic.get()
1727        );
1728        // …and the normal gated query (run AFTER) still returns None — proving
1729        // the measure bypassed, and did not seed, the per-pass cache.
1730        assert_eq!(
1731            active.get(),
1732            -1.0,
1733            "child_size must stay None for a dormant widget (no cache pollution)"
1734        );
1735    }
1736
1737    /// A box whose height is driven by a signal and whose width echoes the
1738    /// proposed width (height-for-width) — models a widget (e.g. a `MessageBox`
1739    /// "Show details" expander) whose intrinsic height changes with content.
1740    /// Echoing the width lets a fixed-width intrinsic measurement be exercised.
1741    #[derive(Debug)]
1742    struct SignalBox {
1743        h: crate::signal::Signal<f32>,
1744    }
1745    impl Widget for SignalBox {
1746        fn layout_response(
1747            &self,
1748            p: SizeProposal,
1749            _ctx: &LayoutContext,
1750        ) -> crate::widget::LayoutResponse {
1751            teksilo_canvas::Size::new(p.width.unwrap_or(0.0), self.h.get()).into()
1752        }
1753        fn cacheable_layout(&self) -> bool {
1754            false
1755        }
1756    }
1757
1758    /// Sums the ACTIVE children's heights via `child_size` (which returns
1759    /// `None` for a dormant child, so a hidden child contributes nothing) —
1760    /// lets a test assert size-to-content excludes dormant subtrees.
1761    #[derive(Debug)]
1762    struct VSumBox {
1763        children: Vec<WidgetId>,
1764    }
1765    impl Widget for VSumBox {
1766        fn layout_response(
1767            &self,
1768            p: SizeProposal,
1769            ctx: &LayoutContext,
1770        ) -> crate::widget::LayoutResponse {
1771            let h: f32 = self
1772                .children
1773                .iter()
1774                .filter_map(|&c| ctx.child_size(c, p))
1775                .map(|s| s.height)
1776                .sum();
1777            teksilo_canvas::Size::new(p.width.unwrap_or(0.0), h).into()
1778        }
1779        fn children(&self) -> Vec<WidgetId> {
1780            self.children.clone()
1781        }
1782    }
1783
1784    #[test]
1785    fn measure_root_intrinsic_honors_fixed_width_and_tracks_content() {
1786        let h = crate::signal::Signal::new(140.0);
1787        let mut tree = WidgetTree::new();
1788        let _root = tree.add(SignalBox { h: h.clone() });
1789        // Lay the root out constrained to a fixed native-modal size.
1790        tree.layout(SizeProposal::exact(460.0, 140.0));
1791
1792        // Intrinsic measurement at a fixed width / unbounded height reports the
1793        // proposed width and the content's natural height — the size a
1794        // size-to-content window grows to, independent of the constrained pass.
1795        let m = tree
1796            .measure_root_intrinsic(SizeProposal {
1797                width: Some(460.0),
1798                height: None,
1799            })
1800            .expect("one active primary root");
1801        assert!(
1802            (m.width - 460.0).abs() < 0.01,
1803            "fixed width honored, got {}",
1804            m.width
1805        );
1806        assert!(
1807            (m.height - 140.0).abs() < 0.01,
1808            "natural height, got {}",
1809            m.height
1810        );
1811
1812        // A different fixed width flows through (the proposal really is used).
1813        let narrow = tree
1814            .measure_root_intrinsic(SizeProposal {
1815                width: Some(300.0),
1816                height: None,
1817            })
1818            .expect("root active");
1819        assert!(
1820            (narrow.width - 300.0).abs() < 0.01,
1821            "proposal width, got {}",
1822            narrow.width
1823        );
1824
1825        // Content growth (a "Show details" expander) is reflected.
1826        h.set(300.0);
1827        let grown = tree
1828            .measure_root_intrinsic(SizeProposal {
1829                width: Some(460.0),
1830                height: None,
1831            })
1832            .expect("root active");
1833        assert!(
1834            (grown.height - 300.0).abs() < 0.01,
1835            "grows with content, got {}",
1836            grown.height
1837        );
1838    }
1839
1840    #[test]
1841    fn measure_root_intrinsic_excludes_dormant_content() {
1842        let mut tree = WidgetTree::new();
1843        let shown = tree.add(SignalBox {
1844            h: crate::signal::Signal::new(200.0),
1845        });
1846        let hidden = tree.add(SignalBox {
1847            h: crate::signal::Signal::new(1000.0),
1848        });
1849        tree.set_dormant(hidden);
1850        let _root = tree.add(VSumBox {
1851            children: vec![shown, hidden],
1852        });
1853        tree.layout(SizeProposal::exact(460.0, 200.0));
1854
1855        // The dormant child must NOT contribute — a size-to-content window is
1856        // sized to what is actually shown. Regression guard for measuring via
1857        // `layout_response` (activation-respecting) rather than the
1858        // activation-ignoring `measure_intrinsic` (which would return 1200).
1859        let m = tree
1860            .measure_root_intrinsic(SizeProposal {
1861                width: Some(460.0),
1862                height: None,
1863            })
1864            .expect("one active primary root");
1865        assert!(
1866            (m.height - 200.0).abs() < 0.01,
1867            "dormant child must be excluded, got {}",
1868            m.height
1869        );
1870    }
1871
1872    #[test]
1873    fn single_widget_fills_proposal() {
1874        let mut tree = WidgetTree::new();
1875        let widget = tree.add(FillWidget::new().background(Color::RED));
1876        tree.layout(SizeProposal::exact(200.0, 40.0));
1877        let bounds = tree.bounds(widget);
1878        assert_eq!(bounds.width, 200.0);
1879        assert_eq!(bounds.height, 40.0);
1880    }
1881
1882    #[test]
1883    fn stack_children_overlap() {
1884        let mut tree = WidgetTree::new();
1885        let a = tree.add(FillWidget::new());
1886        let b = tree.add(FillWidget::new());
1887        let stack = tree.add(StackWidget::new().child(a).child(b));
1888        tree.layout(SizeProposal::exact(100.0, 50.0));
1889        let children = tree.children(stack);
1890        assert_eq!(children.len(), 2);
1891        let a_bounds = tree.bounds(children[0]);
1892        let b_bounds = tree.bounds(children[1]);
1893        assert_eq!(a_bounds.origin(), b_bounds.origin());
1894        assert_eq!(a_bounds.size(), b_bounds.size());
1895    }
1896
1897    #[test]
1898    fn inset_widget_insets_child() {
1899        let mut tree = WidgetTree::new();
1900        let child = tree.add(FillWidget::new());
1901        let parent = tree.add(InsetWidget::new(10.0).set_child(child));
1902        tree.layout(SizeProposal::exact(100.0, 50.0));
1903        let children = tree.children(parent);
1904        let child_bounds = tree.bounds(children[0]);
1905        assert_eq!(child_bounds.x, 10.0);
1906        assert_eq!(child_bounds.y, 10.0);
1907        assert_eq!(child_bounds.width, 80.0);
1908        assert_eq!(child_bounds.height, 30.0);
1909    }
1910
1911    #[test]
1912    fn recursive_layout_preserves_exact_parent_placement_for_containers() {
1913        let mut tree = WidgetTree::new();
1914        let leaf = tree.add(FillWidget::new());
1915        let shrink = tree.add(ShrinkWrapContainer {
1916            child: leaf,
1917            inset: 8.0,
1918        });
1919        let root = tree.add(StackWidget::new().child(shrink));
1920
1921        tree.layout(SizeProposal::exact(120.0, 80.0));
1922
1923        assert_eq!(tree.bounds(root), Rect::new(0.0, 0.0, 120.0, 80.0));
1924        assert_eq!(
1925            tree.bounds(shrink),
1926            Rect::new(0.0, 0.0, 120.0, 80.0),
1927            "child container should keep the exact size assigned by its parent"
1928        );
1929        assert_eq!(tree.bounds(leaf), Rect::new(8.0, 8.0, 104.0, 64.0));
1930    }
1931
1932    #[test]
1933    fn needs_paint_after_layout() {
1934        let mut tree = WidgetTree::new();
1935        tree.add(FillWidget::new());
1936        assert!(tree.needs_layout());
1937        tree.layout(SizeProposal::exact(100.0, 50.0));
1938        assert!(!tree.needs_layout());
1939    }
1940
1941    #[test]
1942    fn signal_binding_marks_widget_dirty_on_layout() {
1943        use crate::signal::Signal;
1944
1945        let mut tree = WidgetTree::new();
1946        let widget = tree.add(FillWidget::new().background(Color::RED));
1947        tree.layout(SizeProposal::exact(100.0, 50.0));
1948        tree.render();
1949
1950        assert!(!tree.needs_paint());
1951
1952        let visible = Signal::new(true);
1953        visible.bind_to(
1954            widget,
1955            tree.binding_registry(),
1956            crate::binding::BindingLevel::RepaintOnly,
1957        );
1958
1959        visible.set(false);
1960        tree.layout(SizeProposal::exact(100.0, 50.0));
1961        assert!(tree.needs_paint());
1962    }
1963
1964    /// `culls_children` as a framework primitive, without a scene.
1965    ///
1966    /// The mechanism is one field and two rules: a widget that opts in is
1967    /// handed every child, parked ones included, and whatever it leaves in
1968    /// `WidgetPlacement::dormant` is applied — cleared wakes in the same pass,
1969    /// set parks after it.
1970    mod culling_containers {
1971        use super::*;
1972        use crate::signal::Signal;
1973        use crate::widget::{LayoutResponse, WidgetPlacement};
1974        use crate::widget_builder::WidgetBuilder;
1975        use std::cell::Cell;
1976        use std::rc::Rc;
1977
1978        /// Parks every child whose index is set in the `park` bitmask, and
1979        /// reports how many children its `place_children` was handed.
1980        ///
1981        /// `park` is a `Signal` bound at `BindingLevel::Relayout` — the same
1982        /// level a `SceneView` binds its camera at — because that is what makes
1983        /// changing it run a pass at all: `layout_with_ops` returns early when
1984        /// the proposal is unchanged and nothing needs layout.
1985        #[derive(Debug)]
1986        struct Culler {
1987            kids: Vec<WidgetId>,
1988            park: Signal<u64>,
1989            seen: Rc<Cell<usize>>,
1990            opts_in: bool,
1991            /// Stop writing `dormant` at all, so what the framework pre-set
1992            /// there is what gets applied.
1993            ignore_dormant: Rc<Cell<bool>>,
1994            /// One per child, so "was this subtree recursed into" is
1995            /// observable and not merely inferred from the child's activation.
1996            grandkids: Rc<std::cell::RefCell<Vec<WidgetId>>>,
1997            /// How many times the culler has been asked to decide. The
1998            /// question "was the decision re-taken?" has no other witness — a
1999            /// re-run that reaches the same answer changes nothing else.
2000            passes: Rc<Cell<usize>>,
2001        }
2002
2003        impl Culler {
2004            fn new(park: Signal<u64>, seen: Rc<Cell<usize>>, opts_in: bool) -> Self {
2005                Self {
2006                    kids: Vec::new(),
2007                    park,
2008                    seen,
2009                    opts_in,
2010                    ignore_dormant: Rc::new(Cell::new(false)),
2011                    grandkids: Rc::new(std::cell::RefCell::new(Vec::new())),
2012                    passes: Rc::new(Cell::new(0)),
2013                }
2014            }
2015        }
2016
2017        impl Widget for Culler {
2018            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2019                self.park.bind_to(
2020                    ctx.self_id(),
2021                    ctx.binding_registry(),
2022                    crate::binding::BindingLevel::Relayout,
2023                );
2024                // Each child has a child of its own, so "was this subtree
2025                // recursed into" is observable from the grandchild's bounds
2026                // and not only from the child's activation.
2027                self.kids = (0..3)
2028                    .map(|i| {
2029                        let grandkid = ctx.add(FillWidget::new().label(format!("grandkid{i}")));
2030                        self.grandkids.borrow_mut().push(grandkid);
2031                        ctx.add(StackWidget::new().child(grandkid).focusable(true))
2032                    })
2033                    .collect();
2034                self.kids.clone()
2035            }
2036            fn layout_response(
2037                &self,
2038                _p: SizeProposal,
2039                _c: &crate::widget::LayoutContext,
2040            ) -> LayoutResponse {
2041                teksilo_canvas::Size::new(100.0, 100.0).into()
2042            }
2043            fn children(&self) -> Vec<WidgetId> {
2044                self.kids.clone()
2045            }
2046            fn culls_children(&self) -> bool {
2047                self.opts_in
2048            }
2049            fn place_children(
2050                &self,
2051                bounds: Rect,
2052                _proposal: SizeProposal,
2053                children: &mut [WidgetPlacement],
2054                _ctx: &crate::widget::LayoutContext,
2055            ) {
2056                self.seen.set(children.len());
2057                self.passes.set(self.passes.get() + 1);
2058                let mask = self.park.get();
2059                for placement in children.iter_mut() {
2060                    let Some(i) = self.kids.iter().position(|&k| k == placement.id) else {
2061                        continue;
2062                    };
2063                    placement.origin = teksilo_canvas::Point::new(0.0, i as f32 * 20.0);
2064                    // Width follows the proposal so a *grandchild*'s size says
2065                    // whether the recursion reached it — a child's own size is
2066                    // written here, before any cull decision, and so proves
2067                    // nothing.
2068                    placement.size = teksilo_canvas::Size::new(bounds.width, 20.0);
2069                    if !self.ignore_dormant.get() {
2070                        placement.dormant = mask & (1 << i) != 0;
2071                    }
2072                }
2073            }
2074        }
2075
2076        struct Rig {
2077            tree: WidgetTree,
2078            id: WidgetId,
2079            park: Signal<u64>,
2080            seen: Rc<Cell<usize>>,
2081            ignore_dormant: Rc<Cell<bool>>,
2082            grandkids: Rc<std::cell::RefCell<Vec<WidgetId>>>,
2083            passes: Rc<Cell<usize>>,
2084        }
2085
2086        fn tree_with(opts_in: bool) -> Rig {
2087            let park = Signal::new(0u64);
2088            let seen = Rc::new(Cell::new(0usize));
2089            let culler = Culler::new(park.clone(), seen.clone(), opts_in);
2090            let ignore_dormant = culler.ignore_dormant.clone();
2091            let grandkids = culler.grandkids.clone();
2092            let passes = culler.passes.clone();
2093            let mut tree = WidgetTree::new();
2094            let id = tree.add(culler);
2095            tree.layout(SizeProposal::exact(100.0, 100.0));
2096            Rig {
2097                tree,
2098                id,
2099                park,
2100                seen,
2101                ignore_dormant,
2102                grandkids,
2103                passes,
2104            }
2105        }
2106
2107        #[test]
2108        fn focus_moving_re_asks_the_culling_parent_that_owns_it() {
2109            // A culling parent is told to keep what the user is in the middle
2110            // of, and it is only asked during layout — but focus moving from
2111            // one child to another moves nothing and resizes nothing, so the
2112            // idle early-return would skip the pass and leave the parent
2113            // answering with a stale anchor set. It would go on pinning the
2114            // child the user has left, and never pin the one they arrived in.
2115            //
2116            // Harmless while pinning only keeps a child *alive* — a newly
2117            // focused child is active by definition. Not harmless for a parent
2118            // that publishes a narrower set than it keeps alive, as
2119            // `SceneView` does: the published tree names its focused node, and
2120            // a focus the walk did not emit is a broken update.
2121            let Rig {
2122                mut tree,
2123                id,
2124                passes,
2125                ..
2126            } = tree_with(true);
2127            let kids = tree.children(id).to_vec();
2128
2129            let before = passes.get();
2130            tree.layout(SizeProposal::exact(100.0, 100.0));
2131            assert_eq!(
2132                passes.get(),
2133                before,
2134                "precondition: a settled tree skips the pass entirely"
2135            );
2136
2137            tree.focus(kids[0]);
2138            tree.layout(SizeProposal::exact(100.0, 100.0));
2139            assert_eq!(
2140                passes.get(),
2141                before + 1,
2142                "focus arriving is a change to the culler's inputs"
2143            );
2144
2145            tree.focus(kids[2]);
2146            tree.layout(SizeProposal::exact(100.0, 100.0));
2147            assert_eq!(
2148                passes.get(),
2149                before + 2,
2150                "…and so is focus moving between two of its children"
2151            );
2152
2153            tree.layout(SizeProposal::exact(100.0, 100.0));
2154            assert_eq!(
2155                passes.get(),
2156                before + 2,
2157                "…while a pass with the same anchors is still skipped"
2158            );
2159        }
2160
2161        #[test]
2162        fn focus_moving_forces_no_pass_where_nothing_culls() {
2163            // The scoping half. The invalidation walks up from the moved
2164            // anchor and marks only `culls_children` ancestors, so a tree
2165            // without one pays a parent-chain walk and forces no layout — the
2166            // cost lands on the trees that asked for the mechanism.
2167            let Rig {
2168                mut tree,
2169                id,
2170                passes,
2171                ..
2172            } = tree_with(false);
2173            let kids = tree.children(id).to_vec();
2174            let before = passes.get();
2175
2176            tree.focus(kids[0]);
2177            tree.layout(SizeProposal::exact(100.0, 100.0));
2178            tree.focus(kids[2]);
2179            tree.layout(SizeProposal::exact(100.0, 100.0));
2180
2181            assert_eq!(
2182                passes.get(),
2183                before,
2184                "no culling ancestor, no reason to re-run layout"
2185            );
2186        }
2187
2188        #[test]
2189        fn a_parked_child_leaves_the_tab_ring_and_the_at_tree_in_the_same_pass() {
2190            let Rig {
2191                mut tree, id, park, ..
2192            } = tree_with(true);
2193            let kids = tree.children(id).to_vec();
2194            assert_eq!(tree.tab_stops_within(id).len(), 3);
2195            let before = tree.accessibility_tree_snapshot().nodes.len();
2196
2197            park.set(0b010);
2198            tree.layout(SizeProposal::exact(100.0, 100.0));
2199
2200            assert!(!tree.is_active(kids[1]), "parked in the pass that asked");
2201            assert!(tree.is_active(kids[0]) && tree.is_active(kids[2]));
2202            let stops = tree.tab_stops_within(id);
2203            assert_eq!(stops.len(), 2, "stops = {stops:?}");
2204            assert!(!stops.contains(&kids[1]));
2205            assert_eq!(
2206                tree.accessibility_tree_snapshot().nodes.len(),
2207                before - 2,
2208                "the parked child AND its own child leave the AccessKit tree — \
2209                 parking is a subtree operation"
2210            );
2211        }
2212
2213        #[test]
2214        fn a_parked_child_takes_its_subtree_out_of_the_layout_recursion() {
2215            // The activation flag alone does not prove this: a child could be
2216            // dormant and still have been recursed into. Watch the grandchild's
2217            // bounds, which only the recursion writes.
2218            let Rig {
2219                mut tree,
2220                park,
2221                grandkids,
2222                ..
2223            } = tree_with(true);
2224            let grandkid = grandkids.borrow()[1];
2225            assert_eq!(tree.bounds(grandkid).size(), Size::new(100.0, 20.0));
2226
2227            park.set(0b010);
2228            tree.layout(SizeProposal::exact(100.0, 300.0));
2229            let parked_bounds = tree.bounds(grandkid);
2230
2231            // Change the geometry the recursion would have written, and check
2232            // it does not reach the parked subtree.
2233            park.set(0b010);
2234            tree.layout(SizeProposal::exact(100.0, 300.0));
2235            assert_eq!(
2236                tree.bounds(grandkid),
2237                parked_bounds,
2238                "nothing below a parked child is laid out"
2239            );
2240
2241            park.set(0);
2242            tree.layout(SizeProposal::exact(100.0, 300.0));
2243            assert_eq!(
2244                tree.bounds(grandkid).size(),
2245                Size::new(100.0, 20.0),
2246                "…and the recursion resumes when it wakes"
2247            );
2248        }
2249
2250        #[test]
2251        fn a_parent_that_leaves_dormant_alone_changes_nothing() {
2252            // `dormant` arrives pre-set to the child's current state, so a
2253            // culling parent that does not write it keeps whatever it decided
2254            // last time. Without that, every pass in which the parent declines
2255            // to answer would silently wake everything it had parked.
2256            let Rig {
2257                mut tree,
2258                park,
2259                ignore_dormant,
2260                id,
2261                ..
2262            } = tree_with(true);
2263            let kids = tree.children(id).to_vec();
2264
2265            park.set(0b101);
2266            tree.layout(SizeProposal::exact(100.0, 100.0));
2267            assert!(!tree.is_active(kids[0]) && !tree.is_active(kids[2]));
2268
2269            ignore_dormant.set(true);
2270            park.set(0);
2271            tree.layout(SizeProposal::exact(100.0, 100.0));
2272            assert!(
2273                !tree.is_active(kids[0]) && !tree.is_active(kids[2]),
2274                "a parent that ignores the field must not resurrect anything"
2275            );
2276            assert!(tree.is_active(kids[1]), "…nor park anything");
2277        }
2278
2279        #[test]
2280        fn a_woken_child_is_laid_out_in_the_same_pass() {
2281            // The half a signal-based gate cannot do: a gate written during
2282            // layout is not read until the next pass, so the woken child would
2283            // paint at a stale rectangle for a frame.
2284            let Rig {
2285                mut tree, id, park, ..
2286            } = tree_with(true);
2287            let kids = tree.children(id).to_vec();
2288
2289            park.set(0b100);
2290            tree.layout(SizeProposal::exact(100.0, 100.0));
2291            assert!(!tree.is_active(kids[2]));
2292
2293            park.set(0);
2294            tree.layout(SizeProposal::exact(100.0, 100.0));
2295            assert!(tree.is_active(kids[2]), "woken");
2296            assert_eq!(
2297                tree.bounds(kids[2]),
2298                Rect::new(0.0, 40.0, 100.0, 20.0),
2299                "…and laid out this pass, not the next"
2300            );
2301        }
2302
2303        #[test]
2304        fn a_culling_parent_is_handed_its_parked_children() {
2305            // Otherwise it could never ask one back, having parked it.
2306            let Rig {
2307                mut tree,
2308                park,
2309                seen,
2310                ..
2311            } = tree_with(true);
2312            assert_eq!(seen.get(), 3);
2313            park.set(0b111);
2314            tree.layout(SizeProposal::exact(100.0, 100.0));
2315            park.set(0b111);
2316            tree.layout(SizeProposal::exact(100.0, 100.0));
2317            assert_eq!(
2318                seen.get(),
2319                3,
2320                "all three are parked, and all three are still offered"
2321            );
2322        }
2323
2324        #[test]
2325        fn a_widget_that_did_not_opt_in_cannot_park_anything() {
2326            // `dormant` is documented as read only for a `culls_children`
2327            // parent. A widget that writes it anyway must change nothing —
2328            // otherwise the field is a trapdoor on every container in the
2329            // framework.
2330            let Rig {
2331                mut tree,
2332                id,
2333                park,
2334                seen,
2335                grandkids,
2336                ..
2337            } = tree_with(false);
2338            let kids = tree.children(id).to_vec();
2339            park.set(0b111);
2340            tree.layout(SizeProposal::exact(100.0, 100.0));
2341            assert_eq!(seen.get(), 3, "active children only, which is all of them");
2342            for kid in &kids {
2343                assert!(tree.is_active(*kid), "{kid:?} must stay active");
2344            }
2345            assert_eq!(tree.tab_stops_within(id).len(), 3);
2346            // Widen the view so a subtree that was recursed into changes size.
2347            tree.layout(SizeProposal::exact(200.0, 100.0));
2348            for grandkid in grandkids.borrow().iter() {
2349                assert_eq!(
2350                    tree.bounds(*grandkid).size(),
2351                    Size::new(200.0, 20.0),
2352                    "…and its subtree must still be laid out: reading `dormant` \
2353                     from a widget that did not opt in would take the subtree \
2354                     out of the recursion",
2355                );
2356            }
2357        }
2358
2359        #[test]
2360        fn parking_the_focused_child_does_not_leave_focus_on_a_dormant_node() {
2361            // The backstop behind the pin. A culling parent is expected to keep
2362            // what the user is using, but if it parks it anyway, focus must not
2363            // survive the pass pointing at a node dispatch will refuse — a
2364            // keystroke into the void is worse than a focus loss the user can
2365            // see.
2366            let Rig {
2367                mut tree, id, park, ..
2368            } = tree_with(true);
2369            let kids = tree.children(id).to_vec();
2370            tree.focus(kids[0]);
2371            assert_eq!(tree.focused(), Some(kids[0]));
2372
2373            park.set(0b001);
2374            tree.layout(SizeProposal::exact(100.0, 100.0));
2375
2376            assert!(!tree.is_active(kids[0]));
2377            assert_eq!(
2378                tree.focused(),
2379                None,
2380                "focus must not point into a subtree this pass parked"
2381            );
2382            let _ = id;
2383        }
2384
2385        /// Parks the children whose bit is set in a mask no binding watches,
2386        /// so what runs its pass is whatever else needed one: here a child's
2387        /// own scroll handler, which writes the mask.
2388        #[derive(Debug)]
2389        struct ScrollCuller {
2390            kids: Vec<WidgetId>,
2391            park: Rc<Cell<u64>>,
2392        }
2393
2394        impl Widget for ScrollCuller {
2395            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2396                self.kids = (0..3)
2397                    .map(|i| {
2398                        let park = self.park.clone();
2399                        ctx.add(
2400                            FillWidget::new()
2401                                .background(teksilo_tokens::Color::RED)
2402                                .corner_radius(teksilo_tokens::CornerRadius::uniform(2.0))
2403                                .on_scroll(move |_event, _ctx| {
2404                                    park.set(1 << i);
2405                                    crate::event::EventResponse::Handled
2406                                }),
2407                        )
2408                    })
2409                    .collect();
2410                self.kids.clone()
2411            }
2412            fn layout_response(
2413                &self,
2414                _p: SizeProposal,
2415                _c: &crate::widget::LayoutContext,
2416            ) -> LayoutResponse {
2417                teksilo_canvas::Size::new(100.0, 60.0).into()
2418            }
2419            fn children(&self) -> Vec<WidgetId> {
2420                self.kids.clone()
2421            }
2422            fn culls_children(&self) -> bool {
2423                true
2424            }
2425            fn place_children(
2426                &self,
2427                bounds: Rect,
2428                _proposal: SizeProposal,
2429                children: &mut [WidgetPlacement],
2430                _ctx: &crate::widget::LayoutContext,
2431            ) {
2432                let mask = self.park.get();
2433                for placement in children.iter_mut() {
2434                    let Some(i) = self.kids.iter().position(|&k| k == placement.id) else {
2435                        continue;
2436                    };
2437                    placement.origin = teksilo_canvas::Point::new(0.0, i as f32 * 20.0);
2438                    placement.size = teksilo_canvas::Size::new(bounds.width, 20.0);
2439                    placement.dormant = mask & (1 << i) != 0;
2440                }
2441            }
2442        }
2443
2444        /// The rows the frame draws, by their top edge.
2445        fn drawn_rows(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
2446            frame.shapes.iter().map(|shape| shape.screen[1]).collect()
2447        }
2448
2449        #[test]
2450        fn a_child_parked_in_a_pass_that_marked_nothing_for_paint_leaves_the_frame() {
2451            // A handled scroll marks its target for layout and nothing else.
2452            // When the culling parent then parks that target, the pass ends
2453            // with no active node marked for paint, which is the condition
2454            // under which a render replays the frame it cached: the parked
2455            // child's drawing with it, and a live picture's texture kept for
2456            // a widget nothing shows.
2457            let park = Rc::new(Cell::new(0));
2458            let mut tree = WidgetTree::new();
2459            let id = tree.add(ScrollCuller {
2460                kids: Vec::new(),
2461                park: park.clone(),
2462            });
2463            tree.layout(SizeProposal::exact(100.0, 60.0));
2464            assert_eq!(drawn_rows(&tree.render()), vec![0.0, 20.0, 40.0]);
2465            let kids = tree.children(id).to_vec();
2466
2467            let scroll_over = |tree: &mut WidgetTree, y: f32| {
2468                tree.dispatch_event(WidgetEvent::scroll_at(
2469                    crate::event::ScrollDelta::Lines { x: 0.0, y: -1.0 },
2470                    crate::event::Modifiers::NONE,
2471                    teksilo_canvas::Point::new(50.0, y),
2472                ));
2473                tree.layout(SizeProposal::exact(100.0, 60.0));
2474            };
2475            scroll_over(&mut tree, 30.0);
2476            assert!(!tree.is_active(kids[1]), "the culler parked the middle row");
2477            assert_eq!(
2478                drawn_rows(&tree.render()),
2479                vec![0.0, 40.0],
2480                "a parked child is not drawn, whatever marked the pass"
2481            );
2482
2483            // And back: the first row's handler clears the middle row's bit
2484            // and parks its own.
2485            scroll_over(&mut tree, 10.0);
2486            assert!(tree.is_active(kids[1]) && !tree.is_active(kids[0]));
2487            assert_eq!(drawn_rows(&tree.render()), vec![20.0, 40.0]);
2488        }
2489    }
2490}