teksilo_core/widget_tree/layout_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6impl WidgetTree {
7 /// Process dirty state bindings: mark bound widgets for repaint, relayout,
8 /// or rebuild. Called automatically at the start of layout().
9 pub(super) fn process_state_changes(&mut self, ops: &mut dyn crate::window::WindowOps) {
10 // Refresh the node-resident `effective_enabled_signal`s FIRST, so a
11 // widget bound to one is dirty-marked in time for the binding flush
12 // immediately below to drain it in this same pass, rather than a frame
13 // late. This is also where a signal seeded during `build()` — when the
14 // widget's parent was not yet wired, so the seed could only see its own
15 // `enabled` prop — is corrected against the now-complete tree.
16 self.flush_effective_enabled_signals();
17
18 // One unified flush: both visual buckets and the a11y flag
19 // are drained from the same walk, so a signal bound at both
20 // a visual level and `AccessibilityOnly` (e.g. a Button's
21 // `label` re-registers the same Signal at RepaintOnly
22 // *and* AccessibilityOnly) flips both. Two separate flushes
23 // would each advance this registry's last-seen generation for
24 // that source, so the second would find nothing to report.
25 let (dirty_widgets, a11y_binding_dirty) = self.binding_registry.flush_all_dirty();
26 for (id, level) in &dirty_widgets {
27 match level {
28 crate::binding::BindingLevel::RepaintOnly => {
29 self.arena.mark_needs_paint(*id);
30 }
31 crate::binding::BindingLevel::SubtreeRepaint => {
32 // Used by `enabled_when` so the leaves in the
33 // disabled subtree re-resolve their role colors
34 // via the paint walker's `effective_enabled`.
35 // No layout work — geometry is unchanged.
36 self.arena.mark_subtree_needs_paint(*id);
37 }
38 crate::binding::BindingLevel::Relayout => {
39 self.arena.mark_needs_layout(*id);
40 self.arena.mark_ancestors_need_layout(*id);
41 }
42 crate::binding::BindingLevel::Rebuild => {
43 self.arena.mark_needs_rebuild(*id);
44 self.arena.mark_ancestors_need_layout(*id);
45 }
46 crate::binding::BindingLevel::AccessibilityOnly => {
47 // Drained into the boolean below — never appears in
48 // the visual map, but kept in the match so a future
49 // variant addition is a compile-time reminder.
50 }
51 }
52 }
53
54 // Orthogonal to the visual dirty pass: if any signal bound at
55 // `BindingLevel::AccessibilityOnly` fired, flip the tree-wide
56 // `a11y_dirty` flag so the next `sync_accessibility` rebuilds
57 // the AccessKit tree. Decoupled from layout / paint so a text
58 // edit that changes no visual geometry still reaches screen
59 // readers within one frame.
60 if a11y_binding_dirty {
61 self.a11y_dirty = true;
62 }
63
64 // Rebuild data-driven widgets whose data model changed.
65 self.process_pending_rebuilds(&mut *ops);
66
67 let mut to_dormant = Vec::new();
68 let mut to_activate = Vec::new();
69 for (id, is_active, should_be_visible) in self.arena.visibility_checks_iter() {
70 if is_active && !should_be_visible {
71 to_dormant.push(id);
72 } else if !is_active && should_be_visible {
73 // Only wake a `visible_when(true)` node whose parent is active.
74 // A gated node inside a dormant ancestor (e.g. a row in a
75 // closed popover / overflow menu) must NOT escape that
76 // ancestor's dormancy and render on its own. When the ancestor
77 // is later activated, `arena.activate` wakes this node via the
78 // cascade (its gate is true). The dormancy invariant — an
79 // active node has an active parent — makes the immediate-parent
80 // check sufficient.
81 let parent_active = self
82 .arena
83 .parent(id)
84 .map(|p| self.arena.is_active(p))
85 .unwrap_or(true);
86 if parent_active {
87 to_activate.push(id);
88 }
89 }
90 }
91 // The accessibility walk skips dormant nodes, so any
92 // active↔dormant transition changes the AccessKit tree shape
93 // and must dirty the cached snapshot. Other Relayout-causing
94 // signal flips (e.g. a Switcher visibility binding that doesn't
95 // straddle activation, an opacity change, a text-width change)
96 // do not change the AT tree — the unconditional `a11y_dirty = true`
97 // was removed from `layout()` and is now set only by events that
98 // actually change the AT tree shape.
99 if !to_dormant.is_empty() || !to_activate.is_empty() {
100 self.a11y_dirty = true;
101 }
102 for id in to_dormant {
103 self.arena.set_dormant(id);
104 }
105 for id in to_activate {
106 self.arena.activate(id);
107 }
108 // Fire activation_signal observers (e.g. a WebView's set_visible
109 // bridge) after the whole visibility pass has committed — not from
110 // inside the set_dormant/activate recursion above.
111 self.flush_activation_signals();
112
113 // Reclaim binding groups nothing points at any more. Deliberately
114 // last: `unregister_for_widget` leaves emptied groups in place so
115 // that a rebuild — which is unregister-then-re-register — keeps
116 // the group's `last_seen` ledger and cannot swallow a write its
117 // own `build()` made before re-binding. By here every rebuild in
118 // this pass has re-registered, so anything still empty belongs to
119 // a widget that is genuinely gone.
120 self.binding_registry.reclaim_empty_groups();
121 }
122
123 /// Dismiss any active overlay whose content widget is no longer
124 /// alive in the arena. An overlay's owner can be torn down
125 /// out-of-band: a data-driven rebuild destroys the widget that
126 /// showed it (clicking "mark all read" inside a notification popover
127 /// rebuilds the bell that owns the overlay; closing a document tears
128 /// down a still-open inline popover). The content then disappears
129 /// visually, but the overlay ENTRY survives in the manager and keeps
130 /// intercepting clicks (the click-outside scrim) until the user
131 /// clicks elsewhere. This GC removes such orphans immediately (no
132 /// fade — the content is already gone). A normally-open overlay's
133 /// content stays active (gated `true`), so it is never touched.
134 pub(super) fn gc_orphaned_overlays(&mut self) {
135 let orphaned: Vec<crate::overlay::OverlayId> = self
136 .overlay_manager
137 .active_ids()
138 .into_iter()
139 .filter(|&id| {
140 self.overlay_manager
141 .overlay(id)
142 .map(|o| !self.arena.is_active(o.content_id))
143 .unwrap_or(false)
144 })
145 .collect();
146 for id in orphaned {
147 self.overlay_manager.dismiss_immediate(id);
148 }
149 }
150
151 /// Drain any widgets flagged `needs_rebuild` that are currently
152 /// active + have built children. Called from
153 /// `process_state_changes` after dirty bindings have been
154 /// flushed, and again after overlay / tooltip activation so that
155 /// widgets transitioning from dormant → active in the same
156 /// layout pass get rebuilt *this* frame rather than the next.
157 pub(super) fn process_pending_rebuilds(&mut self, ops: &mut dyn crate::window::WindowOps) {
158 // Defer *selected* rebuilds while a pointer capture is held:
159 // from `PointerDown` (which stores the press position in the
160 // captured widget's arena) until `PointerUp`. Rebuilding the
161 // captured widget, or any of its ancestors, would destroy that
162 // arena and lose the press state — the recognizer would never
163 // fire.
164 //
165 // The window really does last the whole gesture, NOT just up to
166 // `DragStarted`: a gesture drag auto-captures on `DragStarted`
167 // (`gesture_dispatch_impl`) and holds until `DragEnded`, and the
168 // only thing that lifts this filter is `active_drag`, which is
169 // the drag-and-DROP session set by `start_drag` — never a
170 // scrollbar thumb. So a widget holding a live gesture must not
171 // be a descendant of anything that rebuilds on data or scroll
172 // changes, or that rebuild is silently dropped until release.
173 //
174 // Rebuilds targeting widgets *outside* the captured widget's
175 // ancestor chain are safe: destroying sibling subtrees leaves
176 // the captured widget intact, so ongoing drags keep routing
177 // correctly. That is exactly why all five virtualized views
178 // (`ListView`, `TreeView`, `TableView`, `TreeTableView`,
179 // `GridView`) hoist their rows into a body pane that is a
180 // *sibling* of their scrollbar rather than realizing rows on
181 // the view root — see `common::thumb_drag_test` in
182 // `teksilo-widgets`, which asserts it for each of them.
183 //
184 // Once `active_drag` is set, the framework routes PointerMove /
185 // PointerUp via `handle_drag_move` / `handle_drag_drop` keyed
186 // on the `DragSession`, not on the captured widget's arena —
187 // so a mid-drag rebuild is safe regardless of topology. Post-
188 // rebuild, `revalidate_interaction_state` clears a now-stale
189 // `pointer_captured_by`; subsequent events hit-test normally.
190 let to_rebuild_all = self.arena.collect_needs_rebuild();
191 if to_rebuild_all.is_empty() {
192 self.revalidate_interaction_state(&mut *ops);
193 return;
194 }
195 let captured_ancestors: Option<Vec<WidgetId>> = if self.active_drag.is_none() {
196 self.pointer_captured_by.map(|cap| {
197 let mut ids = vec![cap];
198 let mut cur = self.arena.parent(cap);
199 while let Some(id) = cur {
200 ids.push(id);
201 cur = self.arena.parent(id);
202 }
203 ids
204 })
205 } else {
206 None
207 };
208 let to_rebuild: Vec<WidgetId> = match &captured_ancestors {
209 Some(chain) => to_rebuild_all
210 .into_iter()
211 .filter(|id| !chain.contains(id))
212 .collect(),
213 None => to_rebuild_all,
214 };
215 if to_rebuild.is_empty() {
216 self.revalidate_interaction_state(&mut *ops);
217 return;
218 }
219 // Does focus live inside a subtree we are about to rebuild? Its children
220 // are about to be destroyed and re-allocated with fresh ids, taking the
221 // focused node with them — and once that has happened there is no way
222 // back from the dead id to the subtree it belonged to. Work it out now.
223 let focus_owner: Option<WidgetId> = self.focused.and_then(|focused| {
224 let depth = |id: WidgetId| -> usize {
225 let mut d = 0;
226 let mut cur = id;
227 while let Some(parent) = self.arena.parent(cur) {
228 d += 1;
229 cur = parent;
230 }
231 d
232 };
233 // Every root containing `focused` sits on its ancestor chain, so the
234 // candidates are totally ordered by depth. Take the OUTERMOST: it is
235 // the only one sure to survive, since a rebuild destroys its children
236 // — an inner rebuild root nested inside an outer one is torn down by
237 // the outer's rebuild, and its id would be dead by restore time.
238 to_rebuild
239 .iter()
240 .copied()
241 .filter(|&root| self.is_descendant_of(focused, root))
242 .min_by_key(|&root| depth(root))
243 });
244
245 for widget_id in to_rebuild {
246 self.rebuild_single_widget(widget_id);
247 }
248 // A rebuild destroys old child subtrees and allocates fresh
249 // WidgetIds, so the AccessKit tree shape changed — dirty the cached
250 // snapshot so the next `sync_accessibility` re-walks. This is the one
251 // place every `BindingLevel::Rebuild` consumer converges (data-view
252 // model updates via the binding registry AND `with_widget_mut(Rebuild)`
253 // via `apply_tree_mutations`, both draining the same `needs_rebuild`
254 // arena flag) — without this, an ordinary `ListModel::push()` leaves
255 // screen readers on a stale tree indefinitely.
256 self.a11y_dirty = true;
257 // Rebuild destroys old child subtrees and allocates fresh WidgetIds;
258 // drop any focus/hover state whose target is no longer valid so we
259 // don't dispatch to dead widgets on the next event.
260 self.revalidate_interaction_state(&mut *ops);
261 // ...but "no longer valid" must not mean "gone". If focus lived in the
262 // subtree we just rebuilt, the drop above kicked the user clean out of
263 // the widget they were in: a popover that re-scans its content when it
264 // opens throws away the row the popover itself had just focused, and the
265 // menu comes up with nothing focused — no arrow keys, no Enter. Put focus
266 // back inside that subtree, at the end of the layout pass (the fresh
267 // children have no bounds yet, and the focus-driven scroll-into-view
268 // needs them). A rebuild that never held focus, or one whose focused node
269 // survived it (the rebuild root itself is not destroyed), records nothing.
270 if self.focused.is_none()
271 && let Some(root) = focus_owner
272 {
273 self.pending_focus_restore = Some(root);
274 }
275 // A rebuild's `build()` may arm new animations (looping or
276 // one-shot) by calling `signal.animate_to(...)` /
277 // `animate_looping(...)` — these set `pending` on the signal
278 // but don't enter the scheduler until `process_pending_animations`
279 // runs again. The early-frame `process_pending_animations`
280 // (`layout_impl::layout_with_ops`) already ran *before* this
281 // rebuild, so without this second drain the animation would
282 // wait for the next frame; if the rebuild also cancelled
283 // existing scheduler entries (`cancel_by_widget` is called by
284 // `rebuild_single_widget`), the scheduler ends up empty, no
285 // frame deadline is set, and the freshly-armed animation
286 // *never* gets picked up — the user sees animations freeze
287 // after any state-driven rebuild that re-arms them
288 // (e.g. SceneView's drag-end rebuild re-arming PulsingDot
289 // loopers via `register_bindings`).
290 self.process_pending_animations();
291 }
292
293 /// Run the layout pass with the given size proposal, using
294 /// [`NoopWindowOps`](crate::window::NoopWindowOps). Handlers
295 /// triggered from drag_tick / tooltip activation / etc cannot
296 /// call `ctx.open_window(...)` from this path.
297 ///
298 /// `teksilo-app` calls [`layout_with_ops`](Self::layout_with_ops)
299 /// with a real sink so those handlers can open windows.
300 pub fn layout(&mut self, proposal: SizeProposal) {
301 let mut noop = crate::window::NoopWindowOps;
302 self.layout_with_ops(proposal, &mut noop);
303 }
304
305 /// Measure the intrinsic size of the primary (non-overlay) content root(s)
306 /// at `proposal` — e.g. `{ width: Some(w), height: None }` for the natural
307 /// height at a fixed width. Mirrors the overlay intrinsic pass below: it
308 /// calls the root's `layout_response` *directly* — NOT the
309 /// activation-ignoring `WidgetArena::measure_intrinsic` — so a
310 /// `visible_when(false)` / parked-`Switcher` descendant is excluded exactly
311 /// as the real layout excludes it. A size-to-content window is therefore
312 /// sized to what is actually shown. Computes sizes only (never writes
313 /// bounds), so it is safe to call right after a layout pass.
314 ///
315 /// Drives size-to-content windows (see
316 /// [`WindowConfig::size_to_content`](crate::window::WindowConfig::size_to_content)):
317 /// the native-window path has no in-tree overlay to size to content, so
318 /// `teksilo-app` measures the root here and resizes the OS window to fit.
319 /// Returns `None` if there is no active primary root; with more than one
320 /// active primary root the per-axis maximum is returned (size-to-content is
321 /// intended for single-primary-root windows).
322 pub fn measure_root_intrinsic(&self, proposal: SizeProposal) -> Option<teksilo_canvas::Size> {
323 let overlay_content_ids = self.overlay_manager.active_content_ids();
324 let base_theme = self.effective_theme.clone();
325 let mut result: Option<teksilo_canvas::Size> = None;
326 for root_id in self.arena.roots() {
327 if overlay_content_ids.contains(&root_id) || !self.arena.is_active(root_id) {
328 continue;
329 }
330 let resolved_theme = self.arena.resolve_theme(root_id, &base_theme);
331 let extras = crate::widget::LayoutExtras {
332 focused: self.focused,
333 shortcut_registry: Some(&self.shortcut_registry),
334 overlay_manager: Some(&self.overlay_manager),
335 };
336 let ctx = LayoutContext {
337 theme: &resolved_theme,
338 layout_direction: self.layout_direction,
339 scale_factor: self.device_scale_factor,
340 text_scale: self.effective_text_scale,
341 text_backend: self.text_backend.as_ref(),
342 arena: Some(&self.arena),
343 extras: Some(extras),
344 stack_main_axis: None,
345 };
346 let Some(node) = self.arena.get(root_id) else {
347 continue;
348 };
349 // Direct `layout_response` (activation-respecting), like the overlay
350 // pass — dormant descendants fall out via `child_size` returning
351 // `None`, so we measure only what is actually shown.
352 let size = node.widget.layout_response(proposal, &ctx).size;
353 result = Some(match result {
354 Some(acc) => teksilo_canvas::Size::new(
355 acc.width.max(size.width),
356 acc.height.max(size.height),
357 ),
358 None => size,
359 });
360 }
361 result
362 }
363
364 /// Run the layout pass with the given size proposal, threading
365 /// the app's [`WindowOps`](crate::window::WindowOps) sink
366 /// through to drag_tick / tooltip / delayed-overlay handlers.
367 pub fn layout_with_ops(
368 &mut self,
369 proposal: SizeProposal,
370 ops: &mut dyn crate::window::WindowOps,
371 ) {
372 self.process_pending_animations();
373
374 let now = std::time::Instant::now();
375 // Deadline-driven wake-up: if a widget requested a future
376 // frame via `wake_at_handle()` and that deadline is now past,
377 // arm the frame tick so its effect runs on this layout pass.
378 // Used by the rich text editor's caret blink to avoid
379 // keeping winit in Poll mode.
380 if let Some(deadline) = self.pending_wake_at.get()
381 && deadline <= now
382 {
383 self.pending_wake_at.set(None);
384 self.frame_tick_requested.set(true);
385 }
386 self.advance_frame_tick(now);
387 self.animation_scheduler
388 .tick(now, &self.arena, self.paint_epoch);
389
390 // Fire on_drag_tick on the current drop target, if any. Runs once
391 // per layout pass so widgets can implement per-frame behaviours
392 // (viewport-edge auto-scroll, spring-loaded folders) without
393 // depending on pointer events — crucial when the user holds the
394 // cursor still at the edge or over a collapsed branch.
395 self.process_drag_tick(&mut *ops);
396
397 self.process_state_changes(&mut *ops);
398 // A drag owns the pointer. `handle_pointer_move` is short-circuited for
399 // the duration, so a dwell armed just before the drag started would sit
400 // frozen at its hover origin and then mature here — popping a tooltip
401 // over the drag. Keep the timers cleared instead of letting them ripen.
402 if self.active_drag.is_some() {
403 self.tooltip_cancel_pending_dwell();
404 }
405 self.process_tooltips_real();
406 self.process_delayed_overlays_real(&mut *ops);
407 self.process_pointer_leave_overlays_real(&mut *ops);
408 self.process_auto_dismiss_overlays_real(&mut *ops);
409 self.process_overlay_fade_dismissals_real(&mut *ops);
410 // The show paths above may arm a fade animation via
411 // `attach_overlay_fade` (plain tooltips, delayed overlays).
412 // That sets `pending` on the opacity signal but does NOT
413 // register the animation with the scheduler — registration
414 // happens via `process_pending_animations`, which already ran
415 // earlier in this layout pass. Without a second drain here,
416 // the fade only enters the scheduler on the *next* layout
417 // pass, and for surfaces with no further wake source (plain
418 // tooltips, no dwell timer) `next_deadline` returns `None`
419 // and the event loop sleeps with the fade stuck at opacity 0
420 // — the tooltip is "shown" but invisible until an unrelated
421 // input event forces another layout pass.
422 self.process_pending_animations();
423 // Overlay / tooltip activation may have flipped widgets from
424 // dormant → active; if any of those had `needs_rebuild`
425 // pending (e.g. a shortcut rebind happened while the tooltip
426 // was hidden), drain them now so the freshly-visible surface
427 // shows fresh content in the *same* layout pass rather than
428 // waiting for another paint-triggering event.
429 self.process_pending_rebuilds(&mut *ops);
430
431 // Now that any data-driven rebuilds have torn down their old
432 // subtrees, drop any overlay whose content was destroyed out-of-
433 // band (e.g. clicking "mark all read" inside a notification
434 // popover rebuilds the bell that owns it). Without this the
435 // overlay lingers as an invisible click-blocker. Runs before the
436 // early-return so it takes effect even on otherwise-idle passes.
437 self.gc_orphaned_overlays();
438
439 self.arena.refresh_roots();
440
441 let proposal_changed = self.last_proposal != proposal;
442 self.last_proposal = proposal;
443
444 if !proposal_changed && !self.arena.any_needs_layout() {
445 return;
446 }
447
448 // Per-pass layout memoization: a widget's `layout_response` is a pure
449 // function of (state, proposal) within a pass, so memoizing across the
450 // main-then-cross queries that height-for-width negotiation issues keeps
451 // the pass O(n). Cleared here — once, dominating both the main-tree and
452 // overlay root recursions below — because geometry may change between
453 // passes. See `WidgetArena::cached_layout_response`.
454 self.arena.clear_layout_cache();
455
456 // `effective_theme` carries the user/OS text-scale multiplier baked into
457 // its typography, so every text widget measures at the scaled size.
458 let base_theme = self.effective_theme.clone();
459
460 let overlay_content_ids = self.overlay_manager.active_content_ids();
461 let roots: Vec<WidgetId> = self.arena.roots();
462 let focused = self.focused;
463 for root_id in roots {
464 if overlay_content_ids.contains(&root_id) {
465 continue;
466 }
467 let extras = crate::widget::LayoutExtras {
468 focused,
469 shortcut_registry: Some(&self.shortcut_registry),
470 overlay_manager: Some(&self.overlay_manager),
471 };
472 layout_widget_recursive(
473 &mut self.arena,
474 root_id,
475 Rect::from_origin_size(Point::ZERO, proposal.resolve(0.0, 0.0)),
476 proposal,
477 &base_theme,
478 self.layout_direction,
479 self.device_scale_factor,
480 self.effective_text_scale,
481 self.text_backend.as_ref(),
482 Some(extras),
483 );
484 }
485
486 let anchor_bounds = |id: WidgetId| -> Option<Rect> {
487 self.arena.is_active(id).then(|| self.arena.bounds(id))
488 };
489 let viewport = (
490 proposal.width.unwrap_or(800.0),
491 proposal.height.unwrap_or(600.0),
492 );
493 self.overlay_manager
494 .position_overlays(anchor_bounds, viewport, self.layout_direction);
495 for content_id in &overlay_content_ids {
496 if !self.arena.is_active(*content_id) {
497 continue;
498 }
499 let overlay_id = self.overlay_manager.find_by_content(*content_id);
500 let intrinsic = {
501 let resolved_theme = self.arena.resolve_theme(*content_id, &base_theme);
502 let extras = crate::widget::LayoutExtras {
503 focused: self.focused,
504 shortcut_registry: Some(&self.shortcut_registry),
505 overlay_manager: Some(&self.overlay_manager),
506 };
507 let ctx = LayoutContext {
508 theme: &resolved_theme,
509 layout_direction: self.layout_direction,
510 scale_factor: self.device_scale_factor,
511 text_scale: self.effective_text_scale,
512 text_backend: self.text_backend.as_ref(),
513 arena: Some(&self.arena),
514 extras: Some(extras),
515 stack_main_axis: None,
516 };
517 let node = self
518 .arena
519 .get(*content_id)
520 .expect("content_id from active arena children");
521 node.widget
522 .layout_response(
523 SizeProposal {
524 width: None,
525 height: None,
526 },
527 &ctx,
528 )
529 .size
530 };
531 if let Some(overlay_id) = overlay_id {
532 self.overlay_manager
533 .set_content_bounds(overlay_id, intrinsic);
534 let anchor_bounds = |id: WidgetId| -> Option<Rect> {
535 self.arena.is_active(id).then(|| self.arena.bounds(id))
536 };
537 self.overlay_manager.position_overlays(
538 anchor_bounds,
539 viewport,
540 self.layout_direction,
541 );
542 }
543 let overlay_bounds = overlay_id
544 .and_then(|overlay_id| {
545 self.overlay_manager
546 .stack
547 .iter()
548 .find(|overlay| overlay.id == overlay_id)
549 .map(|overlay| overlay.bounds)
550 })
551 .unwrap_or(Rect::ZERO);
552 // Use the positioned overlay_bounds for layout, not the intrinsic
553 // size. For `BelowPreferred` (and any future placement that
554 // inflates the overlay rect beyond the content's intrinsic size
555 // to match an anchor, e.g. a combo-box dropdown that must be at
556 // least as wide as its trigger), this lets the content widget
557 // actually fill the overlay rather than sitting as a narrow
558 // strip inside it. All other placements return
559 // overlay_bounds.size() == intrinsic, so this is a no-op there.
560 let content_proposal = SizeProposal::exact(overlay_bounds.width, overlay_bounds.height);
561 let extras = crate::widget::LayoutExtras {
562 focused: self.focused,
563 shortcut_registry: Some(&self.shortcut_registry),
564 overlay_manager: Some(&self.overlay_manager),
565 };
566 layout_widget_recursive(
567 &mut self.arena,
568 *content_id,
569 overlay_bounds,
570 content_proposal,
571 &base_theme,
572 self.layout_direction,
573 self.device_scale_factor,
574 self.effective_text_scale,
575 self.text_backend.as_ref(),
576 Some(extras),
577 );
578 }
579
580 // Clear `needs_layout` for every active widget — layout just
581 // ran. `needs_rebuild` is NOT cleared here: `rebuild_single_widget`
582 // clears it for widgets it processes, and widgets whose rebuild
583 // was deferred (captured-pointer window) must keep the flag set
584 // so the next layout pass picks them up. Wiping it here caused
585 // a regression where a scroll-driven ListView rebuild, deferred
586 // during a scrollbar thumb drag, was silently dropped — the
587 // user saw the thumb move but the list view stayed frozen.
588 // Clear `needs_layout` on every active node. Mutation during
589 // iter — pull the snapshot via the reusable scratch.
590 self.arena.fill_active_ids(&mut self.active_ids_scratch);
591 let ids = std::mem::take(&mut self.active_ids_scratch);
592 for &id in &ids {
593 if let Some(node) = self.arena.get_mut(id) {
594 node.dirty.needs_layout = false;
595 }
596 }
597 self.active_ids_scratch = ids;
598
599 // Post-layout hover refresh. When a rebuild destroyed the
600 // hovered widget, `revalidate_interaction_state` cleared
601 // `hovered` to `None`. Now that widgets have fresh bounds
602 // from this layout pass, re-hit-test at the cached pointer
603 // position so the next wheel/pointer event routes to the
604 // widget the cursor is actually over. Without this, a
605 // virtualized list that materializes new rows under a
606 // stationary cursor would see the next `Scroll` fall through
607 // to `focused` and bubble to an ancestor scrollable.
608 if self.hovered.is_none()
609 && let Some(pos) = self.last_pointer_position
610 {
611 let new_target = self.hit_test(pos);
612 if new_target.is_some() {
613 if let Some(new) = new_target {
614 self.dispatch_to_widget(new, &WidgetEvent::PointerEnter, &mut *ops);
615 // Seed the tooltip dwell too, exactly as `handle_pointer_move`
616 // pairs these two. The rebuild replaced the anchor's tooltip
617 // entry with a fresh one whose `hover_start` is `None`, and
618 // the pointer is not going to move again — so without this the
619 // widget's tooltip is unreachable for the rest of the hover.
620 self.tooltip_pointer_enter(new);
621 }
622 self.set_hovered(new_target);
623 }
624 }
625
626 // Post-layout focus refresh — the symmetric case to the hover refresh
627 // above. A rebuild destroyed the focused widget, so
628 // `revalidate_interaction_state` cleared `focused` to `None`; the
629 // subtree that owned it was recorded as `pending_focus_restore`. Now
630 // that its fresh children have bounds from this layout pass, land focus
631 // back inside it, so a rebuild keeps focus in the subtree that had it
632 // rather than dumping it out of the widget entirely.
633 //
634 // Deliberately conservative: only when nothing else has taken focus in
635 // the meantime, only into a subtree that is still active (a rebuild that
636 // also went dormant, e.g. a popover closing, must NOT drag focus back
637 // into hidden content — its own dismiss path restores focus to the
638 // trigger), and only if it still has somewhere to put it. Otherwise focus
639 // stays `None`, exactly as before.
640 if let Some(root) = self.pending_focus_restore.take()
641 && self.focused.is_none()
642 && self.arena.is_active(root)
643 && let Some(target) = self.first_focusable_descendant(root)
644 {
645 self.focus_ops(target, &mut *ops);
646 }
647 }
648}
649
650/// Recursive layout pass operating on the arena directly (avoids borrow conflicts).
651#[allow(clippy::too_many_arguments)]
652fn layout_widget_recursive(
653 arena: &mut WidgetArena,
654 id: WidgetId,
655 parent_bounds: Rect,
656 proposal: SizeProposal,
657 base_theme: &crate::styles::Theme,
658 layout_direction: crate::environment::LayoutDirection,
659 scale_factor: f32,
660 text_scale: f32,
661 text_backend: Option<&std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>>,
662 extras: Option<crate::widget::LayoutExtras<'_>>,
663) {
664 if !arena.is_active(id) {
665 return;
666 }
667
668 let resolved_theme = arena.resolve_theme(id, base_theme);
669
670 let desired_size = {
671 let ctx = LayoutContext {
672 theme: &resolved_theme,
673 layout_direction,
674 scale_factor,
675 text_scale,
676 text_backend,
677 arena: Some(arena),
678 extras,
679 stack_main_axis: None,
680 };
681 arena
682 .cached_layout_response(id, proposal, &ctx)
683 .map(|r| r.size)
684 .unwrap_or(teksilo_canvas::Size::ZERO)
685 };
686
687 let bounds = Rect::new(
688 parent_bounds.x,
689 parent_bounds.y,
690 proposal.width.unwrap_or(desired_size.width),
691 proposal.height.unwrap_or(desired_size.height),
692 );
693 if let Some(node) = arena.get_mut(id) {
694 if node.bounds != bounds {
695 node.cached_paint = None;
696 node.dirty.needs_paint = true;
697 }
698 node.bounds = bounds;
699 }
700
701 let child_ids: Vec<WidgetId> = arena.children(id).to_vec();
702 let active_child_ids: Vec<WidgetId> = child_ids
703 .iter()
704 .copied()
705 .filter(|&child_id| arena.is_active(child_id))
706 .collect();
707
708 let mut placements: Vec<WidgetPlacement> = active_child_ids
709 .iter()
710 .map(|&child_id| WidgetPlacement {
711 id: child_id,
712 origin: bounds.origin(),
713 size: bounds.size(),
714 })
715 .collect();
716
717 // `place_children` is a widget's ONLY hook that receives its final,
718 // parent-assigned `bounds`, so it runs for EVERY active widget on every
719 // pass — including leaves, which get an empty `placements` slice. A widget
720 // whose paint depends on where the parent put it (a scene folding its
721 // origin into a view transform, a text engine sizing its viewport) can then
722 // read its bounds during *layout*, which is the only point early enough:
723 // the render walker pushes node-level transform scopes before `paint` runs.
724 {
725 let ctx = LayoutContext {
726 theme: &resolved_theme,
727 layout_direction,
728 scale_factor,
729 text_scale,
730 text_backend,
731 arena: Some(arena),
732 extras,
733 stack_main_axis: None,
734 };
735 let node = arena.get(id).expect("widget id is active in arena");
736 node.widget
737 .place_children(bounds, proposal, &mut placements, &ctx);
738 }
739
740 for placement in &placements {
741 let child_bounds = Rect::from_origin_size(placement.origin, placement.size);
742 if let Some(child_node) = arena.get_mut(placement.id) {
743 if child_node.bounds != child_bounds {
744 child_node.cached_paint = None;
745 child_node.dirty.needs_paint = true;
746 }
747 child_node.bounds = child_bounds;
748 }
749
750 let child_proposal = SizeProposal::exact(placement.size.width, placement.size.height);
751 let grandchild_ids: Vec<WidgetId> = arena.children(placement.id).to_vec();
752 if !grandchild_ids.is_empty() {
753 layout_widget_recursive(
754 arena,
755 placement.id,
756 child_bounds,
757 child_proposal,
758 base_theme,
759 layout_direction,
760 scale_factor,
761 text_scale,
762 text_backend,
763 extras,
764 );
765 } else {
766 // A childless child is never visited by the recursion above, so
767 // hand it its final bounds here — with an empty `placements` slice.
768 //
769 // Deliberately NOT a `layout_widget_recursive` call: that would
770 // re-measure the leaf against a fresh `exact` proposal (a memo miss,
771 // since the parent measured it under a different proposal), adding a
772 // redundant `layout_response` per leaf on every pass.
773 let ctx = LayoutContext {
774 theme: &resolved_theme,
775 layout_direction,
776 scale_factor,
777 text_scale,
778 text_backend,
779 arena: Some(arena),
780 extras,
781 stack_main_axis: None,
782 };
783 let node = arena.get(placement.id).expect("child id is active");
784 node.widget
785 .place_children(child_bounds, child_proposal, &mut [], &ctx);
786 }
787 }
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use crate::test_widgets::{FillWidget, InsetWidget, StackWidget};
794 use teksilo_canvas::Size;
795 use teksilo_tokens::Color;
796
797 /// A leaf that records the bounds `place_children` hands it, and how often.
798 #[derive(Debug, Clone, Default)]
799 struct BoundsRecorder {
800 seen: std::rc::Rc<std::cell::RefCell<Vec<Rect>>>,
801 }
802
803 impl Widget for BoundsRecorder {
804 fn layout_response(
805 &self,
806 proposal: SizeProposal,
807 _ctx: &LayoutContext,
808 ) -> crate::widget::LayoutResponse {
809 Size::new(
810 proposal.width.unwrap_or(10.0),
811 proposal.height.unwrap_or(10.0),
812 )
813 .into()
814 }
815
816 fn place_children(
817 &self,
818 bounds: Rect,
819 _proposal: SizeProposal,
820 children: &mut [WidgetPlacement],
821 _ctx: &LayoutContext,
822 ) {
823 assert!(
824 children.is_empty(),
825 "a leaf must be handed an empty placements slice"
826 );
827 self.seen.borrow_mut().push(bounds);
828 }
829 }
830
831 /// The invariant `SceneView` (and both text engines) depend on: a widget with
832 /// NO children still gets `place_children`, carrying its final bounds.
833 ///
834 /// Before this was guaranteed, the walker skipped `place_children` whenever
835 /// there was nothing to place, so a leaf could only discover its bounds in
836 /// `paint`. That is too late for anything the renderer consumes *before*
837 /// paint — a `SceneView` folds `bounds.origin` into the transform scope the
838 /// walker pushes around its subtree, so a scene holding only lightweight
839 /// items (hence no arena children) painted its content offset by
840 /// `-bounds.origin`, an error that scaled with zoom.
841 #[test]
842 fn a_childless_widget_still_receives_its_bounds() {
843 let mut tree = WidgetTree::new();
844 let leaf = BoundsRecorder::default();
845 let seen = leaf.seen.clone();
846
847 // Nested inside an inset container, so a correct origin is non-zero and a
848 // stale/zero origin cannot pass by accident.
849 let leaf_id = tree.add(leaf);
850 let _root = tree.add(InsetWidget::new(12.0).set_child(leaf_id));
851 tree.layout(SizeProposal::exact(200.0, 100.0));
852
853 let bounds = seen.borrow();
854 assert_eq!(
855 bounds.len(),
856 1,
857 "the leaf must be placed exactly once per layout pass, got {bounds:?}"
858 );
859 assert_eq!(
860 (bounds[0].x, bounds[0].y),
861 (12.0, 12.0),
862 "the leaf must receive its real, parent-assigned origin"
863 );
864 assert_eq!(
865 (bounds[0].width, bounds[0].height),
866 (176.0, 76.0),
867 "the leaf must receive its real, parent-assigned size"
868 );
869 }
870
871 /// The same guarantee at the root: a tree whose root IS a leaf.
872 #[test]
873 fn a_childless_root_still_receives_its_bounds() {
874 let mut tree = WidgetTree::new();
875 let leaf = BoundsRecorder::default();
876 let seen = leaf.seen.clone();
877 let _id = tree.add(leaf);
878 tree.layout(SizeProposal::exact(320.0, 240.0));
879
880 let bounds = seen.borrow();
881 assert_eq!(bounds.len(), 1, "root leaf must be placed once");
882 assert_eq!((bounds[0].width, bounds[0].height), (320.0, 240.0));
883 }
884
885 #[derive(Debug)]
886 struct ShrinkWrapContainer {
887 child: WidgetId,
888 inset: f32,
889 }
890
891 impl Widget for ShrinkWrapContainer {
892 fn layout_response(
893 &self,
894 _proposal: SizeProposal,
895 ctx: &LayoutContext,
896 ) -> crate::widget::LayoutResponse {
897 let child_size = ctx
898 .child_size(self.child, SizeProposal::unspecified())
899 .unwrap_or(Size::ZERO);
900 Size::new(
901 child_size.width + self.inset * 2.0,
902 child_size.height + self.inset * 2.0,
903 )
904 .into()
905 }
906
907 fn place_children(
908 &self,
909 bounds: Rect,
910 _proposal: SizeProposal,
911 children: &mut [WidgetPlacement],
912 _ctx: &LayoutContext,
913 ) {
914 for child in children.iter_mut() {
915 child.origin = Point::new(bounds.x + self.inset, bounds.y + self.inset);
916 child.size = Size::new(
917 (bounds.width - self.inset * 2.0).max(0.0),
918 (bounds.height - self.inset * 2.0).max(0.0),
919 );
920 }
921 }
922
923 fn children(&self) -> Vec<WidgetId> {
924 vec![self.child]
925 }
926 }
927
928 // ── Per-pass layout memoization cache (Part C) ──────────────────────────
929
930 /// A childless leaf that counts how many times `layout_response` runs and
931 /// can opt out of caching. The driver does not recurse into a childless
932 /// leaf's placement, so the only calls come from a parent's `child_size`
933 /// queries — making the count a precise probe of the cache.
934 #[derive(Debug)]
935 struct CountingLeaf {
936 calls: std::rc::Rc<std::cell::Cell<u32>>,
937 cacheable: bool,
938 }
939
940 impl Widget for CountingLeaf {
941 fn layout_response(
942 &self,
943 _proposal: SizeProposal,
944 _ctx: &LayoutContext,
945 ) -> crate::widget::LayoutResponse {
946 self.calls.set(self.calls.get() + 1);
947 Size::new(50.0, 20.0).into()
948 }
949 fn cacheable_layout(&self) -> bool {
950 self.cacheable
951 }
952 }
953
954 /// Queries its single child with the *same* proposal in both
955 /// `layout_response` and `place_children` — the pattern real stacks use
956 /// for height-for-width. With caching the child computes once; without it,
957 /// twice.
958 #[derive(Debug)]
959 struct DoubleQueryContainer {
960 child: WidgetId,
961 }
962
963 impl Widget for DoubleQueryContainer {
964 fn layout_response(
965 &self,
966 _proposal: SizeProposal,
967 ctx: &LayoutContext,
968 ) -> crate::widget::LayoutResponse {
969 ctx.child_size(self.child, SizeProposal::exact(50.0, 20.0))
970 .unwrap_or(Size::ZERO)
971 .into()
972 }
973 fn place_children(
974 &self,
975 bounds: Rect,
976 _proposal: SizeProposal,
977 children: &mut [WidgetPlacement],
978 ctx: &LayoutContext,
979 ) {
980 // Second query with the identical proposal.
981 let _ = ctx.child_size(self.child, SizeProposal::exact(50.0, 20.0));
982 for child in children.iter_mut() {
983 child.origin = bounds.origin();
984 child.size = bounds.size();
985 }
986 }
987 fn children(&self) -> Vec<WidgetId> {
988 vec![self.child]
989 }
990 }
991
992 #[test]
993 fn cache_dedupes_identical_child_queries_within_a_pass() {
994 let calls = std::rc::Rc::new(std::cell::Cell::new(0));
995 let mut tree = WidgetTree::new();
996 let leaf = tree.add(CountingLeaf {
997 calls: calls.clone(),
998 cacheable: true,
999 });
1000 let _root = tree.add(DoubleQueryContainer { child: leaf });
1001 tree.layout(SizeProposal::exact(100.0, 50.0));
1002 // Two identical `exact(50,20)` queries (layout_response + place_children)
1003 // collapse to one real call; the driver does not recurse into the
1004 // childless leaf.
1005 assert_eq!(calls.get(), 1, "cacheable leaf should be computed once");
1006 }
1007
1008 #[test]
1009 fn cache_opt_out_recomputes_every_query() {
1010 let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1011 let mut tree = WidgetTree::new();
1012 let leaf = tree.add(CountingLeaf {
1013 calls: calls.clone(),
1014 cacheable: false,
1015 });
1016 let _root = tree.add(DoubleQueryContainer { child: leaf });
1017 tree.layout(SizeProposal::exact(100.0, 50.0));
1018 assert_eq!(
1019 calls.get(),
1020 2,
1021 "opt-out leaf must run on every query (side effects preserved)"
1022 );
1023 }
1024
1025 #[test]
1026 fn cache_is_cleared_between_passes() {
1027 let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1028 let mut tree = WidgetTree::new();
1029 let leaf = tree.add(CountingLeaf {
1030 calls: calls.clone(),
1031 cacheable: true,
1032 });
1033 let _root = tree.add(DoubleQueryContainer { child: leaf });
1034 tree.layout(SizeProposal::exact(100.0, 50.0));
1035 // A second pass with a different proposal must re-run layout — proving
1036 // the cache is per-pass, not stale across passes (the `exact(50,20)`
1037 // child key is identical between passes).
1038 tree.layout(SizeProposal::exact(120.0, 60.0));
1039 assert_eq!(
1040 calls.get(),
1041 2,
1042 "each pass recomputes; cache cleared per pass"
1043 );
1044 }
1045
1046 // ── measure_intrinsic (Primitive 2) ─────────────────────────────────────
1047
1048 /// Probe: from its own `layout_response`, measures `target` two ways and
1049 /// stashes the results — the normal (activation-gated) query and the
1050 /// intrinsic (activation-ignoring) query.
1051 #[derive(Debug)]
1052 struct MeasureProbe {
1053 target: WidgetId,
1054 active_w: std::rc::Rc<std::cell::Cell<f32>>, // -1.0 == None
1055 intrinsic_w: std::rc::Rc<std::cell::Cell<f32>>,
1056 }
1057 impl Widget for MeasureProbe {
1058 fn layout_response(
1059 &self,
1060 p: SizeProposal,
1061 ctx: &LayoutContext,
1062 ) -> crate::widget::LayoutResponse {
1063 // Measure intrinsic FIRST, then the normal gated query: if the
1064 // measure had polluted the cache, the gated query could wrongly
1065 // return a size for the dormant target. `exact` because FillWidget
1066 // fills its proposal (it has no intrinsic size of its own).
1067 let probe = SizeProposal::exact(120.0, 30.0);
1068 let intrinsic = ctx
1069 .measure_intrinsic(self.target, probe)
1070 .map(|s| s.width)
1071 .unwrap_or(-1.0);
1072 let active = ctx
1073 .child_size(self.target, probe)
1074 .map(|s| s.width)
1075 .unwrap_or(-1.0);
1076 self.intrinsic_w.set(intrinsic);
1077 self.active_w.set(active);
1078 p.resolve(0.0, 0.0).into()
1079 }
1080 fn cacheable_layout(&self) -> bool {
1081 false
1082 }
1083 }
1084
1085 #[test]
1086 fn measure_intrinsic_sees_a_dormant_widget_normal_query_does_not() {
1087 let active = std::rc::Rc::new(std::cell::Cell::new(0.0));
1088 let intrinsic = std::rc::Rc::new(std::cell::Cell::new(0.0));
1089 let mut tree = WidgetTree::new();
1090 let leaf = tree.add(FillWidget::new());
1091 tree.set_dormant(leaf);
1092 let _probe = tree.add(MeasureProbe {
1093 target: leaf,
1094 active_w: active.clone(),
1095 intrinsic_w: intrinsic.clone(),
1096 });
1097 tree.layout(SizeProposal::exact(200.0, 50.0));
1098
1099 // measure_intrinsic measures the dormant widget (FillWidget fills the
1100 // 120px probe)…
1101 assert!(
1102 (intrinsic.get() - 120.0).abs() < 0.01,
1103 "measure_intrinsic should size the dormant widget, got {}",
1104 intrinsic.get()
1105 );
1106 // …and the normal gated query (run AFTER) still returns None — proving
1107 // the measure bypassed, and did not seed, the per-pass cache.
1108 assert_eq!(
1109 active.get(),
1110 -1.0,
1111 "child_size must stay None for a dormant widget (no cache pollution)"
1112 );
1113 }
1114
1115 /// A box whose height is driven by a signal and whose width echoes the
1116 /// proposed width (height-for-width) — models a widget (e.g. a `MessageBox`
1117 /// "Show details" expander) whose intrinsic height changes with content.
1118 /// Echoing the width lets a fixed-width intrinsic measurement be exercised.
1119 #[derive(Debug)]
1120 struct SignalBox {
1121 h: crate::signal::Signal<f32>,
1122 }
1123 impl Widget for SignalBox {
1124 fn layout_response(
1125 &self,
1126 p: SizeProposal,
1127 _ctx: &LayoutContext,
1128 ) -> crate::widget::LayoutResponse {
1129 teksilo_canvas::Size::new(p.width.unwrap_or(0.0), self.h.get()).into()
1130 }
1131 fn cacheable_layout(&self) -> bool {
1132 false
1133 }
1134 }
1135
1136 /// Sums the ACTIVE children's heights via `child_size` (which returns
1137 /// `None` for a dormant child, so a hidden child contributes nothing) —
1138 /// lets a test assert size-to-content excludes dormant subtrees.
1139 #[derive(Debug)]
1140 struct VSumBox {
1141 children: Vec<WidgetId>,
1142 }
1143 impl Widget for VSumBox {
1144 fn layout_response(
1145 &self,
1146 p: SizeProposal,
1147 ctx: &LayoutContext,
1148 ) -> crate::widget::LayoutResponse {
1149 let h: f32 = self
1150 .children
1151 .iter()
1152 .filter_map(|&c| ctx.child_size(c, p))
1153 .map(|s| s.height)
1154 .sum();
1155 teksilo_canvas::Size::new(p.width.unwrap_or(0.0), h).into()
1156 }
1157 fn children(&self) -> Vec<WidgetId> {
1158 self.children.clone()
1159 }
1160 }
1161
1162 #[test]
1163 fn measure_root_intrinsic_honors_fixed_width_and_tracks_content() {
1164 let h = crate::signal::Signal::new(140.0);
1165 let mut tree = WidgetTree::new();
1166 let _root = tree.add(SignalBox { h: h.clone() });
1167 // Lay the root out constrained to a fixed native-modal size.
1168 tree.layout(SizeProposal::exact(460.0, 140.0));
1169
1170 // Intrinsic measurement at a fixed width / unbounded height reports the
1171 // proposed width and the content's natural height — the size a
1172 // size-to-content window grows to, independent of the constrained pass.
1173 let m = tree
1174 .measure_root_intrinsic(SizeProposal {
1175 width: Some(460.0),
1176 height: None,
1177 })
1178 .expect("one active primary root");
1179 assert!(
1180 (m.width - 460.0).abs() < 0.01,
1181 "fixed width honored, got {}",
1182 m.width
1183 );
1184 assert!(
1185 (m.height - 140.0).abs() < 0.01,
1186 "natural height, got {}",
1187 m.height
1188 );
1189
1190 // A different fixed width flows through (the proposal really is used).
1191 let narrow = tree
1192 .measure_root_intrinsic(SizeProposal {
1193 width: Some(300.0),
1194 height: None,
1195 })
1196 .expect("root active");
1197 assert!(
1198 (narrow.width - 300.0).abs() < 0.01,
1199 "proposal width, got {}",
1200 narrow.width
1201 );
1202
1203 // Content growth (a "Show details" expander) is reflected.
1204 h.set(300.0);
1205 let grown = tree
1206 .measure_root_intrinsic(SizeProposal {
1207 width: Some(460.0),
1208 height: None,
1209 })
1210 .expect("root active");
1211 assert!(
1212 (grown.height - 300.0).abs() < 0.01,
1213 "grows with content, got {}",
1214 grown.height
1215 );
1216 }
1217
1218 #[test]
1219 fn measure_root_intrinsic_excludes_dormant_content() {
1220 let mut tree = WidgetTree::new();
1221 let shown = tree.add(SignalBox {
1222 h: crate::signal::Signal::new(200.0),
1223 });
1224 let hidden = tree.add(SignalBox {
1225 h: crate::signal::Signal::new(1000.0),
1226 });
1227 tree.set_dormant(hidden);
1228 let _root = tree.add(VSumBox {
1229 children: vec![shown, hidden],
1230 });
1231 tree.layout(SizeProposal::exact(460.0, 200.0));
1232
1233 // The dormant child must NOT contribute — a size-to-content window is
1234 // sized to what is actually shown. Regression guard for measuring via
1235 // `layout_response` (activation-respecting) rather than the
1236 // activation-ignoring `measure_intrinsic` (which would return 1200).
1237 let m = tree
1238 .measure_root_intrinsic(SizeProposal {
1239 width: Some(460.0),
1240 height: None,
1241 })
1242 .expect("one active primary root");
1243 assert!(
1244 (m.height - 200.0).abs() < 0.01,
1245 "dormant child must be excluded, got {}",
1246 m.height
1247 );
1248 }
1249
1250 #[test]
1251 fn single_widget_fills_proposal() {
1252 let mut tree = WidgetTree::new();
1253 let widget = tree.add(FillWidget::new().background(Color::RED));
1254 tree.layout(SizeProposal::exact(200.0, 40.0));
1255 let bounds = tree.bounds(widget);
1256 assert_eq!(bounds.width, 200.0);
1257 assert_eq!(bounds.height, 40.0);
1258 }
1259
1260 #[test]
1261 fn stack_children_overlap() {
1262 let mut tree = WidgetTree::new();
1263 let a = tree.add(FillWidget::new());
1264 let b = tree.add(FillWidget::new());
1265 let stack = tree.add(StackWidget::new().add_child(a).add_child(b));
1266 tree.layout(SizeProposal::exact(100.0, 50.0));
1267 let children = tree.children(stack);
1268 assert_eq!(children.len(), 2);
1269 let a_bounds = tree.bounds(children[0]);
1270 let b_bounds = tree.bounds(children[1]);
1271 assert_eq!(a_bounds.origin(), b_bounds.origin());
1272 assert_eq!(a_bounds.size(), b_bounds.size());
1273 }
1274
1275 #[test]
1276 fn inset_widget_insets_child() {
1277 let mut tree = WidgetTree::new();
1278 let child = tree.add(FillWidget::new());
1279 let parent = tree.add(InsetWidget::new(10.0).set_child(child));
1280 tree.layout(SizeProposal::exact(100.0, 50.0));
1281 let children = tree.children(parent);
1282 let child_bounds = tree.bounds(children[0]);
1283 assert_eq!(child_bounds.x, 10.0);
1284 assert_eq!(child_bounds.y, 10.0);
1285 assert_eq!(child_bounds.width, 80.0);
1286 assert_eq!(child_bounds.height, 30.0);
1287 }
1288
1289 #[test]
1290 fn recursive_layout_preserves_exact_parent_placement_for_containers() {
1291 let mut tree = WidgetTree::new();
1292 let leaf = tree.add(FillWidget::new());
1293 let shrink = tree.add(ShrinkWrapContainer {
1294 child: leaf,
1295 inset: 8.0,
1296 });
1297 let root = tree.add(StackWidget::new().add_child(shrink));
1298
1299 tree.layout(SizeProposal::exact(120.0, 80.0));
1300
1301 assert_eq!(tree.bounds(root), Rect::new(0.0, 0.0, 120.0, 80.0));
1302 assert_eq!(
1303 tree.bounds(shrink),
1304 Rect::new(0.0, 0.0, 120.0, 80.0),
1305 "child container should keep the exact size assigned by its parent"
1306 );
1307 assert_eq!(tree.bounds(leaf), Rect::new(8.0, 8.0, 104.0, 64.0));
1308 }
1309
1310 #[test]
1311 fn needs_paint_after_layout() {
1312 let mut tree = WidgetTree::new();
1313 tree.add(FillWidget::new());
1314 assert!(tree.needs_layout());
1315 tree.layout(SizeProposal::exact(100.0, 50.0));
1316 assert!(!tree.needs_layout());
1317 }
1318
1319 #[test]
1320 fn signal_binding_marks_widget_dirty_on_layout() {
1321 use crate::signal::Signal;
1322
1323 let mut tree = WidgetTree::new();
1324 let widget = tree.add(FillWidget::new().background(Color::RED));
1325 tree.layout(SizeProposal::exact(100.0, 50.0));
1326 tree.render();
1327
1328 assert!(!tree.needs_paint());
1329
1330 let visible = Signal::new(true);
1331 visible.bind_to(
1332 widget,
1333 tree.binding_registry(),
1334 crate::binding::BindingLevel::RepaintOnly,
1335 );
1336
1337 visible.set(false);
1338 tree.layout(SizeProposal::exact(100.0, 50.0));
1339 assert!(tree.needs_paint());
1340 }
1341}