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`](Switcher::child)
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`](Switcher::child) 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`]
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`] 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`].
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`] 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. An `impl 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. A `WidgetId` (an
134    /// already-mounted page) is pre-mounted immediately instead, built
135    /// eagerly regardless of the selected index — see `Slot::PreMounted`.
136    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
137        match teksilo_core::IntoTeksiChild::into_pending(widget) {
138            teksilo_core::PendingChild::Id(id) => {
139                self.slots.push(Slot::PreMounted(id));
140                self
141            }
142            teksilo_core::PendingChild::Deferred(w) => {
143                self.slots.push(Slot::Pending(w));
144                self
145            }
146        }
147    }
148    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
149    ///
150    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
151    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
152    /// adds no arena node, so nothing is laid out, painted, or published to the
153    /// accessibility tree, and a stack applies no spacing around it.
154    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
155        match widget {
156            Some(w) => self.child(w),
157            None => self,
158        }
159    }
160
161    /// Add multiple child pages from an iterator (lazy, same as
162    /// [`Self::child`]).
163    pub fn children(
164        self,
165        iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
166    ) -> Self {
167        iter.into_iter().fold(self, Self::child)
168    }
169}
170
171impl std::fmt::Debug for Switcher {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.debug_struct("Switcher")
174            .field("num_children", &self.slots.len())
175            .finish()
176    }
177}
178
179impl Widget for Switcher {
180    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
181        let self_id = ctx.self_id();
182
183        // `selected` flips drive Switcher rebuilds: a flip onto an
184        // already-mounted page costs an idempotent re-registration of
185        // visibility bindings (cheap); a flip onto a `Pending` slot
186        // triggers the lazy `ctx.add_boxed` below. Rebuild level is
187        // load-bearing — without it the framework would only repaint
188        // and the unmounted page would never get built.
189        self.selected
190            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
191
192        let current = self.selected.get();
193
194        // Walk every still-Pending slot's static
195        // `Widget::declare_shortcuts` and pre-register the metadata
196        // owned by this Switcher. This makes shortcuts buried inside
197        // a not-yet-selected page visible to `ShortcutSettings` (and
198        // any other registry consumer) from the moment the Switcher
199        // builds — without paying the cost of mounting the page. When
200        // the page is eventually mounted, the framework's insert-time
201        // declaration walk re-registers the same ids owned by the
202        // page widget; the registry upserts cleanly.
203        for slot in self.slots.iter() {
204            if let Slot::Pending(widget) = slot {
205                let declared = widget.declare_shortcuts();
206                if !declared.is_empty() {
207                    ctx.register_pending_shortcuts(declared);
208                }
209            }
210        }
211
212        // Materialize: promote PreMounted → Mounted on first build,
213        // and promote Pending → Mounted when its index becomes
214        // selected. Pending slots untouched here stay Pending; they
215        // contribute zero work to the arena until visited.
216        for (i, slot) in self.slots.iter_mut().enumerate() {
217            match slot {
218                Slot::PreMounted(id) => {
219                    *slot = Slot::Mounted(*id);
220                }
221                Slot::Pending(_) if i == current => {
222                    let widget = match std::mem::replace(slot, Slot::Mounted(WidgetId::default())) {
223                        Slot::Pending(w) => w,
224                        _ => unreachable!(),
225                    };
226                    let id = ctx.add_boxed(widget);
227                    *slot = Slot::Mounted(id);
228                }
229                _ => {}
230            }
231        }
232
233        // Wire `visible_when` on every mounted page. The binding
234        // registry deduplicates per `(widget_id, source_id, level)`
235        // tuple, so calling this on every rebuild collapses to the
236        // same single entry — no accumulation.
237        for (i, slot) in self.slots.iter().enumerate() {
238            if let Slot::Mounted(id) = slot {
239                let idx = i;
240                let vis = self.selected.map(move |s| *s == idx);
241                ctx.visible_when(*id, vis);
242            }
243        }
244
245        // Publish currently-mounted ids to the external buffer.
246        if let Some(ref out) = self.child_ids_out {
247            let mut buf = out.borrow_mut();
248            buf.clear();
249            for slot in &self.slots {
250                if let Slot::Mounted(id) = slot {
251                    buf.push(*id);
252                }
253            }
254        }
255
256        // Children: every mounted page, in declaration order. The
257        // framework calls `preserves_children_on_rebuild` and skips
258        // the subtree teardown that would otherwise destroy the
259        // mounted pages' state on every selection change.
260        self.slots
261            .iter()
262            .filter_map(|s| match s {
263                Slot::Mounted(id) => Some(*id),
264                _ => None,
265            })
266            .collect()
267    }
268
269    fn preserves_children_on_rebuild(&self) -> bool {
270        // Mounted pages survive selection-driven rebuilds. The
271        // alternative — letting the framework destroy them — would
272        // wipe focus, scroll offsets, text-input contents, and any
273        // signal subscriptions every time the user clicked a
274        // different tab.
275        true
276    }
277
278    fn layout_response(
279        &self,
280        proposal: SizeProposal,
281        ctx: &LayoutContext,
282    ) -> teksilo_core::widget::LayoutResponse {
283        // Max of the mounted pages' sizes *at the incoming proposal*, so the
284        // switcher keeps a stable size across selection changes (flipping
285        // pages must not resize the slot) without inventing width.
286        //
287        // This deliberately does NOT measure at `SizeProposal::unspecified()`.
288        // Doing so reports each page's NATURAL size: wrapped text lays out on
289        // a single line, `Wrap` never wraps, and the switcher then hands its
290        // parent a width derived from content instead of from the space it was
291        // actually offered. `place_children` below already measures at the real
292        // bounds (`exact_proposal`), so the two disagreed — the reported size
293        // said "natural" while placement said "bounds". An enclosing
294        // `ScrollArea` believed the natural figure and sized its content to it.
295        //
296        // A parent that genuinely hugs its content passes an unspecified
297        // proposal, which forwards through unchanged — so the size-to-content
298        // case (menu / popover pages) keeps its previous behaviour, including
299        // background-style pages that report 0×0 for an unspecified proposal.
300        let mut max_w: f32 = 0.0;
301        let mut max_h: f32 = 0.0;
302        let mut any = false;
303        for slot in &self.slots {
304            if let Slot::Mounted(id) = slot
305                && let Some(child_size) = ctx.child_size(*id, proposal)
306            {
307                max_w = max_w.max(child_size.width);
308                max_h = max_h.max(child_size.height);
309                any = true;
310            }
311        }
312        if any {
313            Size::new(max_w, max_h)
314        } else {
315            proposal.resolve(0.0, 0.0)
316        }
317        .into()
318    }
319
320    fn place_children(
321        &self,
322        bounds: Rect,
323        _proposal: SizeProposal,
324        children: &mut [WidgetPlacement],
325        ctx: &LayoutContext,
326    ) {
327        // Top-leading layout (matches the ZStack-with-TOP_LEADING
328        // alignment the previous wrapper used). Background widgets
329        // that take the exact proposal fill the bounds; widgets with
330        // an intrinsic natural size sit at the top-leading corner
331        // (RTL-aware).
332        let rtl = ctx.is_rtl();
333        let exact_proposal = SizeProposal::exact(bounds.width, bounds.height);
334        for child in children.iter_mut() {
335            let child_size = ctx
336                .child_size(child.id, exact_proposal)
337                .unwrap_or_else(|| bounds.size());
338            let dx = if rtl {
339                bounds.width - child_size.width
340            } else {
341                0.0
342            };
343            child.origin = Point::new(bounds.x + dx, bounds.y);
344            child.size = child_size;
345        }
346    }
347
348    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
349        // A bare `GenericContainer`, which the walker prunes and every
350        // adapter drops with the shown page kept. The pages not shown are
351        // dormant, which is what keeps them out of the tree. Never
352        // `set_hidden()`, which an adapter reads as hiding the whole
353        // subtree: the shown page went with it, and so did every
354        // `TabWidget` panel and `Stepper` step, which are shown through here.
355        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
356    }
357
358    fn children(&self) -> Vec<WidgetId> {
359        self.slots
360            .iter()
361            .filter_map(|s| match s {
362                Slot::Mounted(id) => Some(*id),
363                _ => None,
364            })
365            .collect()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use teksilo_canvas::Size;
373    use teksilo_core::widget_tree::WidgetTree;
374
375    #[derive(Debug)]
376    struct FixedLeaf(f32, f32);
377    impl Widget for FixedLeaf {
378        fn layout_response(
379            &self,
380            _proposal: SizeProposal,
381            _ctx: &LayoutContext,
382        ) -> teksilo_core::widget::LayoutResponse {
383            Size::new(self.0, self.1).into()
384        }
385    }
386
387    /// Counts `build()` invocations so we can assert lazy-mount
388    /// semantics: a page should `build()` at most once, and only
389    /// after its index has been selected.
390    #[derive(Debug)]
391    struct CountingLeaf {
392        build_calls: Rc<std::cell::Cell<u32>>,
393        size: (f32, f32),
394    }
395    impl CountingLeaf {
396        fn new(w: f32, h: f32) -> (Self, Rc<std::cell::Cell<u32>>) {
397            let counter = Rc::new(std::cell::Cell::new(0));
398            (
399                Self {
400                    build_calls: counter.clone(),
401                    size: (w, h),
402                },
403                counter,
404            )
405        }
406    }
407    impl Widget for CountingLeaf {
408        fn build(&mut self, _ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
409            self.build_calls.set(self.build_calls.get() + 1);
410            Vec::new()
411        }
412        fn layout_response(
413            &self,
414            _proposal: SizeProposal,
415            _ctx: &LayoutContext,
416        ) -> teksilo_core::widget::LayoutResponse {
417            Size::new(self.size.0, self.size.1).into()
418        }
419    }
420
421    #[test]
422    fn switcher_builds_and_lays_out() {
423        let selected = Signal::new(1_usize);
424        let mut tree = WidgetTree::new();
425
426        let switcher_id = tree.add(
427            Switcher::new(selected.clone())
428                .child(FixedLeaf(100.0, 40.0))
429                .child(FixedLeaf(80.0, 30.0))
430                .child(FixedLeaf(60.0, 20.0)),
431        );
432
433        tree.layout(SizeProposal::exact(200.0, 200.0));
434
435        assert!(tree.is_visible(switcher_id));
436        let bounds = tree.bounds(switcher_id);
437        assert!(bounds.width > 0.0);
438        assert!(bounds.height > 0.0);
439    }
440
441    /// Only the initially-selected page should ever have its
442    /// `build()` called. Unvisited pages stay `Pending` and pay no
443    /// arena / construction cost.
444    #[test]
445    fn unvisited_pages_never_build() {
446        let selected = Signal::new(0_usize);
447        let (page0, c0) = CountingLeaf::new(50.0, 50.0);
448        let (page1, c1) = CountingLeaf::new(60.0, 60.0);
449        let (page2, c2) = CountingLeaf::new(70.0, 70.0);
450
451        let mut tree = WidgetTree::new();
452        let _id = tree.add(
453            Switcher::new(selected.clone())
454                .child(page0)
455                .child(page1)
456                .child(page2),
457        );
458        tree.layout(SizeProposal::exact(200.0, 200.0));
459
460        assert_eq!(c0.get(), 1, "selected page must be built");
461        assert_eq!(c1.get(), 0, "unvisited page must not build");
462        assert_eq!(c2.get(), 0, "unvisited page must not build");
463    }
464
465    /// Switching to a previously-unvisited index mounts that page
466    /// lazily; older pages stay alive (their `build()` count must
467    /// not increment again — they are preserved, not rebuilt).
468    #[test]
469    fn switching_mounts_lazily_and_preserves_prior_pages() {
470        let selected = Signal::new(0_usize);
471        let (page0, c0) = CountingLeaf::new(50.0, 50.0);
472        let (page1, c1) = CountingLeaf::new(60.0, 60.0);
473        let (page2, c2) = CountingLeaf::new(70.0, 70.0);
474
475        let mut tree = WidgetTree::new();
476        let _id = tree.add(
477            Switcher::new(selected.clone())
478                .child(page0)
479                .child(page1)
480                .child(page2),
481        );
482        tree.layout(SizeProposal::exact(200.0, 200.0));
483        assert_eq!((c0.get(), c1.get(), c2.get()), (1, 0, 0));
484
485        selected.set(1);
486        tree.layout(SizeProposal::exact(200.0, 200.0));
487        assert_eq!(
488            (c0.get(), c1.get(), c2.get()),
489            (1, 1, 0),
490            "page 1 mounts on first visit; page 0 is preserved (not rebuilt)"
491        );
492
493        selected.set(0);
494        tree.layout(SizeProposal::exact(200.0, 200.0));
495        assert_eq!(
496            (c0.get(), c1.get(), c2.get()),
497            (1, 1, 0),
498            "returning to page 0 must reuse the existing subtree"
499        );
500
501        selected.set(2);
502        tree.layout(SizeProposal::exact(200.0, 200.0));
503        assert_eq!(
504            (c0.get(), c1.get(), c2.get()),
505            (1, 1, 1),
506            "page 2 mounts on first visit"
507        );
508    }
509
510    /// A non-selected mounted page must go *dormant*, not merely
511    /// unpainted. Dormant nodes are excluded from the AccessKit walk,
512    /// focus traversal, hit-test, and paint (all gate on `is_active`),
513    /// so this pins the `visible_when` → `set_dormant` wiring the
514    /// Switcher relies on to keep hidden tabs out of the a11y tree and
515    /// the tab order.
516    #[test]
517    fn hidden_page_is_dormant_and_excluded_from_at() {
518        let selected = Signal::new(0_usize);
519        let ids = Rc::new(RefCell::new(Vec::new()));
520        let mut tree = WidgetTree::new();
521        let _switcher = tree.add(
522            Switcher::new(selected.clone())
523                .capture_child_ids_into(ids.clone())
524                .child(FixedLeaf(50.0, 50.0))
525                .child(FixedLeaf(60.0, 60.0)),
526        );
527
528        // Visit page 0 then page 1 so BOTH pages are mounted.
529        tree.layout(SizeProposal::exact(200.0, 200.0));
530        selected.set(1);
531        tree.layout(SizeProposal::exact(200.0, 200.0));
532
533        let (page0, page1) = {
534            let ids = ids.borrow();
535            assert_eq!(ids.len(), 2, "both pages mounted after each is visited");
536            (ids[0], ids[1])
537        };
538
539        // Selected page: active + visible. Hidden page: dormant + invisible.
540        assert!(tree.is_active(page1), "selected page must be active");
541        assert!(tree.is_visible(page1), "selected page must be visible");
542        assert!(
543            !tree.is_active(page0),
544            "hidden page must be dormant — excluded from AT / focus / hit-test"
545        );
546        assert!(!tree.is_visible(page0), "hidden page must be invisible");
547
548        // Switching back reactivates page 0 and dormant-izes page 1.
549        selected.set(0);
550        tree.layout(SizeProposal::exact(200.0, 200.0));
551        assert!(tree.is_active(page0) && tree.is_visible(page0));
552        assert!(
553            !tree.is_active(page1),
554            "previously-shown page must now be dormant"
555        );
556    }
557
558    /// A leaf a screen reader can find: a named button.
559    #[derive(Debug)]
560    struct NamedLeaf(&'static str);
561    impl Widget for NamedLeaf {
562        fn layout_response(
563            &self,
564            _proposal: SizeProposal,
565            _ctx: &LayoutContext,
566        ) -> teksilo_core::widget::LayoutResponse {
567            Size::new(40.0, 20.0).into()
568        }
569        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
570            builder.set_role(teksilo_core::accesskit::Role::Button);
571            builder.set_name(self.0);
572        }
573    }
574
575    /// The names a platform adapter would find walking `tree`, which is the
576    /// tree after `accesskit_consumer`'s `common_filter`, the filter AT-SPI,
577    /// UIA and macOS all walk with.
578    fn names_a_screen_reader_finds(tree: &mut WidgetTree) -> Vec<String> {
579        fn walk(node: accesskit_consumer::NodeRef<'_>, out: &mut Vec<String>) {
580            if let Some(name) = node.label() {
581                out.push(name);
582            }
583            for child in node.filtered_children(&accesskit_consumer::common_filter) {
584                walk(child, out);
585            }
586        }
587        let platform = accesskit_consumer::Tree::new(tree.sync_accessibility(), false);
588        let mut out = Vec::new();
589        walk(platform.state().root(), &mut out);
590        out
591    }
592
593    /// The page on show reaches assistive technology, and only that page.
594    ///
595    /// The switcher marked itself hidden, and a hidden node takes its whole
596    /// subtree out of every platform's tree, so the page on show was as
597    /// absent as the others: the day grid of every `Calendar` sits in a
598    /// switcher, and none of its days reached a screen reader.
599    #[test]
600    fn the_page_on_show_reaches_a_screen_reader() {
601        let selected = Signal::new(0_usize);
602        let mut tree = WidgetTree::new();
603        let _switcher = tree.add(
604            Switcher::new(selected.clone())
605                .child(NamedLeaf("first page"))
606                .child(NamedLeaf("second page")),
607        );
608        tree.layout(SizeProposal::exact(200.0, 200.0));
609        assert_eq!(names_a_screen_reader_finds(&mut tree), vec!["first page"]);
610
611        selected.set(1);
612        tree.layout(SizeProposal::exact(200.0, 200.0));
613        assert_eq!(names_a_screen_reader_finds(&mut tree), vec!["second page"]);
614    }
615
616    /// `child_id` pages are pre-mounted by the caller, so unlike a
617    /// `Pending` page added via `child()` (which stays unbuilt until
618    /// selected — see `unvisited_pages_never_build`), a `PreMounted`
619    /// page is built eagerly on first build even when it is not the
620    /// selected index. Visibility still tracks selection, and switching
621    /// to it must not rebuild it.
622    #[test]
623    fn premounted_child_id_pages_build_eagerly_unlike_pending() {
624        let selected = Signal::new(0_usize);
625        let (page1, c1) = CountingLeaf::new(60.0, 60.0);
626
627        let mut tree = WidgetTree::new();
628        let p0 = tree.add(FixedLeaf(50.0, 50.0));
629        let p1 = tree.add(page1); // caller pre-mounts the page
630        let _switcher = tree.add(Switcher::new(selected.clone()).child(p0).child(p1));
631        tree.layout(SizeProposal::exact(200.0, 200.0));
632
633        // Page 1 is NOT selected, yet it has already been built because
634        // it was pre-mounted via `child_id` — the eager path. A `Pending`
635        // page in the same position would have a build count of 0.
636        assert_eq!(
637            c1.get(),
638            1,
639            "PreMounted page builds eagerly even when not selected"
640        );
641        assert!(tree.is_visible(p0), "selected page visible");
642        assert!(!tree.is_visible(p1), "non-selected page hidden");
643
644        // Selecting page 1 reveals it without rebuilding.
645        selected.set(1);
646        tree.layout(SizeProposal::exact(200.0, 200.0));
647        assert!(tree.is_visible(p1), "switched-to page visible");
648        assert!(!tree.is_visible(p0), "switched-from page hidden");
649        assert_eq!(c1.get(), 1, "switching must not rebuild the page");
650    }
651
652    /// `Widget::declare_shortcuts` returned by a Pending Switcher page
653    /// must be registered in the shortcut registry before the page is
654    /// mounted — settings UIs depend on seeing the full keystroke
655    /// catalog without forcing every lazy branch to build.
656    #[test]
657    fn switcher_pending_pages_declare_shortcuts_eagerly() {
658        use teksilo_core::event::Key;
659        use teksilo_core::shortcut::{KeyStroke, Shortcut};
660
661        #[derive(Debug)]
662        struct LazyWithShortcuts(Rc<std::cell::Cell<u32>>);
663        impl Widget for LazyWithShortcuts {
664            fn declare_shortcuts(&self) -> Vec<Shortcut> {
665                vec![
666                    Shortcut::new("__test.lazy.action")
667                        .name("Lazy Action")
668                        .primary(KeyStroke::ctrl(Key::L))
669                        .build(),
670                ]
671            }
672            fn build(
673                &mut self,
674                _ctx: &mut teksilo_core::build_context::BuildContext,
675            ) -> Vec<WidgetId> {
676                self.0.set(self.0.get() + 1);
677                Vec::new()
678            }
679            fn layout_response(
680                &self,
681                _proposal: SizeProposal,
682                _ctx: &LayoutContext,
683            ) -> teksilo_core::widget::LayoutResponse {
684                Size::new(10.0, 10.0).into()
685            }
686        }
687
688        let selected = Signal::new(0_usize);
689        let build_count = Rc::new(std::cell::Cell::new(0));
690        let mut tree = WidgetTree::new();
691        let _id = tree.add(
692            Switcher::new(selected.clone())
693                .child(FixedLeaf(50.0, 50.0))
694                .child(LazyWithShortcuts(build_count.clone())),
695        );
696        tree.layout(SizeProposal::exact(200.0, 200.0));
697
698        assert_eq!(
699            build_count.get(),
700            0,
701            "lazy page must not have built — index 1 was never selected"
702        );
703        assert!(
704            tree.shortcut_registry()
705                .get_default("__test.lazy.action")
706                .is_some(),
707            "Switcher must pre-register Pending pages' declared shortcuts"
708        );
709    }
710}