Skip to main content

teksilo_core/widget_tree/
layout_impl.rs

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