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