Skip to main content

teksilo_widgets/primitives/
switcher.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Switcher — a container that shows exactly one child page at a time.
5//!
6//! `Switcher` is the fundamental tab/wizard/step primitive: it owns N child
7//! pages and exposes only the one whose index matches the `Signal<usize>` it
8//! was constructed with. Switching is a signal write — the framework responds
9//! with a relayout that shows the new page and dormantizes all others (excluded
10//! from focus traversal, accessibility tree, hit-test, and paint).
11//!
12//! **Lazy mount.** Pages added via [`child`](Switcher::child) /
13//! [`children`](Switcher::children) / [`child_boxed`](Switcher::child_boxed)
14//! stay unconstructed until their index is selected for the first time. Once
15//! mounted, the page's subtree persists for the `Switcher`'s lifetime — switching
16//! away then back finds it in the exact state the user left it (focus, scroll
17//! offsets, text-input contents, signal subscriptions). Pages added via
18//! [`child_id`](Switcher::child_id) are pre-mounted by the caller and treated
19//! eagerly.
20//!
21//! The `Switcher` itself reports the maximum natural size across every
22//! currently-mounted page and stretches each placed page to its own bounds —
23//! all pages share the same slot, so the container size never jumps on a switch.
24//!
25//! ```rust
26//! # use teksilo_widgets::primitives::{Switcher, TextWidget};
27//! # use teksilo_core::signal::Signal;
28//! # use teksilo_i18n::lit;
29//! let page = Signal::new(0_usize);
30//! let _w = Switcher::new(page.clone())
31//!     .child(TextWidget::new(lit!("Step 1")))   // built at startup (index 0 is default)
32//!     .child(TextWidget::new(lit!("Step 2")))   // built on first page.set(1)
33//!     .child(TextWidget::new(lit!("Step 3")));  // built on first page.set(2)
34//! ```
35
36use std::cell::RefCell;
37use std::rc::Rc;
38
39use teksilo_canvas::{Point, Rect, Size, SizeProposal};
40
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::binding::BindingLevel;
43use teksilo_core::signal::Signal;
44use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
45use teksilo_core::widget_id::WidgetId;
46
47/// One entry inside a Switcher. `Pending` holds a page that has never
48/// been selected yet — its widget stays Boxed (zero arena footprint,
49/// zero `build()` cost) until the matching index becomes selected for
50/// the first time. `Mounted` carries the arena id from then on.
51enum Slot {
52    /// Deferred page: lives outside the arena until first selection.
53    Pending(Box<dyn Widget>),
54    /// Pre-mounted page: caller registered the widget themselves and
55    /// handed us the id; we eagerly wire `visible_when` and treat it
56    /// as immediately mounted (lazy semantics don't help here — the
57    /// construction cost has already been paid upstream).
58    PreMounted(WidgetId),
59    /// Page that has been mounted into the arena (either from
60    /// `PreMounted` on first build, or from `Pending` on first
61    /// selection). The id is preserved across Switcher rebuilds via
62    /// [`Widget::preserves_children_on_rebuild`].
63    Mounted(WidgetId),
64}
65
66/// A container that shows exactly one child at a time, driven by a
67/// `Signal<usize>` index.
68///
69/// **Lazy mount.** A page added via [`Self::child`] / [`Self::child_boxed`]
70/// / [`Self::children`] stays unconstructed until its index is first
71/// selected. Once mounted, the page's subtree persists for the
72/// Switcher's lifetime — switching away then back finds it in the
73/// state the user left it (focus, scroll, text-input contents, …).
74/// Pages added via [`Self::child_id`] are pre-mounted by the caller
75/// and treated eagerly: no lazy benefit, no semantic change.
76///
77/// The Switcher itself reports the maximum natural size across every
78/// currently-mounted page and stretches each placed child to its own
79/// bounds (top-leading, RTL-aware). Hidden pages keep their subtree
80/// laid out but invisible via per-page `visible_when` bindings.
81///
82/// ```rust
83/// # use teksilo_widgets::primitives::{Switcher, TextWidget};
84/// # use teksilo_core::signal::Signal;
85/// # use teksilo_i18n::lit;
86/// let page = Signal::new(0_usize);
87/// let _w = Switcher::new(page.clone())
88///     .child(TextWidget::new(lit!("Page 0")))   // built at startup
89///     .child(TextWidget::new(lit!("Page 1")))   // built when page.set(1)
90///     .child(TextWidget::new(lit!("Page 2")));  // built when page.set(2)
91/// ```
92pub struct Switcher {
93    selected: Signal<usize>,
94    slots: Vec<Slot>,
95    /// Optional external buffer populated during `build()` with the
96    /// `WidgetId` of every currently-mounted page in declaration order.
97    /// `Pending` slots contribute nothing — callers that need every
98    /// page's id available before first selection must pre-mount via
99    /// [`Self::child_id`].
100    child_ids_out: Option<Rc<RefCell<Vec<WidgetId>>>>,
101}
102
103impl Switcher {
104    /// Create a `Switcher` driven by `selected`. The initially selected index
105    /// is `selected.get()` at build time; page 0 is mounted immediately if that
106    /// is the starting value (the most common case).
107    pub fn new(selected: Signal<usize>) -> Self {
108        Self {
109            selected,
110            slots: Vec::new(),
111            child_ids_out: None,
112        }
113    }
114
115    /// Capture each mounted page's `WidgetId` into an externally owned
116    /// buffer during `build()`. Use when the caller needs to reference
117    /// pages after they're added to the arena — e.g. for accessibility
118    /// relations like Tab → TabPanel.
119    ///
120    /// The buffer reflects the **currently-mounted** set, not every
121    /// declared page. With lazy mount, a page added via `child(...)`
122    /// only appears in the buffer once it has been selected for the
123    /// first time. Callers that need every id up front should pass
124    /// pre-mounted ids via [`Self::child_id`] instead — those are
125    /// eagerly recorded.
126    pub fn capture_child_ids_into(mut self, out: Rc<RefCell<Vec<WidgetId>>>) -> Self {
127        self.child_ids_out = Some(out);
128        self
129    }
130
131    /// Add a child page. The widget stays Boxed until its index is
132    /// selected for the first time, then is mounted into the arena
133    /// and kept alive across selection changes.
134    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
135        self.slots.push(Slot::Pending(Box::new(widget)));
136        self
137    }
138
139    /// Add a pre-boxed child page (lazy, same as [`Self::child`]).
140    pub fn child_boxed(mut self, widget: Box<dyn Widget>) -> Self {
141        self.slots.push(Slot::Pending(widget));
142        self
143    }
144
145    /// Add a child page by its already-allocated `WidgetId`. Pre-mounted
146    /// pages are wired eagerly — the lazy path doesn't apply because
147    /// the caller has already paid the construction cost.
148    pub fn child_id(mut self, id: WidgetId) -> Self {
149        self.slots.push(Slot::PreMounted(id));
150        self
151    }
152
153    /// Add multiple child pages from an iterator (lazy, same as
154    /// [`Self::child`]).
155    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
156        for widget in iter {
157            self.slots.push(Slot::Pending(Box::new(widget)));
158        }
159        self
160    }
161}
162
163impl std::fmt::Debug for Switcher {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.debug_struct("Switcher")
166            .field("num_children", &self.slots.len())
167            .finish()
168    }
169}
170
171impl Widget for Switcher {
172    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
173        let self_id = ctx.self_id();
174
175        // `selected` flips drive Switcher rebuilds: a flip onto an
176        // already-mounted page costs an idempotent re-registration of
177        // visibility bindings (cheap); a flip onto a `Pending` slot
178        // triggers the lazy `ctx.add_boxed` below. Rebuild level is
179        // load-bearing — without it the framework would only repaint
180        // and the unmounted page would never get built.
181        self.selected
182            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
183
184        let current = self.selected.get();
185
186        // Walk every still-Pending slot's static
187        // `Widget::declare_shortcuts` and pre-register the metadata
188        // owned by this Switcher. This makes shortcuts buried inside
189        // a not-yet-selected page visible to `ShortcutSettings` (and
190        // any other registry consumer) from the moment the Switcher
191        // builds — without paying the cost of mounting the page. When
192        // the page is eventually mounted, the framework's insert-time
193        // declaration walk re-registers the same ids owned by the
194        // page widget; the registry upserts cleanly.
195        for slot in self.slots.iter() {
196            if let Slot::Pending(widget) = slot {
197                let declared = widget.declare_shortcuts();
198                if !declared.is_empty() {
199                    ctx.register_pending_shortcuts(declared);
200                }
201            }
202        }
203
204        // Materialize: promote PreMounted → Mounted on first build,
205        // and promote Pending → Mounted when its index becomes
206        // selected. Pending slots untouched here stay Pending; they
207        // contribute zero work to the arena until visited.
208        for (i, slot) in self.slots.iter_mut().enumerate() {
209            match slot {
210                Slot::PreMounted(id) => {
211                    *slot = Slot::Mounted(*id);
212                }
213                Slot::Pending(_) if i == current => {
214                    let widget = match std::mem::replace(slot, Slot::Mounted(WidgetId::default())) {
215                        Slot::Pending(w) => w,
216                        _ => unreachable!(),
217                    };
218                    let id = ctx.add_boxed(widget);
219                    *slot = Slot::Mounted(id);
220                }
221                _ => {}
222            }
223        }
224
225        // Wire `visible_when` on every mounted page. The binding
226        // registry deduplicates per `(widget_id, source_id, level)`
227        // tuple, so calling this on every rebuild collapses to the
228        // same single entry — no accumulation.
229        for (i, slot) in self.slots.iter().enumerate() {
230            if let Slot::Mounted(id) = slot {
231                let idx = i;
232                let vis = self.selected.map(move |s| *s == idx);
233                ctx.visible_when(*id, vis);
234            }
235        }
236
237        // Publish currently-mounted ids to the external buffer.
238        if let Some(ref out) = self.child_ids_out {
239            let mut buf = out.borrow_mut();
240            buf.clear();
241            for slot in &self.slots {
242                if let Slot::Mounted(id) = slot {
243                    buf.push(*id);
244                }
245            }
246        }
247
248        // Children: every mounted page, in declaration order. The
249        // framework calls `preserves_children_on_rebuild` and skips
250        // the subtree teardown that would otherwise destroy the
251        // mounted pages' state on every selection change.
252        self.slots
253            .iter()
254            .filter_map(|s| match s {
255                Slot::Mounted(id) => Some(*id),
256                _ => None,
257            })
258            .collect()
259    }
260
261    fn preserves_children_on_rebuild(&self) -> bool {
262        // Mounted pages survive selection-driven rebuilds. The
263        // alternative — letting the framework destroy them — would
264        // wipe focus, scroll offsets, text-input contents, and any
265        // signal subscriptions every time the user clicked a
266        // different tab.
267        true
268    }
269
270    fn layout_response(
271        &self,
272        proposal: SizeProposal,
273        ctx: &LayoutContext,
274    ) -> teksilo_core::widget::LayoutResponse {
275        // Max of the mounted pages' sizes *at the incoming proposal*, so the
276        // switcher keeps a stable size across selection changes (flipping
277        // pages must not resize the slot) without inventing width.
278        //
279        // This deliberately does NOT measure at `SizeProposal::unspecified()`.
280        // Doing so reports each page's NATURAL size: wrapped text lays out on
281        // a single line, `Wrap` never wraps, and the switcher then hands its
282        // parent a width derived from content instead of from the space it was
283        // actually offered. `place_children` below already measures at the real
284        // bounds (`exact_proposal`), so the two disagreed — the reported size
285        // said "natural" while placement said "bounds". An enclosing
286        // `ScrollArea` believed the natural figure and sized its content to it.
287        //
288        // A parent that genuinely hugs its content passes an unspecified
289        // proposal, which forwards through unchanged — so the size-to-content
290        // case (menu / popover pages) keeps its previous behaviour, including
291        // background-style pages that report 0×0 for an unspecified proposal.
292        let mut max_w: f32 = 0.0;
293        let mut max_h: f32 = 0.0;
294        let mut any = false;
295        for slot in &self.slots {
296            if let Slot::Mounted(id) = slot
297                && let Some(child_size) = ctx.child_size(*id, proposal)
298            {
299                max_w = max_w.max(child_size.width);
300                max_h = max_h.max(child_size.height);
301                any = true;
302            }
303        }
304        if any {
305            Size::new(max_w, max_h)
306        } else {
307            proposal.resolve(0.0, 0.0)
308        }
309        .into()
310    }
311
312    fn place_children(
313        &self,
314        bounds: Rect,
315        _proposal: SizeProposal,
316        children: &mut [WidgetPlacement],
317        ctx: &LayoutContext,
318    ) {
319        // Top-leading layout (matches the ZStack-with-TOP_LEADING
320        // alignment the previous wrapper used). Background widgets
321        // that take the exact proposal fill the bounds; widgets with
322        // an intrinsic natural size sit at the top-leading corner
323        // (RTL-aware).
324        let rtl = ctx.is_rtl();
325        let exact_proposal = SizeProposal::exact(bounds.width, bounds.height);
326        for child in children.iter_mut() {
327            let child_size = ctx
328                .child_size(child.id, exact_proposal)
329                .unwrap_or_else(|| bounds.size());
330            let dx = if rtl {
331                bounds.width - child_size.width
332            } else {
333                0.0
334            };
335            child.origin = Point::new(bounds.x + dx, bounds.y);
336            child.size = child_size;
337        }
338    }
339
340    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
341        builder.set_hidden();
342    }
343
344    fn children(&self) -> Vec<WidgetId> {
345        self.slots
346            .iter()
347            .filter_map(|s| match s {
348                Slot::Mounted(id) => Some(*id),
349                _ => None,
350            })
351            .collect()
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use teksilo_canvas::Size;
359    use teksilo_core::widget_tree::WidgetTree;
360
361    #[derive(Debug)]
362    struct FixedLeaf(f32, f32);
363    impl Widget for FixedLeaf {
364        fn layout_response(
365            &self,
366            _proposal: SizeProposal,
367            _ctx: &LayoutContext,
368        ) -> teksilo_core::widget::LayoutResponse {
369            Size::new(self.0, self.1).into()
370        }
371    }
372
373    /// Counts `build()` invocations so we can assert lazy-mount
374    /// semantics: a page should `build()` at most once, and only
375    /// after its index has been selected.
376    #[derive(Debug)]
377    struct CountingLeaf {
378        build_calls: Rc<std::cell::Cell<u32>>,
379        size: (f32, f32),
380    }
381    impl CountingLeaf {
382        fn new(w: f32, h: f32) -> (Self, Rc<std::cell::Cell<u32>>) {
383            let counter = Rc::new(std::cell::Cell::new(0));
384            (
385                Self {
386                    build_calls: counter.clone(),
387                    size: (w, h),
388                },
389                counter,
390            )
391        }
392    }
393    impl Widget for CountingLeaf {
394        fn build(&mut self, _ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
395            self.build_calls.set(self.build_calls.get() + 1);
396            Vec::new()
397        }
398        fn layout_response(
399            &self,
400            _proposal: SizeProposal,
401            _ctx: &LayoutContext,
402        ) -> teksilo_core::widget::LayoutResponse {
403            Size::new(self.size.0, self.size.1).into()
404        }
405    }
406
407    #[test]
408    fn switcher_builds_and_lays_out() {
409        let selected = Signal::new(1_usize);
410        let mut tree = WidgetTree::new();
411
412        let switcher_id = tree.add(
413            Switcher::new(selected.clone())
414                .child(FixedLeaf(100.0, 40.0))
415                .child(FixedLeaf(80.0, 30.0))
416                .child(FixedLeaf(60.0, 20.0)),
417        );
418
419        tree.layout(SizeProposal::exact(200.0, 200.0));
420
421        assert!(tree.is_visible(switcher_id));
422        let bounds = tree.bounds(switcher_id);
423        assert!(bounds.width > 0.0);
424        assert!(bounds.height > 0.0);
425    }
426
427    /// Only the initially-selected page should ever have its
428    /// `build()` called. Unvisited pages stay `Pending` and pay no
429    /// arena / construction cost.
430    #[test]
431    fn unvisited_pages_never_build() {
432        let selected = Signal::new(0_usize);
433        let (page0, c0) = CountingLeaf::new(50.0, 50.0);
434        let (page1, c1) = CountingLeaf::new(60.0, 60.0);
435        let (page2, c2) = CountingLeaf::new(70.0, 70.0);
436
437        let mut tree = WidgetTree::new();
438        let _id = tree.add(
439            Switcher::new(selected.clone())
440                .child(page0)
441                .child(page1)
442                .child(page2),
443        );
444        tree.layout(SizeProposal::exact(200.0, 200.0));
445
446        assert_eq!(c0.get(), 1, "selected page must be built");
447        assert_eq!(c1.get(), 0, "unvisited page must not build");
448        assert_eq!(c2.get(), 0, "unvisited page must not build");
449    }
450
451    /// Switching to a previously-unvisited index mounts that page
452    /// lazily; older pages stay alive (their `build()` count must
453    /// not increment again — they are preserved, not rebuilt).
454    #[test]
455    fn switching_mounts_lazily_and_preserves_prior_pages() {
456        let selected = Signal::new(0_usize);
457        let (page0, c0) = CountingLeaf::new(50.0, 50.0);
458        let (page1, c1) = CountingLeaf::new(60.0, 60.0);
459        let (page2, c2) = CountingLeaf::new(70.0, 70.0);
460
461        let mut tree = WidgetTree::new();
462        let _id = tree.add(
463            Switcher::new(selected.clone())
464                .child(page0)
465                .child(page1)
466                .child(page2),
467        );
468        tree.layout(SizeProposal::exact(200.0, 200.0));
469        assert_eq!((c0.get(), c1.get(), c2.get()), (1, 0, 0));
470
471        selected.set(1);
472        tree.layout(SizeProposal::exact(200.0, 200.0));
473        assert_eq!(
474            (c0.get(), c1.get(), c2.get()),
475            (1, 1, 0),
476            "page 1 mounts on first visit; page 0 is preserved (not rebuilt)"
477        );
478
479        selected.set(0);
480        tree.layout(SizeProposal::exact(200.0, 200.0));
481        assert_eq!(
482            (c0.get(), c1.get(), c2.get()),
483            (1, 1, 0),
484            "returning to page 0 must reuse the existing subtree"
485        );
486
487        selected.set(2);
488        tree.layout(SizeProposal::exact(200.0, 200.0));
489        assert_eq!(
490            (c0.get(), c1.get(), c2.get()),
491            (1, 1, 1),
492            "page 2 mounts on first visit"
493        );
494    }
495
496    /// A non-selected mounted page must go *dormant*, not merely
497    /// unpainted. Dormant nodes are excluded from the AccessKit walk,
498    /// focus traversal, hit-test, and paint (all gate on `is_active`),
499    /// so this pins the `visible_when` → `set_dormant` wiring the
500    /// Switcher relies on to keep hidden tabs out of the a11y tree and
501    /// the tab order.
502    #[test]
503    fn hidden_page_is_dormant_and_excluded_from_at() {
504        let selected = Signal::new(0_usize);
505        let ids = Rc::new(RefCell::new(Vec::new()));
506        let mut tree = WidgetTree::new();
507        let _switcher = tree.add(
508            Switcher::new(selected.clone())
509                .capture_child_ids_into(ids.clone())
510                .child(FixedLeaf(50.0, 50.0))
511                .child(FixedLeaf(60.0, 60.0)),
512        );
513
514        // Visit page 0 then page 1 so BOTH pages are mounted.
515        tree.layout(SizeProposal::exact(200.0, 200.0));
516        selected.set(1);
517        tree.layout(SizeProposal::exact(200.0, 200.0));
518
519        let (page0, page1) = {
520            let ids = ids.borrow();
521            assert_eq!(ids.len(), 2, "both pages mounted after each is visited");
522            (ids[0], ids[1])
523        };
524
525        // Selected page: active + visible. Hidden page: dormant + invisible.
526        assert!(tree.is_active(page1), "selected page must be active");
527        assert!(tree.is_visible(page1), "selected page must be visible");
528        assert!(
529            !tree.is_active(page0),
530            "hidden page must be dormant — excluded from AT / focus / hit-test"
531        );
532        assert!(!tree.is_visible(page0), "hidden page must be invisible");
533
534        // Switching back reactivates page 0 and dormant-izes page 1.
535        selected.set(0);
536        tree.layout(SizeProposal::exact(200.0, 200.0));
537        assert!(tree.is_active(page0) && tree.is_visible(page0));
538        assert!(
539            !tree.is_active(page1),
540            "previously-shown page must now be dormant"
541        );
542    }
543
544    /// `child_id` pages are pre-mounted by the caller, so unlike a
545    /// `Pending` page added via `child()` (which stays unbuilt until
546    /// selected — see `unvisited_pages_never_build`), a `PreMounted`
547    /// page is built eagerly on first build even when it is not the
548    /// selected index. Visibility still tracks selection, and switching
549    /// to it must not rebuild it.
550    #[test]
551    fn premounted_child_id_pages_build_eagerly_unlike_pending() {
552        let selected = Signal::new(0_usize);
553        let (page1, c1) = CountingLeaf::new(60.0, 60.0);
554
555        let mut tree = WidgetTree::new();
556        let p0 = tree.add(FixedLeaf(50.0, 50.0));
557        let p1 = tree.add(page1); // caller pre-mounts the page
558        let _switcher = tree.add(Switcher::new(selected.clone()).child_id(p0).child_id(p1));
559        tree.layout(SizeProposal::exact(200.0, 200.0));
560
561        // Page 1 is NOT selected, yet it has already been built because
562        // it was pre-mounted via `child_id` — the eager path. A `Pending`
563        // page in the same position would have a build count of 0.
564        assert_eq!(
565            c1.get(),
566            1,
567            "PreMounted page builds eagerly even when not selected"
568        );
569        assert!(tree.is_visible(p0), "selected page visible");
570        assert!(!tree.is_visible(p1), "non-selected page hidden");
571
572        // Selecting page 1 reveals it without rebuilding.
573        selected.set(1);
574        tree.layout(SizeProposal::exact(200.0, 200.0));
575        assert!(tree.is_visible(p1), "switched-to page visible");
576        assert!(!tree.is_visible(p0), "switched-from page hidden");
577        assert_eq!(c1.get(), 1, "switching must not rebuild the page");
578    }
579
580    /// `Widget::declare_shortcuts` returned by a Pending Switcher page
581    /// must be registered in the shortcut registry before the page is
582    /// mounted — settings UIs depend on seeing the full keystroke
583    /// catalog without forcing every lazy branch to build.
584    #[test]
585    fn switcher_pending_pages_declare_shortcuts_eagerly() {
586        use teksilo_core::event::Key;
587        use teksilo_core::shortcut::{KeyStroke, Shortcut};
588
589        #[derive(Debug)]
590        struct LazyWithShortcuts(Rc<std::cell::Cell<u32>>);
591        impl Widget for LazyWithShortcuts {
592            fn declare_shortcuts(&self) -> Vec<Shortcut> {
593                vec![
594                    Shortcut::new("__test.lazy.action")
595                        .name("Lazy Action")
596                        .primary(KeyStroke::ctrl(Key::L))
597                        .build(),
598                ]
599            }
600            fn build(
601                &mut self,
602                _ctx: &mut teksilo_core::build_context::BuildContext,
603            ) -> Vec<WidgetId> {
604                self.0.set(self.0.get() + 1);
605                Vec::new()
606            }
607            fn layout_response(
608                &self,
609                _proposal: SizeProposal,
610                _ctx: &LayoutContext,
611            ) -> teksilo_core::widget::LayoutResponse {
612                Size::new(10.0, 10.0).into()
613            }
614        }
615
616        let selected = Signal::new(0_usize);
617        let build_count = Rc::new(std::cell::Cell::new(0));
618        let mut tree = WidgetTree::new();
619        let _id = tree.add(
620            Switcher::new(selected.clone())
621                .child(FixedLeaf(50.0, 50.0))
622                .child(LazyWithShortcuts(build_count.clone())),
623        );
624        tree.layout(SizeProposal::exact(200.0, 200.0));
625
626        assert_eq!(
627            build_count.get(),
628            0,
629            "lazy page must not have built — index 1 was never selected"
630        );
631        assert!(
632            tree.shortcut_registry()
633                .get_default("__test.lazy.action")
634                .is_some(),
635            "Switcher must pre-register Pending pages' declared shortcuts"
636        );
637    }
638}