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