teksilo_core/deferred_subtree.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`DeferredSubtree`] — a child whose subtree is not built until it is first
5//! revealed, and is retained from then on.
6//!
7//! ## The cost this exists to remove
8//!
9//! A widget that owns overlay content — a popover's panel, a combo box's
10//! dropdown, a menu item's submenu, a date field's calendar — has until now
11//! written it the same way:
12//!
13//! ```ignore
14//! let content_id = ctx.add(panel); // builds the WHOLE subtree, now
15//! ctx.set_dormant(content_id); // ...and immediately parks it
16//! ```
17//!
18//! That is correct and it is what `Arena::set_dormant`
19//! documents: dormancy is about *activation*, not construction, and a parked
20//! subtree keeps its state. What it costs is a full `build()` of content the
21//! user may never open — on **every rebuild of the owner**.
22//!
23//! In a single dialog that is invisible. In a virtualized collection it is the
24//! dominant cost, because the owner is a per-row delegate: a table cell hosting
25//! a `PopoverIconButton` builds its entire menu once per row, per rebuild.
26//! Measured on a 40-row table whose cells each carried a four-item menu:
27//!
28//! | per rebuild | |
29//! |---|---|
30//! | cells with the eager popover | 325–552 ms |
31//! | same cells, content not added to the arena | 61–73 ms |
32//! | no such column at all | 42–46 ms |
33//!
34//! Roughly **85% of the cost is `ctx.add`**, not constructing the widget value
35//! that is handed to it. Deferring the insertion is therefore the whole win, and
36//! it needs no change to what callers pass.
37//!
38//! ## The contract
39//!
40//! * **Built at most once.** The first `build()` that sees `reveal == true`
41//! materializes the subtree; every later rebuild of the host returns the same
42//! child id. So state inside the content survives close/reopen exactly as the
43//! eager-then-dormant version did — that guarantee is why this defers
44//! construction rather than rebuilding per open.
45//! * **The id is stable from the start.** [`BuildContext::add_deferred`] returns a real arena
46//! node immediately, so everything downstream — `set_dormant` / `activate`,
47//! `visible_when`, `OverlayRequest::content_id`, descendant checks, dismissal —
48//! is unchanged. Only *when* the subtree below that id exists has moved.
49//! * **Layout-transparent.** Reports the child's size, and nothing (a zero-size
50//! node) while still unbuilt.
51//!
52//! ## Why an explicit `reveal` signal, and not activation
53//!
54//! A node's own activation is observable ([`BuildContext::activation_signal`]),
55//! and every caller here already gates the content on a signal — that is what
56//! `visible_when` is given. Binding *that* signal is what makes the content
57//! arrive in the right frame: a `Signal` set inside an event handler marks the
58//! host `needs_rebuild` during dispatch, so the rebuild pass of the very next
59//! layout builds the content **before** the overlay it belongs to is measured
60//! and placed. `activation_signal` is flushed at the *end* of the visibility
61//! pass (see `WidgetTree::flush_activation_signals`), which is after that
62//! frame's rebuilds — the content would land a frame late, and the overlay would
63//! be placed against an empty panel first.
64//!
65//! (The framework re-runs `process_pending_rebuilds` after overlay activation
66//! for exactly this class of problem, and `needs_rebuild_iter` is deliberately
67//! not gated on the node already having children — so a host that starts empty
68//! is rebuilt correctly either way. The explicit signal is what makes the
69//! timing tight rather than merely eventually-correct.)
70
71use teksilo_canvas::{Point, Rect, Size, SizeProposal};
72
73use crate::binding::BindingLevel;
74use crate::build_context::BuildContext;
75use crate::signal::Signal;
76use crate::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
77use crate::widget_id::WidgetId;
78
79/// A child subtree built the first time `reveal` is `true`, then retained.
80///
81/// Construct through [`BuildContext::add_deferred`] and its siblings rather
82/// than directly — they are what give the host its stable id.
83pub struct DeferredSubtree {
84 /// The un-built content. `None` once materialized (or once taken by a
85 /// build that found `reveal` true).
86 pending: Option<Box<dyn Widget>>,
87 /// The materialized child, once built. Retained across rebuilds.
88 child: Option<WidgetId>,
89 /// The caller's reveal gate. `None` for content the *framework* decides to
90 /// materialize — a tooltip body, which has no widget-visible open signal
91 /// and is instead forced by the tree when a dwell matures.
92 reveal: Option<Signal<bool>>,
93 /// Set by [`force`](Self::force) when the framework needs the content now.
94 forced: bool,
95}
96
97impl DeferredSubtree {
98 pub(crate) fn new(reveal: Option<Signal<bool>>, content: Box<dyn Widget>) -> Self {
99 Self {
100 pending: Some(content),
101 child: None,
102 reveal,
103 forced: false,
104 }
105 }
106
107 /// Materialize on the next build regardless of the reveal gate.
108 ///
109 /// For content the framework shows on its own initiative — a tooltip body,
110 /// whose dwell has no signal a widget could hand over. Pair it with a
111 /// rebuild of this host (`WidgetTree::materialize_deferred` does both).
112 pub fn force(&mut self) {
113 self.forced = true;
114 }
115
116 /// Whether the subtree has been built. Test hook, and the honest answer to
117 /// "did deferring actually defer".
118 pub fn is_materialized(&self) -> bool {
119 self.child.is_some()
120 }
121
122 /// The materialized child, if the subtree has been built.
123 ///
124 /// Lets a caller that must inspect the *content* — the accessibility walk
125 /// reading a tooltip's text off the node it is attached to — resolve past
126 /// this host instead of probing the host itself and finding nothing.
127 pub fn materialized_child(&self) -> Option<WidgetId> {
128 self.child
129 }
130}
131
132impl std::fmt::Debug for DeferredSubtree {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.debug_struct("DeferredSubtree")
135 .field("materialized", &self.child.is_some())
136 .finish()
137 }
138}
139
140impl Widget for DeferredSubtree {
141 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
142 // Bound on every build, including the ones that return nothing: the
143 // binding is what turns the reveal into a rebuild, so a host that
144 // skipped it while asleep would never wake.
145 if let Some(reveal) = &self.reveal {
146 reveal.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
147 }
148
149 if let Some(id) = self.child {
150 // Already materialized. Return the same id rather than rebuilding:
151 // re-adding would discard whatever state the content holds, which
152 // is the one thing the eager-then-dormant version got right.
153 return vec![id];
154 }
155 if !self.forced && !self.reveal.as_ref().is_some_and(|r| r.get()) {
156 return Vec::new();
157 }
158 let Some(content) = self.pending.take() else {
159 // Revealed, but the content was already consumed and no child came
160 // of it. Nothing to do — and nothing to build twice.
161 return Vec::new();
162 };
163 let id = ctx.add_boxed(content);
164 self.child = Some(id);
165 vec![id]
166 }
167
168 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
169 match self.child {
170 // `child_size` returns `None` for a **dormant** child as well as an
171 // unbuilt one, and a materialized-then-closed popover (or a tooltip
172 // body between dwells) is exactly that. So this fallback is the same
173 // hazard as the arm below and takes the same answer: `Size::ZERO`,
174 // never `proposal.resolve(0.0, 0.0)`.
175 Some(id) => ctx
176 .child_size(id, proposal)
177 .unwrap_or(Size::new(0.0, 0.0))
178 .into(),
179 // **`Size::ZERO`, never `proposal.resolve(0.0, 0.0)`.** `resolve`
180 // defers to whichever axis the proposal specifies, so under an
181 // `exact` proposal it hands back the parent's full box — an unbuilt
182 // popover panel would then claim the whole cell it is a sibling of
183 // and shove the trigger out of the row.
184 None => Size::new(0.0, 0.0).into(),
185 }
186 }
187
188 fn place_children(
189 &self,
190 bounds: Rect,
191 _proposal: SizeProposal,
192 children: &mut [WidgetPlacement],
193 _ctx: &LayoutContext,
194 ) {
195 // Layout-transparent: the child occupies the host's whole box, so the
196 // node adds a level to the arena and nothing to the geometry.
197 for child in children.iter_mut() {
198 child.origin = Point::new(bounds.x, bounds.y);
199 child.size = bounds.size();
200 }
201 }
202
203 /// **Required, not an optimisation.** The default rebuild path destroys a
204 /// widget's children *before* calling `build()`, so a host that caches its
205 /// child id and returns it again would hand back a dead node — the content
206 /// would vanish the first time anything rebuilt this host. Retaining the
207 /// materialized subtree across rebuilds is the whole contract here.
208 fn preserves_children_on_rebuild(&self) -> bool {
209 true
210 }
211
212 /// **Delegates to the un-built content**, which is the whole reason this can
213 /// be layered under a tooltip at all.
214 ///
215 /// A plain tooltip is never auto-shown on focus, so the description copied
216 /// onto the anchor *is* the entire screen-reader path for that tier — and it
217 /// is read by probing the content widget's own `accessibility`. Deferring
218 /// the subtree must not cost that: the widget value is right here, un-built,
219 /// and answering from it needs no arena node. Without this the tips still
220 /// appear on hover and every screen-reader user silently loses them, which
221 /// is the kind of regression that ships.
222 fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
223 if let Some(pending) = &self.pending {
224 pending.accessibility(builder);
225 }
226 }
227
228 /// Same delegation as [`accessibility`](Self::accessibility), for the
229 /// emptiness guard: a tooltip with nothing to say must not open a bubble,
230 /// and the un-built body is the only thing that knows.
231 fn tooltip_has_content(&self) -> bool {
232 match &self.pending {
233 Some(pending) => pending.tooltip_has_content(),
234 None => true,
235 }
236 }
237
238 fn as_any(&self) -> Option<&dyn std::any::Any> {
239 Some(self)
240 }
241
242 /// Required by `WidgetTree::materialize_deferred`, which reaches this
243 /// widget by id to force it.
244 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
245 Some(self)
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::widget_tree::WidgetTree;
253
254 /// Counts how many times it is built, so a test can prove the difference
255 /// between "not shown" and "not built".
256 #[derive(Debug)]
257 struct BuildCounter {
258 builds: Signal<u32>,
259 }
260
261 impl Widget for BuildCounter {
262 fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
263 self.builds.set(self.builds.get() + 1);
264 Vec::new()
265 }
266
267 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
268 proposal.resolve(20.0, 12.0).into()
269 }
270 }
271
272 /// A host that owns one deferred child, so the test drives the same shape a
273 /// popover does: the host rebuilds, the child must not.
274 #[derive(Debug)]
275 struct Host {
276 reveal: Signal<bool>,
277 builds: Signal<u32>,
278 host_builds: Signal<u32>,
279 child: Option<WidgetId>,
280 }
281
282 impl Widget for Host {
283 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
284 self.host_builds.set(self.host_builds.get() + 1);
285 // Rebuild this host whenever `reveal` moves, exactly as a popover's
286 // trigger does — the case that must not starve the child of its own
287 // rebuild.
288 self.reveal
289 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
290 let id = self.child.unwrap_or_else(|| {
291 ctx.add_deferred(
292 self.reveal.clone(),
293 BuildCounter {
294 builds: self.builds.clone(),
295 },
296 )
297 });
298 self.child = Some(id);
299 vec![id]
300 }
301
302 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
303 self.child
304 .and_then(|id| ctx.child_size(id, proposal))
305 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
306 .into()
307 }
308
309 fn preserves_children_on_rebuild(&self) -> bool {
310 true
311 }
312 }
313
314 fn tree_with_host() -> (WidgetTree, Signal<bool>, Signal<u32>, Signal<u32>, WidgetId) {
315 let reveal = Signal::new(false);
316 let builds = Signal::new(0);
317 let host_builds = Signal::new(0);
318 let mut tree = WidgetTree::new();
319 let id = tree.add(Host {
320 reveal: reveal.clone(),
321 builds: builds.clone(),
322 host_builds: host_builds.clone(),
323 child: None,
324 });
325 tree.layout(SizeProposal::exact(200.0, 100.0));
326 (tree, reveal, builds, host_builds, id)
327 }
328
329 /// **The whole point: unrevealed content is never built.**
330 ///
331 /// Not "built and parked" — not built. The eager form this replaces would
332 /// report one build here, and one more on every rebuild of the host.
333 #[test]
334 fn content_is_not_built_until_it_is_revealed() {
335 let (_tree, _reveal, builds, _host_builds, _id) = tree_with_host();
336 assert_eq!(builds.get(), 0, "content built while it was never revealed");
337 }
338
339 /// And rebuilding the host — what a table cell does constantly — still does
340 /// not build it. This is the case the measurement in the module doc is about.
341 #[test]
342 fn rebuilding_the_host_does_not_build_unrevealed_content() {
343 let (mut tree, _reveal, builds, host_builds, id) = tree_with_host();
344 for _ in 0..5 {
345 tree.arena_mark_needs_rebuild_for_testing(id);
346 tree.layout(SizeProposal::exact(200.0, 100.0));
347 }
348 assert!(
349 host_builds.get() >= 5,
350 "the host itself must really have rebuilt; got {}",
351 host_builds.get()
352 );
353 assert_eq!(
354 builds.get(),
355 0,
356 "the host rebuilt {} times and dragged its unopened content along",
357 host_builds.get()
358 );
359 }
360
361 /// Revealing builds it — once — and it is retained afterwards.
362 #[test]
363 fn revealing_builds_the_content_once_and_keeps_it() {
364 let (mut tree, reveal, builds, _host_builds, id) = tree_with_host();
365 reveal.set(true);
366 tree.layout(SizeProposal::exact(200.0, 100.0));
367 assert_eq!(builds.get(), 1, "revealing must build the content");
368
369 // Close and reopen: the content is retained, so it is not rebuilt.
370 // That retention is what the eager-then-dormant form bought, and what
371 // deferring must not give up — state inside a popover survives a close.
372 reveal.set(false);
373 tree.layout(SizeProposal::exact(200.0, 100.0));
374 reveal.set(true);
375 tree.layout(SizeProposal::exact(200.0, 100.0));
376 assert_eq!(
377 builds.get(),
378 1,
379 "content was rebuilt on reopen — its state would have been lost"
380 );
381
382 // And a plain rebuild of the host keeps it too.
383 tree.arena_mark_needs_rebuild_for_testing(id);
384 tree.layout(SizeProposal::exact(200.0, 100.0));
385 assert_eq!(builds.get(), 1);
386 }
387
388 /// The host is layout-transparent once built, and zero-size while not.
389 #[test]
390 fn the_host_reports_the_size_of_its_child_and_nothing_before_that() {
391 let (mut tree, reveal, _builds, _host_builds, host) = tree_with_host();
392 // Laid out under a proposal that leaves the height free, so the root's
393 // height follows what the subtree *reports*. Under `exact` every node is
394 // stretched to the box by its parent's placement, and the assertion
395 // would be measuring the placement policy rather than this widget.
396 tree.layout(SizeProposal::with_width(200.0));
397 assert_eq!(
398 tree.bounds(host).height,
399 0.0,
400 "an unbuilt deferred subtree must take no space"
401 );
402
403 reveal.set(true);
404 tree.layout(SizeProposal::with_width(200.0));
405 assert_eq!(
406 tree.bounds(host).height,
407 12.0,
408 "once built it is layout-transparent — the child's size, not its own"
409 );
410 }
411}