Skip to main content

frust_widgets/
indexed_stack.rs

1//! Indexed stack: keeps every child mounted, shows and routes to one of them.
2//!
3//! [`IndexedStackView`]/[`IndexedStackWidget`] hold a list of children and an
4//! optional selected index. Every child is built, rebuilt and laid out on every
5//! pass, so each one keeps its widget state (a tab's scroll offset, a tab's own
6//! navigator stack) while it is not selected. Only the selected child is
7//! painted, receives input, appears in the accessibility tree, and may claim a
8//! back press. This is the container for per-tab navigation stacks: each tab
9//! holds its own navigator, and only the visible tab's navigator answers back.
10//!
11//! See [`IndexedStack`] for the reach rule (R23), the page-switch contract, and
12//! the layout choice.
13
14use std::cell::Cell;
15use std::rc::Rc;
16
17use frust_core::{
18    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EditingState, EventCtx, EventResult,
19    ImeState, InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, ViewSeq, Widget,
20    any,
21};
22use kurbo::{Point, Size};
23
24use crate::nav::{ambient_page_reach, with_page_reach};
25
26/// A declarative indexed stack. See [`IndexedStack`] and the
27/// [module docs](self).
28pub struct IndexedStackView<State: 'static> {
29    index: Option<usize>,
30    children: Vec<AnyView<State>>,
31}
32
33/// Keep every child of `children` mounted and show only the one at `index`.
34///
35/// `children` is any [`ViewSeq`]: a `Vec`/array of views, a tuple of mixed view
36/// types (positional, up to 12 and nestable), an `Option` (`None` drops the
37/// slot), or `views(iter)` for an iterator. Every element is erased once, here.
38/// An `index` that is `None` or out of range selects nothing: the stack paints
39/// nothing, routes no input and exposes no accessibility node, while every child
40/// stays mounted.
41///
42/// # Reach follows the selection (R23)
43///
44/// Only the selected child is reachable. Input events go to it alone, the
45/// accessibility tree contains it alone, and a navigator anywhere inside an
46/// unselected child reports
47/// [`back_interest`](crate::NavigatorController::back_interest)` == false`, the
48/// same rule that silences a navigator on a covered navigator page. The stack
49/// does this by installing one page-reach cell per child, `true` only for the
50/// selected child, and only while the stack itself is reachable. A stack built
51/// on a navigator page that later gets covered therefore leaves every tab
52/// navigator unreachable, at any nesting depth. Cells are rewritten on every
53/// rebuild, before the children are reconciled, so a nested navigator always
54/// reads the value for the selection it is being rebuilt under.
55///
56/// Broadcast events (housekeeping, overlay and drop-end notifications) are not
57/// user input and go to every child, as they do for the navigator's pages: a
58/// navigator in an unselected tab may hold a queued pop result that must still
59/// flush on the frame that produced it. The one exception is
60/// [`InputEvent::Shortcut`]: it reaches the selected child only, so a shortcut
61/// an unselected child declared is never answered and the root falls through
62/// to its next candidate.
63///
64/// # Page-switch contract
65///
66/// When the selected index changes, the outgoing child is handled the way a
67/// navigator handles a covered page, in this order: an in-flight pointer
68/// capture receives a synthetic `Cancel` (so it never fires on a later `Up`),
69/// its focus flag is cleared, and, if that child held focus on the live focus
70/// chain, the next paint publishes a cleared IME surface so the platform
71/// keyboard hides. A child that never held focus publishes nothing, so a text
72/// field elsewhere in the tree keeps its session. The incoming child is not
73/// focused automatically.
74///
75/// # Layout
76///
77/// Every child is laid out under the stack's loosened constraints at the stack
78/// origin, and the stack takes the size of its largest child, clamped to its
79/// constraints. This matches Flutter's `IndexedStack`: the stack does not
80/// change size when the selection changes.
81///
82/// ```
83/// use frust_widgets::{IndexedStack, IndexedStackView, Stack, indexed_stack, text};
84/// # fn demo() -> (IndexedStackView<()>, IndexedStackView<()>) {
85/// // Mixed child types: a tuple, no `any(..)` needed.
86/// let tabs = IndexedStack(Some(1), (text("home"), Stack((text("feed"), text("badge")))));
87/// // The fluent form.
88/// let fluent = indexed_stack(Some(0)).child(text("home")).child(text("search"));
89/// # (tabs, fluent)
90/// # }
91/// # let _ = demo();
92/// ```
93#[allow(non_snake_case)]
94pub fn IndexedStack<State: 'static, M>(
95    index: Option<usize>,
96    children: impl ViewSeq<State, M>,
97) -> IndexedStackView<State> {
98    indexed_stack(index).children(children)
99}
100
101/// An empty indexed stack selecting `index`, ready for fluent children (no
102/// `any(..)` wrapping). See [`IndexedStack`].
103pub fn indexed_stack<State: 'static>(index: Option<usize>) -> IndexedStackView<State> {
104    IndexedStackView {
105        index,
106        children: Vec::new(),
107    }
108}
109
110impl<State: 'static> IndexedStackView<State> {
111    /// Append a child after the existing ones. Accepts any [`View`].
112    pub fn child<V: View<State>>(mut self, view: V) -> Self {
113        self.children.push(any(view));
114        self
115    }
116
117    /// Append every element of `children` after the existing ones, in order.
118    ///
119    /// Accepts any [`ViewSeq`] (`Vec`, array, tuple, `Option`, nested, or
120    /// `views(iter)`); tuples are positional and a `None` element drops its slot.
121    pub fn children<M>(mut self, children: impl ViewSeq<State, M>) -> Self {
122        children.extend_views(&mut self.children);
123        self
124    }
125
126    /// Apply `f` to the builder only when `cond` is true.
127    pub fn when(self, cond: bool, f: impl FnOnce(Self) -> Self) -> Self {
128        if cond { f(self) } else { self }
129    }
130
131    /// Apply `f` with the contained value when `opt` is `Some`.
132    pub fn when_some<T>(self, opt: Option<T>, f: impl FnOnce(Self, T) -> Self) -> Self {
133        match opt {
134            Some(value) => f(self, value),
135            None => self,
136        }
137    }
138}
139
140/// The retained widget for an [`IndexedStackView`].
141pub struct IndexedStackWidget {
142    children: Vec<ChildPod>,
143    /// One page-reach cell per child, installed around that child's
144    /// build/rebuild/teardown. `true` only for the selected child while the
145    /// stack itself is reachable.
146    reach: Vec<Rc<Cell<bool>>>,
147    index: Option<usize>,
148    /// Set when a selection change took focus away from the outgoing child on
149    /// the live chain; the next paint publishes a cleared IME surface.
150    needs_ime_clear: bool,
151}
152
153/// Whether the ambient page this stack lives on is reachable: `true` at the
154/// top level (no hosting page).
155fn parent_reachable() -> bool {
156    ambient_page_reach().is_none_or(|cell| cell.get())
157}
158
159impl IndexedStackWidget {
160    /// The selected pod, if the index names an existing child.
161    fn selected_mut(&mut self) -> Option<&mut ChildPod> {
162        self.index.and_then(|i| self.children.get_mut(i))
163    }
164
165    fn selected(&self) -> Option<&ChildPod> {
166        self.index.and_then(|i| self.children.get(i))
167    }
168
169    /// Apply the page-switch contract to the currently selected child, which is
170    /// about to stop being selected: cancel its capture, clear its focus flag,
171    /// and queue a cleared IME publish when it held focus on the live chain.
172    ///
173    /// The focus flag is read before anything clears it, as the navigator's
174    /// `apply_pop` does; reading it afterwards would always report `false`.
175    fn switch_away(&mut self, ctx: &BuildCtx<'_>) {
176        let Some(pod) = self.selected_mut() else {
177            return;
178        };
179        let outgoing_focused = pod.is_focused();
180        if pod.is_active() {
181            crate::authoring::cancel_pod(pod);
182            pod.set_active(false);
183        }
184        if outgoing_focused {
185            pod.set_focused(false);
186        }
187        if outgoing_focused && ctx.has_focus() {
188            self.needs_ime_clear = true;
189        }
190    }
191
192    /// Rewrite every reach cell for the current selection.
193    fn publish_reach(&self, parent_reachable: bool) {
194        for (i, cell) in self.reach.iter().enumerate() {
195            cell.set(parent_reachable && Some(i) == self.index);
196        }
197    }
198}
199
200impl<State: 'static> View<State> for IndexedStackView<State> {
201    type Element = IndexedStackWidget;
202
203    fn build(&self, ctx: &mut BuildCtx<'_>) -> IndexedStackWidget {
204        let parent = parent_reachable();
205        let reach: Vec<Rc<Cell<bool>>> = (0..self.children.len())
206            .map(|i| Rc::new(Cell::new(parent && Some(i) == self.index)))
207            .collect();
208        let children = self
209            .children
210            .iter()
211            .zip(&reach)
212            .map(|(view, cell)| with_page_reach(cell, || crate::authoring::build_child(view, ctx)))
213            .collect();
214        IndexedStackWidget {
215            children,
216            reach,
217            index: self.index,
218            needs_ime_clear: false,
219        }
220    }
221
222    fn rebuild(
223        &self,
224        prev: &Self,
225        element: &mut IndexedStackWidget,
226        ctx: &mut BuildCtx<'_>,
227    ) -> ChangeFlags {
228        let mut flags = ChangeFlags::NONE;
229        // Re-read the ambient parent: this subtree may have moved, or its
230        // hosting page may have been covered, since the last pass.
231        let parent = parent_reachable();
232
233        // The outgoing child is handled first, against the pods as they were.
234        if element.index != self.index {
235            element.switch_away(ctx);
236            element.index = self.index;
237            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
238        }
239
240        // Resize the reach cells to the new child count and rewrite them all
241        // before any child is reconciled. The cells of a shrunk tail are kept
242        // aside, set unreachable, so those children are torn down inside their
243        // own (now false) scope.
244        let next_len = self.children.len();
245        let removed_reach = if element.reach.len() > next_len {
246            element.reach.split_off(next_len)
247        } else {
248            Vec::new()
249        };
250        for cell in &removed_reach {
251            cell.set(false);
252        }
253        element
254            .reach
255            .resize_with(next_len, || Rc::new(Cell::new(false)));
256        element.publish_reach(parent);
257
258        // Reconcile per index rather than through `rebuild_children`: each
259        // child needs its own reach scope around its rebuild, and the shared
260        // reconciler runs all children in one call with no per-child hook.
261        // Positional matching is the right identity here anyway, since a slot
262        // index is exactly what the selection addresses. `rebuild_child`
263        // handles a type swap at one slot (drops its capture and focus, marks
264        // a live focus orphan) without touching the other slots, so a swap in
265        // one tab never cancels another tab's capture or focus.
266        let IndexedStackWidget {
267            children: pods,
268            reach,
269            ..
270        } = element;
271        let common = prev.children.len().min(next_len);
272        for i in 0..common {
273            let (prev_view, next_view, pod) = (&prev.children[i], &self.children[i], &mut pods[i]);
274            flags |= with_page_reach(&reach[i], || {
275                crate::authoring::rebuild_child(prev_view, next_view, pod, ctx)
276            });
277        }
278        if next_len > common {
279            for (view, cell) in self.children[common..].iter().zip(&reach[common..]) {
280                pods.push(with_page_reach(cell, || {
281                    crate::authoring::build_child(view, ctx)
282                }));
283            }
284            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
285        } else if prev.children.len() > next_len {
286            for ((view, pod), cell) in prev.children[next_len..]
287                .iter()
288                .zip(pods[next_len..].iter_mut())
289                .zip(&removed_reach)
290            {
291                with_page_reach(cell, || crate::authoring::teardown_child(view, pod, ctx));
292            }
293            pods.truncate(next_len);
294            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
295        }
296        flags
297    }
298
299    fn teardown(&self, element: &mut IndexedStackWidget, ctx: &mut BuildCtx<'_>) {
300        // The whole stack is leaving: nothing in it is reachable any more.
301        for cell in &element.reach {
302            cell.set(false);
303        }
304        for ((view, pod), cell) in self
305            .children
306            .iter()
307            .zip(element.children.iter_mut())
308            .zip(&element.reach)
309        {
310            with_page_reach(cell, || crate::authoring::teardown_child(view, pod, ctx));
311        }
312    }
313}
314
315impl Widget for IndexedStackWidget {
316    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
317        // Every child, selected or not, so the stack keeps one size across
318        // selection changes (Flutter `IndexedStack` parity).
319        let loose = bc.loosen();
320        let mut max = Size::ZERO;
321        for pod in &mut self.children {
322            let size = pod.layout_child(ctx, &loose);
323            pod.set_origin(Point::ZERO);
324            max = Size::new(max.width.max(size.width), max.height.max(size.height));
325        }
326        bc.constrain(max)
327    }
328
329    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
330        if let Some(pod) = self.selected_mut() {
331            pod.paint_child(ctx, scene);
332        }
333        // Deterministic IME hide after a selection change took focus from the
334        // outgoing child (see `switch_away`).
335        if self.needs_ime_clear {
336            ctx.publish_ime_state(cleared_ime_state());
337            self.needs_ime_clear = false;
338        }
339    }
340
341    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
342        // A shortcut is the carve-out: only the selected child may answer one.
343        if matches!(event, InputEvent::Shortcut(_)) {
344            if let Some(pod) = self.selected_mut() {
345                crate::authoring::route_event_single(pod, ctx, event);
346            }
347            return EventResult::Ignored;
348        }
349        if event.is_broadcast() {
350            // Not user input: every child gets it, nothing consumes it (see
351            // the `IndexedStack` docs).
352            for pod in &mut self.children {
353                crate::authoring::route_event_single(pod, ctx, event);
354            }
355            return EventResult::Ignored;
356        }
357        match self.selected_mut() {
358            Some(pod) => crate::authoring::route_event_single(pod, ctx, event),
359            None => EventResult::Ignored,
360        }
361    }
362
363    /// Forwards the selected child only. This is the input-gating carve-out of
364    /// the forward-every-child rule: the accessibility tree follows input
365    /// routing (R23), and only the selected child receives input.
366    fn semantics(&self, ctx: &mut SemanticsCtx) {
367        if let Some(pod) = self.selected() {
368            pod.semantics_child(ctx);
369        }
370    }
371
372    // Every retained child, selected or not: inspection shows what the tree
373    // holds, not what input reaches.
374    crate::authoring::visit_children!(children);
375
376    /// The selected child only: Tab traversal follows input routing.
377    fn visit_focus_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
378        if let Some(pod) = self.selected() {
379            visitor(pod);
380        }
381    }
382}
383
384/// The cleared/inactive IME surface published after a selection change so the
385/// platform keyboard hides deterministically. A copy of the navigator's and the
386/// pattern switcher's private helper of the same name; `active: false` makes it
387/// a focus/IME session release at the root.
388fn cleared_ime_state() -> ImeState {
389    ImeState {
390        active: false,
391        editing: EditingState {
392            text: String::new(),
393            selection_base: -1,
394            selection_extent: -1,
395            composing_base: -1,
396            composing_extent: -1,
397        },
398        caret: None,
399        content_type: Default::default(),
400        suppress_soft_keyboard: false,
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::nav::navigator::{NavigatorController, NavigatorView, navigator};
408    use crate::test_support::{RecordingScene, leaf, probe, swap_leaf};
409    use frust_core::{PointerButton, PointerEvent, PointerPhase, RenderRoot};
410
411    fn build<S: 'static>(view: &IndexedStackView<S>) -> IndexedStackWidget {
412        let mut counter = 0u64;
413        view.build(&mut BuildCtx::new(&mut counter))
414    }
415
416    fn rebuild<S: 'static>(
417        next: &IndexedStackView<S>,
418        prev: &IndexedStackView<S>,
419        w: &mut IndexedStackWidget,
420    ) -> ChangeFlags {
421        let mut counter = 100u64;
422        next.rebuild(prev, w, &mut BuildCtx::new(&mut counter))
423    }
424
425    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
426        InputEvent::Pointer(PointerEvent {
427            phase,
428            position: Point::new(x, y),
429            button: PointerButton::Primary,
430        })
431    }
432
433    fn down(x: f64, y: f64) -> InputEvent {
434        pointer(PointerPhase::Down, x, y)
435    }
436
437    fn reach_of(w: &IndexedStackWidget) -> Vec<bool> {
438        w.reach.iter().map(|c| c.get()).collect()
439    }
440
441    // --- (a) layout: largest child; paint: selected child only ---
442
443    #[test]
444    fn sizes_to_the_largest_child_and_paints_only_the_selected_one() {
445        let view: IndexedStackView<()> = indexed_stack(Some(1))
446            .child(leaf(30.0, 60.0))
447            .child(leaf(80.0, 20.0));
448        let mut w = build(&view);
449        let size = w.layout(
450            &mut LayoutCtx::new(),
451            &BoxConstraints::loose(Size::new(500.0, 500.0)),
452        );
453        // Widest is the selected child (80), tallest the unselected one (60):
454        // every child is laid out, not just the selected one.
455        assert_eq!(size, Size::new(80.0, 60.0));
456        assert_eq!(w.children[0].origin(), Point::ZERO);
457        assert_eq!(w.children[1].origin(), Point::ZERO);
458
459        let mut scene = RecordingScene::default();
460        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
461        assert_eq!(
462            scene.rects,
463            vec![(Point::ZERO, Size::new(80.0, 20.0))],
464            "only the selected child paints"
465        );
466    }
467
468    #[test]
469    fn a_filling_child_fills_the_stack_under_tight_constraints() {
470        let view: IndexedStackView<Vec<u32>> =
471            indexed_stack(Some(0)).child(probe(0)).child(probe(1));
472        let mut w = build(&view);
473        let size = w.layout(
474            &mut LayoutCtx::new(),
475            &BoxConstraints::tight(Size::new(100.0, 70.0)),
476        );
477        assert_eq!(size, Size::new(100.0, 70.0));
478        assert_eq!(w.children[0].size(), Size::new(100.0, 70.0));
479        assert_eq!(w.children[1].size(), Size::new(100.0, 70.0));
480    }
481
482    // --- (b) input: only the selected child ---
483
484    #[test]
485    fn a_pointer_down_reaches_only_the_selected_child() {
486        // Selecting the BOTTOM child proves selection, not z-order, decides.
487        let view: IndexedStackView<Vec<u32>> =
488            IndexedStack(Some(0), (probe(0), probe(1), probe(2)));
489        let mut w = build(&view);
490        w.layout(
491            &mut LayoutCtx::new(),
492            &BoxConstraints::tight(Size::new(100.0, 100.0)),
493        );
494        let mut log: Vec<u32> = Vec::new();
495        let mut ectx = EventCtx::new(&mut log, Point::ZERO, Size::new(100.0, 100.0));
496        let result = w.event(&mut ectx, &down(50.0, 50.0));
497        assert_eq!(result, EventResult::Handled);
498        assert_eq!(log, vec![0], "only the selected child saw the Down");
499    }
500
501    #[test]
502    fn a_broadcast_reaches_every_child_and_is_not_consumed() {
503        let view: IndexedStackView<Vec<u32>> = IndexedStack(Some(1), (probe(0), probe(1)));
504        let mut w = build(&view);
505        w.layout(
506            &mut LayoutCtx::new(),
507            &BoxConstraints::tight(Size::new(100.0, 100.0)),
508        );
509        let mut log: Vec<u32> = Vec::new();
510        let mut ectx = EventCtx::new(&mut log, Point::ZERO, Size::new(100.0, 100.0));
511        let result = w.event(&mut ectx, &InputEvent::Housekeeping);
512        assert_eq!(result, EventResult::Ignored);
513        assert_eq!(
514            log,
515            vec![0, 1],
516            "housekeeping is not input: every tab gets it"
517        );
518    }
519
520    #[test]
521    fn a_shortcut_reaches_only_the_selected_child() {
522        let view: IndexedStackView<Vec<u32>> = IndexedStack(Some(1), (probe(0), probe(1)));
523        let mut w = build(&view);
524        w.layout(
525            &mut LayoutCtx::new(),
526            &BoxConstraints::tight(Size::new(100.0, 100.0)),
527        );
528        let mut log: Vec<u32> = Vec::new();
529        let mut ectx = EventCtx::new(&mut log, Point::ZERO, Size::new(100.0, 100.0));
530        let shortcut = InputEvent::Shortcut(frust_core::ShortcutId::next());
531        assert_eq!(w.event(&mut ectx, &shortcut), EventResult::Ignored);
532        assert_eq!(log, vec![1], "an unselected tab never answers a shortcut");
533    }
534
535    // --- (c) semantics: only the selected child ---
536
537    struct Labelled {
538        label: &'static str,
539    }
540    struct LabelledWidget {
541        label: &'static str,
542    }
543    impl View<()> for Labelled {
544        type Element = LabelledWidget;
545        fn build(&self, _ctx: &mut BuildCtx<'_>) -> LabelledWidget {
546            LabelledWidget { label: self.label }
547        }
548        fn rebuild(
549            &self,
550            _prev: &Self,
551            element: &mut LabelledWidget,
552            _ctx: &mut BuildCtx<'_>,
553        ) -> ChangeFlags {
554            element.label = self.label;
555            ChangeFlags::NONE
556        }
557    }
558    impl Widget for LabelledWidget {
559        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
560            bc.max()
561        }
562        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
563        fn semantics(&self, ctx: &mut SemanticsCtx) {
564            ctx.push_node(frust_core::accesskit::Role::Button, |node| {
565                node.set_label(self.label)
566            });
567        }
568    }
569
570    fn labels(root: &RenderRoot<(), IndexedStackView<()>>) -> Vec<String> {
571        root.semantics()
572            .nodes
573            .iter()
574            .filter_map(|(_, node)| node.label().map(str::to_string))
575            .collect()
576    }
577
578    #[test]
579    fn semantics_expose_only_the_selected_child() {
580        let selected = Rc::new(Cell::new(Some(1usize)));
581        let mut app = {
582            let selected = selected.clone();
583            move |_: &mut ()| {
584                IndexedStack(
585                    selected.get(),
586                    (
587                        Labelled { label: "tab-a" },
588                        Labelled { label: "tab-b" },
589                        Labelled { label: "tab-c" },
590                    ),
591                )
592            }
593        };
594        let mut root: RenderRoot<(), IndexedStackView<()>> = RenderRoot::new();
595        root.rebuild(&mut app, &mut ());
596        root.layout(Size::new(100.0, 100.0));
597        assert_eq!(labels(&root), vec!["tab-b".to_string()]);
598
599        selected.set(Some(2));
600        root.rebuild(&mut app, &mut ());
601        root.layout(Size::new(100.0, 100.0));
602        assert_eq!(labels(&root), vec!["tab-c".to_string()]);
603
604        selected.set(None);
605        root.rebuild(&mut app, &mut ());
606        root.layout(Size::new(100.0, 100.0));
607        assert!(labels(&root).is_empty(), "no selection, no nodes");
608    }
609
610    // --- (d) back reach: one navigator per tab ---
611
612    #[test]
613    fn only_the_selected_tabs_navigator_claims_back() {
614        let a: NavigatorController<()> = NavigatorController::new();
615        let b: NavigatorController<()> = NavigatorController::new();
616        let selected = Rc::new(Cell::new(0usize));
617        let mut app = {
618            let (a, b, selected) = (a.clone(), b.clone(), selected.clone());
619            move |_: &mut ()| {
620                indexed_stack(Some(selected.get()))
621                    .child(navigator(&a, || leaf(10.0, 10.0)))
622                    .child(navigator(&b, || leaf(10.0, 10.0)))
623            }
624        };
625        let mut root: RenderRoot<(), IndexedStackView<()>> = RenderRoot::new();
626        root.rebuild(&mut app, &mut ());
627        a.push(|| leaf(20.0, 20.0));
628        b.push(|| leaf(20.0, 20.0));
629        root.rebuild(&mut app, &mut ());
630        assert_eq!((a.depth(), b.depth()), (2, 2), "both stacks were pushed");
631        assert!(a.back_interest(), "the selected tab claims back");
632        assert!(!b.back_interest(), "the unselected tab claims nothing");
633
634        selected.set(1);
635        root.rebuild(&mut app, &mut ());
636        assert!(!a.back_interest(), "switched away: tab A claims nothing");
637        assert!(b.back_interest(), "switched to: tab B claims back");
638        assert!(a.is_mounted() && b.is_mounted(), "both stay mounted");
639        assert_eq!((a.depth(), b.depth()), (2, 2), "both keep their stacks");
640    }
641
642    // --- (e) two-deep: tab -> navigator -> page -> navigator ---
643
644    #[test]
645    fn a_navigator_two_levels_down_claims_back_only_while_its_tab_is_selected() {
646        let outer: NavigatorController<()> = NavigatorController::new();
647        let inner: NavigatorController<()> = NavigatorController::new();
648        let selected = Rc::new(Cell::new(0usize));
649        let mut app = {
650            let (outer, inner, selected) = (outer.clone(), inner.clone(), selected.clone());
651            move |_: &mut ()| {
652                let inner = inner.clone();
653                indexed_stack(Some(selected.get()))
654                    .child(navigator(&outer, move || {
655                        navigator(&inner, || leaf(10.0, 10.0))
656                    }))
657                    .child(leaf(10.0, 10.0))
658            }
659        };
660        let mut root: RenderRoot<(), IndexedStackView<()>> = RenderRoot::new();
661        root.rebuild(&mut app, &mut ());
662        inner.push(|| leaf(20.0, 20.0));
663        root.rebuild(&mut app, &mut ());
664        assert_eq!(inner.depth(), 2);
665        assert!(
666            inner.back_interest(),
667            "selected tab: the inner stack claims"
668        );
669
670        selected.set(1);
671        root.rebuild(&mut app, &mut ());
672        assert!(!inner.back_interest(), "switched away: nothing claims");
673        assert!(!outer.back_interest());
674        // Stays silent across further passes (no stale republish).
675        root.rebuild(&mut app, &mut ());
676        assert!(!inner.back_interest(), "still switched away");
677
678        selected.set(0);
679        root.rebuild(&mut app, &mut ());
680        assert!(
681            inner.back_interest(),
682            "switched back: the inner stack claims"
683        );
684        assert_eq!(inner.depth(), 2, "and kept its stack throughout");
685    }
686
687    // --- (f) composition: a stack on a navigator page that gets covered ---
688
689    #[test]
690    fn a_covered_host_page_silences_every_tab_navigator() {
691        for cull in [false, true] {
692            let host: NavigatorController<()> = NavigatorController::new();
693            let t0: NavigatorController<()> = NavigatorController::new();
694            let t1: NavigatorController<()> = NavigatorController::new();
695            let mut app = {
696                let (host, t0, t1) = (host.clone(), t0.clone(), t1.clone());
697                move |_: &mut ()| {
698                    let (t0, t1) = (t0.clone(), t1.clone());
699                    navigator(&host, move || {
700                        indexed_stack(Some(0))
701                            .child(navigator(&t0, || leaf(10.0, 10.0)))
702                            .child(navigator(&t1, || leaf(10.0, 10.0)))
703                    })
704                    .cull_covered_builds(cull)
705                }
706            };
707            let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
708            root.rebuild(&mut app, &mut ());
709            t0.push(|| leaf(20.0, 20.0));
710            t1.push(|| leaf(20.0, 20.0));
711            root.rebuild(&mut app, &mut ());
712            assert!(t0.back_interest(), "selected tab claims (cull={cull})");
713            assert!(!t1.back_interest(), "unselected tab silent (cull={cull})");
714
715            host.push(|| leaf(30.0, 30.0));
716            root.rebuild(&mut app, &mut ());
717            assert!(!t0.back_interest(), "covered: tab 0 silent (cull={cull})");
718            assert!(!t1.back_interest(), "covered: tab 1 silent (cull={cull})");
719            assert!(
720                host.back_interest(),
721                "the host claims instead (cull={cull})"
722            );
723            for _ in 0..3 {
724                root.rebuild(&mut app, &mut ());
725            }
726            assert!(
727                !t0.back_interest(),
728                "stays silent while covered (cull={cull})"
729            );
730
731            host.pop();
732            root.rebuild(&mut app, &mut ());
733            assert!(t0.back_interest(), "revealed: tab 0 claims (cull={cull})");
734            assert!(!t1.back_interest(), "tab 1 still silent (cull={cull})");
735        }
736    }
737
738    // --- (g) no valid selection ---
739
740    #[test]
741    fn no_or_out_of_range_index_selects_nothing() {
742        for index in [None, Some(2), Some(99)] {
743            let view: IndexedStackView<Vec<u32>> = IndexedStack(index, (probe(0), probe(1)));
744            let mut w = build(&view);
745            assert_eq!(reach_of(&w), vec![false, false], "{index:?}: no reach");
746            let size = w.layout(
747                &mut LayoutCtx::new(),
748                &BoxConstraints::tight(Size::new(50.0, 50.0)),
749            );
750            assert_eq!(size, Size::new(50.0, 50.0), "children are still laid out");
751
752            let mut log: Vec<u32> = Vec::new();
753            let mut ectx = EventCtx::new(&mut log, Point::ZERO, size);
754            assert_eq!(w.event(&mut ectx, &down(5.0, 5.0)), EventResult::Ignored);
755            assert!(log.is_empty(), "{index:?}: nothing routed");
756        }
757
758        let view: IndexedStackView<()> = indexed_stack(None)
759            .child(leaf(10.0, 10.0))
760            .child(leaf(20.0, 20.0));
761        let mut w = build(&view);
762        let size = w.layout(
763            &mut LayoutCtx::new(),
764            &BoxConstraints::loose(Size::new(100.0, 100.0)),
765        );
766        let mut scene = RecordingScene::default();
767        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
768        assert!(scene.rects.is_empty(), "nothing painted");
769
770        // A tab navigator under no selection claims nothing either.
771        let nav: NavigatorController<()> = NavigatorController::new();
772        let mut app = {
773            let nav = nav.clone();
774            move |_: &mut ()| indexed_stack(None).child(navigator(&nav, || leaf(10.0, 10.0)))
775        };
776        let mut root: RenderRoot<(), IndexedStackView<()>> = RenderRoot::new();
777        root.rebuild(&mut app, &mut ());
778        nav.push(|| leaf(20.0, 20.0));
779        root.rebuild(&mut app, &mut ());
780        assert_eq!(nav.depth(), 2);
781        assert!(!nav.back_interest(), "no selection: no back reach");
782    }
783
784    #[test]
785    fn reach_cells_follow_child_count_changes() {
786        let v1: IndexedStackView<()> = indexed_stack(Some(1)).child(leaf(1.0, 1.0));
787        let mut w = build(&v1);
788        assert_eq!(
789            reach_of(&w),
790            vec![false],
791            "index past the end selects nothing"
792        );
793
794        // Growing into the selected index makes the new child reachable.
795        let v2: IndexedStackView<()> = indexed_stack(Some(1))
796            .child(leaf(1.0, 1.0))
797            .child(leaf(2.0, 2.0))
798            .child(leaf(3.0, 3.0));
799        let flags = rebuild(&v2, &v1, &mut w);
800        assert!(flags.contains(ChangeFlags::LAYOUT | ChangeFlags::PAINT));
801        assert_eq!(w.children.len(), 3);
802        assert_eq!(reach_of(&w), vec![false, true, false]);
803
804        // Shrinking drops the tail's cells; the dropped cell reads false.
805        let dropped = Rc::clone(&w.reach[2]);
806        let v3: IndexedStackView<()> = indexed_stack(Some(1))
807            .child(leaf(1.0, 1.0))
808            .child(leaf(2.0, 2.0));
809        let flags = rebuild(&v3, &v2, &mut w);
810        assert!(flags.contains(ChangeFlags::LAYOUT | ChangeFlags::PAINT));
811        assert_eq!(w.children.len(), 2);
812        assert_eq!(reach_of(&w), vec![false, true]);
813        assert!(!dropped.get());
814
815        // A plain rebuild with nothing changed reports nothing.
816        let v4: IndexedStackView<()> = indexed_stack(Some(1))
817            .child(leaf(1.0, 1.0))
818            .child(leaf(2.0, 2.0));
819        assert_eq!(rebuild(&v4, &v3, &mut w), ChangeFlags::NONE);
820
821        // An index change alone reports layout + paint.
822        let v5: IndexedStackView<()> = indexed_stack(Some(0))
823            .child(leaf(1.0, 1.0))
824            .child(leaf(2.0, 2.0));
825        let flags = rebuild(&v5, &v4, &mut w);
826        assert!(flags.contains(ChangeFlags::LAYOUT | ChangeFlags::PAINT));
827        assert_eq!(reach_of(&w), vec![true, false]);
828    }
829
830    // --- (h) page-switch contract ---
831
832    /// Captures the pointer and takes focus on `Down`; records `Cancel` and
833    /// `Up` into shared cells (a `Cancel` arm touches internal flags only).
834    struct Recorder {
835        cancelled: Rc<Cell<bool>>,
836        ups: Rc<Cell<u32>>,
837    }
838    struct RecorderWidget {
839        cancelled: Rc<Cell<bool>>,
840        ups: Rc<Cell<u32>>,
841    }
842    impl View<()> for Recorder {
843        type Element = RecorderWidget;
844        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RecorderWidget {
845            RecorderWidget {
846                cancelled: self.cancelled.clone(),
847                ups: self.ups.clone(),
848            }
849        }
850        fn rebuild(
851            &self,
852            _prev: &Self,
853            _element: &mut RecorderWidget,
854            _ctx: &mut BuildCtx<'_>,
855        ) -> ChangeFlags {
856            ChangeFlags::NONE
857        }
858    }
859    impl Widget for RecorderWidget {
860        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
861            bc.max()
862        }
863        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
864        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
865            if let InputEvent::Pointer(p) = event {
866                match p.phase {
867                    PointerPhase::Down => {
868                        ctx.capture_pointer();
869                        ctx.request_focus();
870                        return EventResult::Handled;
871                    }
872                    PointerPhase::Cancel => {
873                        self.cancelled.set(true);
874                        return EventResult::Handled;
875                    }
876                    PointerPhase::Up => {
877                        self.ups.set(self.ups.get() + 1);
878                        return EventResult::Handled;
879                    }
880                    _ => {}
881                }
882            }
883            EventResult::Ignored
884        }
885    }
886
887    struct Armed {
888        w: IndexedStackWidget,
889        v1: IndexedStackView<()>,
890        cancelled: Rc<Cell<bool>>,
891        ups: Rc<Cell<u32>>,
892        size: Size,
893    }
894
895    /// Tab 0 is a recorder, selected and armed (captured + focused) by a Down.
896    fn armed() -> Armed {
897        let cancelled = Rc::new(Cell::new(false));
898        let ups = Rc::new(Cell::new(0u32));
899        let v1: IndexedStackView<()> = indexed_stack(Some(0))
900            .child(Recorder {
901                cancelled: cancelled.clone(),
902                ups: ups.clone(),
903            })
904            .child(leaf(10.0, 10.0));
905        let mut w = build(&v1);
906        let size = w.layout(
907            &mut LayoutCtx::new(),
908            &BoxConstraints::tight(Size::new(100.0, 100.0)),
909        );
910        let mut st = ();
911        let mut ectx = EventCtx::new(&mut st, Point::ZERO, size);
912        w.event(&mut ectx, &down(5.0, 5.0));
913        assert!(w.children[0].is_active(), "tab 0 captured the pointer");
914        assert!(w.children[0].is_focused(), "tab 0 took focus");
915        Armed {
916            w,
917            v1,
918            cancelled,
919            ups,
920            size,
921        }
922    }
923
924    fn switch_view() -> IndexedStackView<()> {
925        // Same child types as `armed`'s view; only the index differs.
926        indexed_stack(Some(1))
927            .child(Recorder {
928                cancelled: Rc::new(Cell::new(false)),
929                ups: Rc::new(Cell::new(0)),
930            })
931            .child(leaf(10.0, 10.0))
932    }
933
934    #[test]
935    fn a_selection_change_cancels_capture_clears_focus_and_clears_the_ime() {
936        let Armed {
937            mut w,
938            v1,
939            cancelled,
940            ups,
941            size,
942        } = armed();
943        let v2 = switch_view();
944        rebuild(&v2, &v1, &mut w);
945
946        assert!(
947            cancelled.get(),
948            "the outgoing tab received a synthetic Cancel"
949        );
950        assert!(!w.children[0].is_active(), "its capture path was released");
951        assert!(!w.children[0].is_focused(), "its focus flag was cleared");
952        assert!(w.needs_ime_clear, "a cleared IME publish is queued");
953
954        // A later Up never reaches the outgoing tab.
955        let mut st = ();
956        let mut ectx = EventCtx::new(&mut st, Point::ZERO, size);
957        w.event(&mut ectx, &pointer(PointerPhase::Up, 5.0, 5.0));
958        assert_eq!(ups.get(), 0, "the cancelled tab does not fire on Up");
959
960        let mut pctx = PaintCtx::new(Point::ZERO, size);
961        w.paint(&mut pctx, &mut RecordingScene::default());
962        let ime = pctx
963            .take_ime_state()
964            .expect("the post-switch paint publishes a cleared IME surface");
965        assert!(!ime.active);
966        assert!(!w.needs_ime_clear, "the queued clear was consumed");
967
968        // The incoming tab is not focused automatically.
969        assert!(!w.children[1].is_focused());
970    }
971
972    #[test]
973    fn a_selection_change_off_the_live_focus_chain_publishes_no_ime_clear() {
974        let Armed {
975            mut w,
976            v1,
977            cancelled,
978            size,
979            ..
980        } = armed();
981        let v2 = switch_view();
982        let mut counter = 100u64;
983        let mut ctx = BuildCtx::new(&mut counter);
984        // The session lives elsewhere: the chain down to this stack is broken.
985        ctx.with_focus_link(false, |ctx| v2.rebuild(&v1, &mut w, ctx));
986
987        assert!(cancelled.get(), "capture is still cancelled");
988        assert!(
989            !w.children[0].is_focused(),
990            "the stale flag is still cleared"
991        );
992        assert!(!w.needs_ime_clear, "but no session release is queued");
993        let mut pctx = PaintCtx::new(Point::ZERO, size);
994        w.paint(&mut pctx, &mut RecordingScene::default());
995        assert!(pctx.take_ime_state().is_none(), "no IME publish");
996    }
997
998    #[test]
999    fn switching_away_from_an_unfocused_tab_publishes_no_ime_clear() {
1000        let v1: IndexedStackView<()> = indexed_stack(Some(0))
1001            .child(leaf(10.0, 10.0))
1002            .child(leaf(10.0, 10.0));
1003        let mut w = build(&v1);
1004        let v2: IndexedStackView<()> = indexed_stack(Some(1))
1005            .child(leaf(10.0, 10.0))
1006            .child(leaf(10.0, 10.0));
1007        rebuild(&v2, &v1, &mut w);
1008        assert!(!w.needs_ime_clear, "the outgoing tab held no session");
1009    }
1010
1011    // --- (i) builders ---
1012
1013    #[test]
1014    fn fluent_and_tuple_builders_build() {
1015        let fluent: IndexedStackView<()> = indexed_stack(Some(0))
1016            .child(leaf(1.0, 1.0))
1017            .children((swap_leaf(), leaf(2.0, 2.0)))
1018            .when(true, |s| s.child(leaf(3.0, 3.0)))
1019            .when(false, |s| s.child(leaf(4.0, 4.0)))
1020            .when_some(Some(5.0), |s, side| s.child(leaf(side, side)))
1021            .when_some(None::<f64>, |s, side| s.child(leaf(side, side)));
1022        assert_eq!(build(&fluent).children.len(), 5);
1023
1024        let tuple: IndexedStackView<()> =
1025            IndexedStack(Some(1), (leaf(1.0, 1.0), swap_leaf(), leaf(2.0, 2.0)));
1026        let w = build(&tuple);
1027        assert_eq!(w.children.len(), 3);
1028        assert_eq!(reach_of(&w), vec![false, true, false]);
1029    }
1030
1031    #[test]
1032    fn traversal_enters_only_the_selected_child() {
1033        let view: IndexedStackView<Vec<u32>> =
1034            IndexedStack(Some(1), (probe(0), probe(1), probe(2)));
1035        let w = build(&view);
1036        let mut all = 0;
1037        w.visit_children(&mut |_| all += 1);
1038        let mut entered: Vec<*const ChildPod> = Vec::new();
1039        w.visit_focus_children(&mut |pod| entered.push(pod));
1040        assert_eq!(all, 3, "inspection still sees every retained child");
1041        assert_eq!(entered, vec![&w.children[1] as *const ChildPod]);
1042
1043        let none: IndexedStackView<Vec<u32>> = IndexedStack(None, (probe(0),));
1044        let mut entered = 0;
1045        build(&none).visit_focus_children(&mut |_| entered += 1);
1046        assert_eq!(entered, 0, "nothing selected, nothing to traverse");
1047    }
1048}