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