Skip to main content

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}