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}