Skip to main content

teksilo_core/widget_tree/
layout_impl.rs

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