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}