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