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}