Skip to main content

frust_widgets/motion/
switcher.rs

1//! Keyed single-slot child-switch container: [`PatternSwitcher`], Flutter's
2//! `PageTransitionSwitcher`/`AnimatedSwitcher` analog. It stages an
3//! outgoing/incoming child pair under a
4//! [`TransitionPattern`](super::patterns::TransitionPattern) whenever its
5//! declared child changes *identity* (a [`ChildKey`](crate::ChildKey)).
6//!
7//! # Identity, not equality, drives the transition
8//!
9//! The switcher holds one child plus a caller-supplied `key`. On a rebuild
10//! whose `key` is **unchanged**, the child reconciles in place like any
11//! single-child wrapper — no transition (an every-frame rebuild is free). On a
12//! **changed** `key`, the current child widget is *frozen* and retained as the
13//! **exiting** child while a fresh **incoming** child is built in its place;
14//! both then stage under the pattern, driven `0→1` off the frame clock
15//! (advance-during-paint, `request_frame` — the crate's shared animation
16//! contract). Same identity ⇒ same widget, state preserved; changed identity ⇒
17//! a real subtree swap with an animated hand-off. This mirrors Flutter's
18//! `PageTransitionSwitcher` "identity = type + key" rule.
19//!
20//! # Frozen exiting child: cancelled, never routed, dropped on settle
21//!
22//! While a transition runs the exiting child is inert: it keeps *painting* its
23//! last state but is removed from the event path entirely — pointer routing
24//! goes to the incoming child only ([`event`](PatternSwitcherWidget::event)).
25//! At the instant it is frozen it receives the navigator's
26//! **container-suppresses-routing** sequence verbatim (`docs/CODE_STANDARDS.md`):
27//! a synthetic `Cancel` unwinds any in-flight capture, its focus flag is
28//! cleared, and a cleared IME surface is published on the next paint — in that
29//! order. It is retained until progress reaches `1.0`, then dropped on the next
30//! rebuild; unlike the navigator (which retains a page *builder*), the switcher
31//! holds no retained view for the frozen widget, so its teardown is a drop —
32//! sound because [`frust_core::ComponentWidget`] disposes its reactive owner on
33//! `Drop` defensively (`docs/CODE_STANDARDS.md`'s State & Reactivity rule).
34//!
35//! # Compositing: paint-only, route-to-incoming keeps input correct
36//!
37//! A resolved [`PatternLayer`](super::patterns::PatternLayer) is applied as a
38//! pod-origin offset (so a slide's paint and hit-test move together), a
39//! `push_layer` opacity, and a `push_transform` scale about the child's centre.
40//! `push_layer`/`push_transform` are **paint-only** (no hit-test effect); it is
41//! the route-to-incoming-only rule above — not the transforms — that keeps
42//! input correct mid-transition.
43//!
44//! # Timing & reduced motion
45//!
46//! Timing defaults from the theme's [`MotionScheme`](frust_theme::MotionScheme)
47//! (`durations.base` + `easing.effects`), overridable with
48//! [`.timing(...)`](PatternSwitcherView::timing); with no theme threaded it
49//! falls back to [`FALLBACK_DURATION`] + [`Curve::Emphasized`]. A
50//! `reduce_motion` theme collapses *every* pattern to a fast
51//! ([`REDUCE_MOTION_DURATION`]) linear [`FadeThrough`] crossfade — the same hard
52//! accessibility rule `nav::transition::resolve_spec` enforces.
53//!
54//! # Scaffold contract
55//!
56//! This file owns its own contents only — it never edits `motion/mod.rs`'s
57//! module list or re-export block (see that module's docs; the facade
58//! re-exports `motion` wholesale, so these types ride along under
59//! `frust::motion::*`).
60
61use std::sync::atomic::Ordering;
62use std::time::Duration;
63
64use frust_core::{
65    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Curve, DiscardScene, EditingState,
66    EventCtx, EventResult, ImeState, InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx,
67    View, Widget, any,
68};
69use frust_theme::Theme;
70use kurbo::{Point, Size, Vec2};
71
72use crate::ChildKey;
73use crate::Timing;
74use crate::motion::patterns::{FadeThrough, PatternLayer, TransitionPattern};
75use crate::nav::navigator::NEXT_SNAPSHOT_KEY;
76use crate::nav::transition::{TransitionDriver, make_driver};
77
78/// The unthemed fallback transition duration (the Glyph `base` token, 220ms) —
79/// used when no theme is threaded to resolve a `MotionScheme` from (bare-core
80/// tests, pre-theme apps). See the [module docs](self)'s timing section.
81pub const FALLBACK_DURATION: Duration = Duration::from_millis(220);
82
83/// The reduced-motion collapse duration: `reduce_motion` flattens every pattern
84/// to a `≤120ms` linear [`FadeThrough`] crossfade — the Glyph design system's
85/// hard accessibility rule, matching `nav::transition`'s
86/// [`REDUCE_MOTION_DURATION`](crate::nav::transition) collapse.
87pub const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
88
89/// Resolve the effective [`Timing`] for a switch: an explicit builder value
90/// always wins; otherwise the theme's `base` duration + `effects` easing,
91/// falling back to [`FALLBACK_DURATION`] + [`Curve::Emphasized`] when no theme
92/// is threaded.
93fn resolve_switch_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
94    explicit.unwrap_or_else(|| match theme {
95        Some(theme) => Timing::Duration(
96            Duration::from_secs_f64(theme.motion.durations.base / 1000.0),
97            theme.motion.easing.effects,
98        ),
99        None => Timing::Duration(FALLBACK_DURATION, Curve::Emphasized),
100    })
101}
102
103/// Paint one staged child: offset its pod origin by the layer's `dx`/`dy` (so
104/// paint and hit-testing move together), then bracket its paint with a
105/// snapshot bracket, keyed by `snapshot`, using `layer`'s alpha/scale — no
106/// `push_transform`/`push_layer` of its own. Both the live and frozen exiting
107/// child are static during a switch (only their position/alpha/scale move,
108/// never their content), the same "ideal cache candidate" reasoning
109/// `nav::navigator`'s `paint_page_layer` applies to a static transition page —
110/// see [`PatternSwitcherWidget::snapshot_base`] for the two keys this bracket
111/// draws from.
112///
113/// When `alpha == 0` the child is fully invisible this frame: rather than
114/// rasterize it under a zero-opacity layer, it paints into a [`DiscardScene`]
115/// sink instead — the pass still has to run for its side effects (animating
116/// descendants advancing), but nothing it records is ever composited, so no
117/// bracket is needed at all. Mirrors `nav::navigator`'s `paint_page_layer`,
118/// whose `DiscardScene` check also runs first, ahead of its own bracket.
119fn paint_staged_child(
120    pod: &mut ChildPod,
121    ctx: &mut PaintCtx,
122    scene: &mut dyn PaintScene,
123    layer: PatternLayer,
124    snapshot: u64,
125) {
126    pod.set_origin(Point::new(layer.dx, layer.dy));
127    let alpha = layer.alpha.clamp(0.0, 1.0);
128
129    if alpha <= 0.0 {
130        let mut sink = DiscardScene;
131        pod.paint_child(ctx, &mut sink);
132        return;
133    }
134
135    let bracket_origin = ctx.origin() + Vec2::new(layer.dx, layer.dy);
136    scene.push_snapshot(snapshot, bracket_origin, ctx.size(), alpha, layer.scale);
137    pod.paint_child(ctx, scene);
138    scene.pop_snapshot();
139}
140
141/// The cleared/inactive IME surface published after a switch so the platform
142/// keyboard hides deterministically (mirrors `nav::navigator`'s
143/// `cleared_ime_state`).
144fn cleared_ime_state() -> ImeState {
145    ImeState {
146        active: false,
147        editing: EditingState {
148            text: String::new(),
149            selection_base: -1,
150            selection_extent: -1,
151            composing_base: -1,
152            composing_extent: -1,
153        },
154        caret: None,
155        content_type: Default::default(),
156        suppress_soft_keyboard: false,
157    }
158}
159
160/// A keyed single-slot child-switch container. See the [module docs](self).
161pub struct PatternSwitcherView<State: 'static, P: TransitionPattern + Clone + 'static> {
162    key: ChildKey,
163    pattern: P,
164    timing: Option<Timing>,
165    reverse: bool,
166    child: AnyView<State>,
167}
168
169/// Stage a keyed child switch under `pattern`: whenever `key` changes identity,
170/// the previous child animates out and `child` animates in. See the
171/// [module docs](self).
172pub fn pattern_switcher<State, P, V>(
173    key: impl Into<ChildKey>,
174    pattern: P,
175    child: V,
176) -> PatternSwitcherView<State, P>
177where
178    State: 'static,
179    P: TransitionPattern + Clone + 'static,
180    V: View<State>,
181{
182    PatternSwitcherView {
183        key: key.into(),
184        pattern,
185        timing: None,
186        reverse: false,
187        child: any(child),
188    }
189}
190
191/// PascalCase alias for [`pattern_switcher`] (mirrors the `AnimatedOpacity`/
192/// `animated_opacity` dual naming).
193#[allow(non_snake_case)]
194pub fn PatternSwitcher<State, P, V>(
195    key: impl Into<ChildKey>,
196    pattern: P,
197    child: V,
198) -> PatternSwitcherView<State, P>
199where
200    State: 'static,
201    P: TransitionPattern + Clone + 'static,
202    V: View<State>,
203{
204    pattern_switcher(key, pattern, child)
205}
206
207impl<State: 'static, P: TransitionPattern + Clone + 'static> PatternSwitcherView<State, P> {
208    /// Override the default (theme-resolved) transition timing.
209    pub fn timing(mut self, timing: Timing) -> Self {
210        self.timing = Some(timing);
211        self
212    }
213
214    /// Flip the pattern's directional motion (a "back"/reverse switch). Has no
215    /// effect on the non-directional patterns ([`FadeThrough`]/`FadeScale`).
216    pub fn reverse(mut self, reverse: bool) -> Self {
217        self.reverse = reverse;
218        self
219    }
220}
221
222/// The retained widget for a [`PatternSwitcherView`]. Generic only over the
223/// pattern `P` (the frozen exiting child is retained as a bare
224/// [`ChildPod`](frust_core::ChildPod), so no `State` is captured — see the
225/// [module docs](self)'s teardown note).
226pub struct PatternSwitcherWidget<P: TransitionPattern + Clone + 'static> {
227    key: ChildKey,
228    child: ChildPod,
229    /// The frozen outgoing child, retained (painting its last state) until the
230    /// transition settles, then dropped.
231    exiting: Option<ChildPod>,
232    pattern: P,
233    explicit_timing: Option<Timing>,
234    reverse: bool,
235    /// The `0→1` progress driver, created lazily on the first paint of a
236    /// transition (a `BuildCtx` has no theme to resolve timing/`reduce_motion`
237    /// from — mirrors `motion::animated`'s deferred retarget).
238    driver: Option<TransitionDriver>,
239    /// A transition was staged by the last rebuild; the next paint resolves
240    /// timing and creates [`Self::driver`].
241    pending_start: bool,
242    /// The driver reached rest this paint; the next rebuild finalizes (drops the
243    /// exiting child).
244    settled: bool,
245    /// The active transition collapsed to a `reduce_motion` crossfade (resolved
246    /// once at start, stable for the whole transition).
247    reduce_motion: bool,
248    /// A cleared IME surface must be published on the next paint (deterministic
249    /// keyboard hide after the switch).
250    needs_ime_clear: bool,
251    /// This switcher's pair of [`PaintScene::push_snapshot`] cache keys,
252    /// reserved once from [`NEXT_SNAPSHOT_KEY`] at construction (`fetch_add(2)`)
253    /// and stable for the widget's lifetime, across every switch it ever
254    /// stages: `snapshot_base` for the live (incoming) child,
255    /// `snapshot_base + 1` for the frozen exiting pod. A single-slot container
256    /// has no slot index to key by (unlike the navigator's per-page keys), but
257    /// still needs two distinct keys since both children can be on screen
258    /// staged together — reusing one key for both would alias their cached
259    /// rasters.
260    snapshot_base: u64,
261}
262
263impl<State: 'static, P: TransitionPattern + Clone + 'static> View<State>
264    for PatternSwitcherView<State, P>
265{
266    type Element = PatternSwitcherWidget<P>;
267
268    fn build(&self, ctx: &mut BuildCtx<'_>) -> PatternSwitcherWidget<P> {
269        PatternSwitcherWidget {
270            key: self.key,
271            child: crate::authoring::build_child(&self.child, ctx),
272            exiting: None,
273            pattern: self.pattern.clone(),
274            explicit_timing: self.timing,
275            reverse: self.reverse,
276            driver: None,
277            pending_start: false,
278            settled: false,
279            reduce_motion: false,
280            needs_ime_clear: false,
281            snapshot_base: NEXT_SNAPSHOT_KEY.fetch_add(2, Ordering::Relaxed),
282        }
283    }
284
285    fn rebuild(
286        &self,
287        prev: &Self,
288        element: &mut PatternSwitcherWidget<P>,
289        ctx: &mut BuildCtx<'_>,
290    ) -> ChangeFlags {
291        let mut flags = ChangeFlags::NONE;
292
293        // Pattern/timing/reverse are paint-time inputs; refresh them (a pattern
294        // swap mid-flight is unusual but harmless — the running driver is
295        // untouched). Only flag PAINT when a value actually changed.
296        element.pattern = self.pattern.clone();
297        if element.explicit_timing != self.timing {
298            element.explicit_timing = self.timing;
299            flags |= ChangeFlags::PAINT;
300        }
301        if element.reverse != self.reverse {
302            element.reverse = self.reverse;
303            flags |= ChangeFlags::PAINT;
304        }
305
306        // Finalize a just-settled transition (paint flagged `settled` and
307        // requested this frame): drop the frozen exiting child. `Option::take`
308        // via reassignment runs `ComponentWidget`'s defensive `Drop`.
309        if element.settled {
310            element.exiting = None;
311            element.driver = None;
312            element.settled = false;
313        }
314
315        if self.key != element.key {
316            // --- Identity change: stage a transition. ---
317            // A new switch supersedes any still-running one (drop its exiting).
318            element.exiting = None;
319            element.driver = None;
320            element.reduce_motion = false;
321
322            // Freeze the current incoming child as the exiting child; build the
323            // new incoming child fresh in its place.
324            let mut old = std::mem::replace(
325                &mut element.child,
326                crate::authoring::build_child(&self.child, ctx),
327            );
328
329            // Container-suppresses-routing contract (docs/CODE_STANDARDS.md):
330            // synthetic Cancel -> focus clear -> cleared-IME publish, in order.
331            if old.is_active() {
332                crate::authoring::cancel_pod(&mut old);
333                old.set_active(false);
334            }
335            if old.is_focused() {
336                old.set_focused(false);
337                // The cleared-IME publish is the third step of that contract, and
338                // it belongs INSIDE this arm: publishing an inactive surface is a
339                // full focus/IME **session release** at the root, not a value
340                // update (`docs/CORE_ARCHITECTURE.md`'s Focus/IME Lifecycle), and
341                // it bubbles last-write-wins. A switcher whose own child never
342                // held focus has no session to end — raising the flag anyway
343                // would release whatever *unrelated* subtree does own one (a
344                // field painted earlier in the same frame), with no self-heal.
345                //
346                // Gated on the same composition every severing site in the crate
347                // uses (`frust-widgets`' `mark_orphan_if_live`): the outgoing
348                // pod's own link ANDed with the rebuild-pass chain down to this
349                // switcher, so a *stale* flag under an already-blurred ancestor
350                // publishes nothing either. `ctx.has_focus()` is unaffected by the
351                // `build_child` above — that descent restores the chain on the
352                // way out.
353                if ctx.has_focus() {
354                    element.needs_ime_clear = true;
355                }
356            }
357
358            element.exiting = Some(old);
359            element.key = self.key;
360            element.pending_start = true;
361            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
362        } else {
363            // --- Same identity: an ordinary in-place reconcile, no transition. ---
364            flags |=
365                crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
366        }
367
368        flags
369    }
370
371    fn teardown(&self, element: &mut PatternSwitcherWidget<P>, ctx: &mut BuildCtx<'_>) {
372        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
373        // The frozen exiting child has no retained view to run `View::teardown`
374        // against; dropping it disposes any Component owner via the defensive
375        // `Drop` (module docs).
376        element.exiting = None;
377    }
378}
379
380impl<P: TransitionPattern + Clone + 'static> Widget for PatternSwitcherWidget<P> {
381    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
382        let size = self.child.layout_child(ctx, bc);
383        self.child.set_origin(Point::ZERO);
384        // Lay out the frozen exiting child too (under the same constraints) so it
385        // has a valid size to paint at during the transition. Its origin is set
386        // per-frame in `paint` from the pattern layer.
387        if let Some(exiting) = &mut self.exiting {
388            exiting.layout_child(ctx, bc);
389            exiting.set_origin(Point::ZERO);
390        }
391        size
392    }
393
394    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
395        // Lazily create the driver now a theme is in scope (reduce_motion +
396        // timing resolution), mirroring `motion::animated`'s deferred retarget.
397        if self.pending_start {
398            let theme = Theme::from_paint_ctx(ctx);
399            let reduce = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
400            let timing = if reduce {
401                Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear)
402            } else {
403                resolve_switch_timing(theme, self.explicit_timing)
404            };
405            self.driver = Some(make_driver(timing).0);
406            self.reduce_motion = reduce;
407            self.pending_start = false;
408        }
409
410        if let (Some(exiting), Some(driver)) = (self.exiting.as_mut(), self.driver.as_mut()) {
411            let adv = driver.advance(ctx.frame_time());
412            let size = ctx.size();
413            // reduce_motion collapses any pattern to a FadeThrough crossfade.
414            let (incoming_layer, exiting_layer) = if self.reduce_motion {
415                FadeThrough.resolve(adv.value, self.reverse, size)
416            } else {
417                self.pattern.resolve(adv.value, self.reverse, size)
418            };
419            // Paint the exiting child (below) then the incoming child (on top).
420            // `snapshot_base` keys the live child; `snapshot_base + 1` the
421            // frozen exiting pod — see `Self::snapshot_base`.
422            paint_staged_child(exiting, ctx, scene, exiting_layer, self.snapshot_base + 1);
423            paint_staged_child(
424                &mut self.child,
425                ctx,
426                scene,
427                incoming_layer,
428                self.snapshot_base,
429            );
430
431            if adv.animating {
432                ctx.request_frame();
433            }
434            if adv.done {
435                // Settle: finalize on the next rebuild; request one more frame so
436                // that rebuild runs (mirrors the navigator's settle contract).
437                self.settled = true;
438                ctx.request_frame();
439            }
440        } else {
441            self.child.paint_child(ctx, scene);
442        }
443
444        // Deterministic IME hide after a switch (navigator contract). Publishing
445        // a cleared surface bubbles up through `ChildPod::paint_child`.
446        if self.needs_ime_clear {
447            ctx.publish_ime_state(cleared_ime_state());
448            self.needs_ime_clear = false;
449        }
450    }
451
452    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
453        // Route only to the incoming child; the frozen exiting child left the
454        // event path at transition start (synthetically cancelled) — module docs.
455        crate::authoring::route_event_single(&mut self.child, ctx, event)
456    }
457
458    fn semantics(&self, ctx: &mut SemanticsCtx) {
459        // Forward the incoming (destination) child; the transient exiting child
460        // is mid-removal and is not reported.
461        self.child.semantics_child(ctx);
462    }
463
464    crate::authoring::visit_children!(exiting, child);
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::motion::patterns::{FadeScale, SharedAxis};
471    use crate::test_support::{RecordingScene, leaf_any};
472    use crate::{Column, FlexView};
473    use frust_core::{FrameTime, PointerButton, PointerEvent, PointerPhase, RenderRoot};
474    use kurbo::Rect;
475    use std::cell::Cell;
476    use std::rc::Rc;
477
478    fn ft_ms(ms: f64) -> FrameTime {
479        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
480    }
481
482    fn build<S: 'static, V: View<S>>(view: &V) -> V::Element {
483        let mut counter = 0u64;
484        view.build(&mut BuildCtx::new(&mut counter))
485    }
486
487    fn down(x: f64, y: f64) -> InputEvent {
488        InputEvent::Pointer(PointerEvent {
489            phase: PointerPhase::Down,
490            position: Point::new(x, y),
491            button: PointerButton::Primary,
492        })
493    }
494
495    fn loose() -> BoxConstraints {
496        BoxConstraints::loose(Size::new(100.0, 100.0))
497    }
498
499    /// A recording scene that separates `push_snapshot`/`pop_snapshot` calls
500    /// from `push_layer`/`push_transform` ones. `crate::test_support`'s shared
501    /// `RecordingScene` doesn't override `push_snapshot`, so it would fold a
502    /// snapshot bracket into the trait's emulating default — a `push_transform`
503    /// and `push_layer` pair — indistinguishable from a plain bracket. This one
504    /// keeps them apart so a test can assert a staged child painted through
505    /// the snapshot bracket specifically, and through no other bracket.
506    #[derive(Default)]
507    struct SnapshotRecordingScene {
508        rects: Vec<(Point, Size)>,
509        layers: Vec<(Point, Size, f32)>,
510        transform_pushes: u32,
511        snapshots: Vec<(u64, Point, Size, f32, f64)>,
512        snapshot_pops: u32,
513    }
514    impl PaintScene for SnapshotRecordingScene {
515        fn fill_rect(&mut self, origin: Point, size: Size, _color: peniko::Color) {
516            self.rects.push((origin, size));
517        }
518        fn draw_text(&mut self, _origin: Point, _text: &str) {}
519        fn push_layer(&mut self, origin: Point, size: Size, alpha: f32) {
520            self.layers.push((origin, size, alpha));
521        }
522        fn push_transform(&mut self, _transform: kurbo::Affine) {
523            self.transform_pushes += 1;
524        }
525        fn push_snapshot(&mut self, key: u64, origin: Point, size: Size, alpha: f32, scale: f64) {
526            self.snapshots.push((key, origin, size, alpha, scale));
527        }
528        fn pop_snapshot(&mut self) {
529            self.snapshot_pops += 1;
530        }
531    }
532
533    /// An interactive child that captures the pointer + takes focus on `Down`,
534    /// records a synthetic `Cancel` into a shared cell, and records its own
535    /// teardown (a drop) into a shared counter — the recording interactive child
536    /// the tests below assert against (mirrors the navigator's recording tests).
537    struct Recorder {
538        cancelled: Rc<Cell<bool>>,
539        torn: Rc<Cell<u32>>,
540    }
541    struct RecorderWidget {
542        cancelled: Rc<Cell<bool>>,
543        torn: Rc<Cell<u32>>,
544    }
545    impl View<()> for Recorder {
546        type Element = RecorderWidget;
547        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RecorderWidget {
548            RecorderWidget {
549                cancelled: self.cancelled.clone(),
550                torn: self.torn.clone(),
551            }
552        }
553        fn rebuild(
554            &self,
555            _prev: &Self,
556            _element: &mut RecorderWidget,
557            _ctx: &mut BuildCtx<'_>,
558        ) -> ChangeFlags {
559            ChangeFlags::NONE
560        }
561    }
562    impl Widget for RecorderWidget {
563        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
564            bc.max()
565        }
566        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
567        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
568            if let InputEvent::Pointer(p) = event {
569                match p.phase {
570                    PointerPhase::Down => {
571                        ctx.capture_pointer();
572                        ctx.request_focus();
573                        return EventResult::Handled;
574                    }
575                    // A Cancel arm only clears internal flags (never app state) —
576                    // recording into an owned `Rc<Cell>` is an internal flag.
577                    PointerPhase::Cancel => {
578                        self.cancelled.set(true);
579                        return EventResult::Handled;
580                    }
581                    _ => {}
582                }
583            }
584            EventResult::Ignored
585        }
586    }
587    impl Drop for RecorderWidget {
588        fn drop(&mut self) {
589            self.torn.set(self.torn.get() + 1);
590        }
591    }
592
593    fn recorder(cancelled: &Rc<Cell<bool>>, torn: &Rc<Cell<u32>>) -> Recorder {
594        Recorder {
595            cancelled: cancelled.clone(),
596            torn: torn.clone(),
597        }
598    }
599
600    fn reduced_motion_theme() -> Theme {
601        let mut theme = Theme::neutral();
602        theme.motion.reduce_motion = true;
603        theme
604    }
605
606    // --- Identity change transitions; same identity doesn't ---
607
608    #[test]
609    fn same_identity_rebuild_does_not_transition_but_changed_identity_does() {
610        let v1: PatternSwitcherView<(), _> =
611            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
612        let mut w = build(&v1);
613        assert!(w.exiting.is_none());
614
615        // Same key: an ordinary reconcile, no transition staged.
616        let v2: PatternSwitcherView<(), _> =
617            pattern_switcher(1u32, FadeThrough, leaf_any(20.0, 20.0));
618        let mut counter = 0u64;
619        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
620        assert!(
621            w.exiting.is_none(),
622            "same identity must not stage a transition"
623        );
624        assert!(!w.pending_start);
625
626        // Changed key: the old child is frozen as the exiting child.
627        let v3: PatternSwitcherView<(), _> =
628            pattern_switcher(2u32, FadeThrough, leaf_any(30.0, 30.0));
629        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
630        assert!(
631            w.exiting.is_some(),
632            "an identity change must retain the exiting child"
633        );
634        assert!(w.pending_start, "the next paint starts the driver");
635        assert_eq!(w.key, 2u32.into());
636    }
637
638    // --- Exiting child gets Cancel + focus/IME clear ---
639
640    #[test]
641    fn identity_change_cancels_focus_and_clears_ime_on_the_exiting_child() {
642        let cancelled = Rc::new(Cell::new(false));
643        let torn = Rc::new(Cell::new(0u32));
644        let v1: PatternSwitcherView<(), _> =
645            pattern_switcher(1u32, FadeThrough, any(recorder(&cancelled, &torn)));
646        let mut w = build(&v1);
647        let size = w.layout(&mut LayoutCtx::new(), &loose());
648
649        // Arm the child: a Down captures the pointer and takes focus.
650        let mut st = ();
651        let mut ectx = EventCtx::new(&mut st, Point::ZERO, size);
652        w.event(&mut ectx, &down(5.0, 5.0));
653        assert!(w.child.is_active(), "the child captured the pointer");
654        assert!(w.child.is_focused(), "the child took focus");
655
656        // Switch identity: the armed child becomes the exiting child and gets the
657        // capture-cancel -> focus-clear -> IME-clear sequence.
658        let v2: PatternSwitcherView<(), _> =
659            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0));
660        let mut counter = 0u64;
661        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
662
663        assert!(
664            cancelled.get(),
665            "the exiting child received a synthetic Cancel"
666        );
667        let exiting = w.exiting.as_ref().expect("exiting child retained");
668        assert!(
669            !exiting.is_focused(),
670            "the exiting child's focus was cleared"
671        );
672        assert!(!exiting.is_active(), "its capture path was released");
673        assert!(
674            w.needs_ime_clear,
675            "an IME clear is queued for the next paint"
676        );
677
678        // The paint after the switch publishes the cleared IME surface.
679        let mut pctx = PaintCtx::new(Point::ZERO, size);
680        let mut scene = RecordingScene::default();
681        w.paint(&mut pctx, &mut scene);
682        let ime = pctx
683            .take_ime_state()
684            .expect("a cleared IME surface was published on the post-switch paint");
685        assert!(!ime.active);
686        assert!(ime.editing.text.is_empty());
687        assert!(!w.needs_ime_clear, "the queued clear was consumed");
688    }
689
690    // --- Cross-subtree survival: a switcher whose OWN child never held focus
691    //     must not release somebody else's live session (review-fix-3, FC). ---
692
693    /// The IME surface the sibling field owns for the whole test below.
694    fn field_surface() -> ImeState {
695        ImeState {
696            active: true,
697            editing: EditingState {
698                text: "query".to_string(),
699                selection_base: 5,
700                selection_extent: 5,
701                composing_base: -1,
702                composing_extent: -1,
703            },
704            caret: Some(Rect::new(0.0, 0.0, 1.0, 12.0)),
705            content_type: Default::default(),
706            suppress_soft_keyboard: false,
707        }
708    }
709
710    /// A **persistent** field-shaped leaf: it claims focus and publishes on
711    /// `Down`, and republishes the same surface on every paint while it still
712    /// holds focus — a real `TextInput`'s behavior, and the reason paint order
713    /// (field first, switcher second) decides the last write. Bounded size so it
714    /// can sit as an inflexible child on a `Column`'s unbounded main axis.
715    struct PersistentField {
716        size: Size,
717    }
718    struct PersistentFieldWidget {
719        size: Size,
720    }
721    impl<S: 'static> View<S> for PersistentField {
722        type Element = PersistentFieldWidget;
723        fn build(&self, _ctx: &mut BuildCtx<'_>) -> PersistentFieldWidget {
724            PersistentFieldWidget { size: self.size }
725        }
726        fn rebuild(
727            &self,
728            _prev: &Self,
729            _element: &mut PersistentFieldWidget,
730            _ctx: &mut BuildCtx<'_>,
731        ) -> ChangeFlags {
732            ChangeFlags::NONE
733        }
734    }
735    impl Widget for PersistentFieldWidget {
736        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
737            bc.constrain(self.size)
738        }
739        fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
740            if ctx.has_focus() {
741                ctx.publish_ime_state(field_surface());
742            }
743        }
744        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
745            if let InputEvent::Pointer(p) = event
746                && p.phase == PointerPhase::Down
747            {
748                ctx.request_focus();
749                ctx.publish_ime_state(field_surface());
750                return EventResult::Handled;
751            }
752            EventResult::Ignored
753        }
754    }
755
756    #[test]
757    fn identity_change_in_an_unfocused_switcher_leaves_a_sibling_fields_session_alive() {
758        // A persistent field as the EARLIER sibling of an unrelated switcher, so
759        // the field paints FIRST and anything the switcher published would win
760        // last-write-wins on the way to the root. Rows are 40 tall: the field
761        // owns `y ∈ [0, 40)`, the switcher `y ∈ [40, 80)`.
762        let key = Rc::new(Cell::new(1u32));
763        let mut app = {
764            let key = key.clone();
765            move |_: &mut ()| {
766                Column(vec![
767                    any(PersistentField {
768                        size: Size::new(100.0, 40.0),
769                    }),
770                    any(pattern_switcher(
771                        key.get(),
772                        FadeThrough,
773                        leaf_any(100.0, 40.0),
774                    )),
775                ])
776            }
777        };
778        let mut root: RenderRoot<(), FlexView<()>> = RenderRoot::new();
779        let mut state = ();
780        root.rebuild(&mut app, &mut state);
781        root.layout(Size::new(100.0, 100.0));
782        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
783
784        // Tap inside the field's row — never the switcher's, which would blur the
785        // field on the way in (blur-on-outside-tap) and hide the defect.
786        root.event(&mut state, &down(5.0, 5.0));
787        assert!(
788            root.is_focus_active(),
789            "the sibling field owns the focus session"
790        );
791        assert_eq!(
792            root.ime_state().map(|s| s.editing.text),
793            Some("query".to_string()),
794            "…and the shell-facing surface is the field's"
795        );
796        let generation = root.focus_ime_generation();
797
798        // The switcher's identity changes. Its own child never held focus, so
799        // there is no session here to end.
800        key.set(2);
801        root.rebuild(&mut app, &mut state);
802        root.layout(Size::new(100.0, 100.0));
803        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
804
805        assert!(
806            root.is_focus_active(),
807            "an identity change in an unfocused switcher must not release the \
808             sibling field's session"
809        );
810        let ime = root
811            .ime_state()
812            .expect("the identity change dropped the sibling field's IME surface entirely");
813        assert!(ime.active, "the field's surface was left inactive");
814        assert_eq!(
815            ime.editing.text, "query",
816            "the field's surface was replaced with the switcher's cleared one"
817        );
818        assert_eq!(
819            root.focus_ime_generation(),
820            generation,
821            "an identity change in an unfocused switcher is not a focus/IME edge"
822        );
823    }
824
825    // --- Exiting retained until progress 1.0, then torn down ---
826
827    #[test]
828    fn exiting_child_is_retained_until_settle_then_torn_down() {
829        let cancelled = Rc::new(Cell::new(false));
830        let torn = Rc::new(Cell::new(0u32));
831        let short = Timing::Duration(Duration::from_millis(100), Curve::Linear);
832        let v1: PatternSwitcherView<(), _> =
833            pattern_switcher(1u32, FadeThrough, any(recorder(&cancelled, &torn))).timing(short);
834        let mut w = build(&v1);
835        let size = w.layout(&mut LayoutCtx::new(), &loose());
836
837        // Switch identity -> transition begins; exiting retained, not torn down.
838        let v2: PatternSwitcherView<(), _> =
839            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
840        let mut counter = 0u64;
841        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
842        assert!(w.exiting.is_some());
843        assert_eq!(
844            torn.get(),
845            0,
846            "exiting not torn down while the transition runs"
847        );
848
849        // First paint creates + seeds the driver (still mid-flight).
850        w.paint(
851            &mut PaintCtx::new(Point::ZERO, size),
852            &mut RecordingScene::default(),
853        );
854        assert!(w.exiting.is_some(), "retained during the transition");
855        assert_eq!(torn.get(), 0);
856        assert!(!w.settled);
857
858        // Drive the retained driver to rest directly (PaintCtx::set_frame_time is
859        // crate-private to frust-core, mirroring the animated.rs tests).
860        w.driver
861            .as_mut()
862            .expect("driver created on first paint")
863            .advance(ft_ms(10_000.0));
864
865        // The next paint observes the settled driver and flags finalize.
866        w.paint(
867            &mut PaintCtx::new(Point::ZERO, size),
868            &mut RecordingScene::default(),
869        );
870        assert!(w.settled, "paint flags settle once the driver reaches rest");
871        assert!(
872            w.exiting.is_some(),
873            "still retained until the finalize rebuild"
874        );
875        assert_eq!(torn.get(), 0);
876
877        // The finalize rebuild (same identity, key 2) tears the exiting child down.
878        let v3: PatternSwitcherView<(), _> =
879            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
880        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
881        assert!(w.exiting.is_none(), "exiting dropped after settle");
882        assert_eq!(
883            torn.get(),
884            1,
885            "exiting torn down exactly once, after progress 1.0"
886        );
887        assert!(!w.settled);
888        assert!(w.driver.is_none());
889    }
890
891    // --- reduce_motion collapses to a fast crossfade ---
892
893    #[test]
894    fn reduce_motion_collapses_to_fast_crossfade() {
895        let theme = reduced_motion_theme();
896        // Configure a *directional* pattern (SharedAxis::X slides the incoming
897        // child by 30dp) so the collapse to a non-directional crossfade is
898        // observable in the child's pod origin.
899        let v1: PatternSwitcherView<(), _> =
900            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0));
901        let mut w = build(&v1);
902        let size = w.layout(&mut LayoutCtx::new(), &loose());
903
904        let v2: PatternSwitcherView<(), _> =
905            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0));
906        let mut counter = 0u64;
907        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
908
909        let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
910        w.paint(&mut pctx, &mut RecordingScene::default());
911
912        assert!(
913            w.reduce_motion,
914            "reduce_motion must be resolved from the threaded theme"
915        );
916        // FadeThrough collapse: the incoming child never slides (dx stays 0),
917        // unlike the configured SharedAxis::X (which offsets it by 30dp at p=0).
918        assert_eq!(
919            w.child.origin(),
920            Point::ZERO,
921            "reduce_motion collapses to a non-directional crossfade (no slide)"
922        );
923    }
924
925    #[test]
926    fn without_reduce_motion_the_configured_directional_pattern_slides() {
927        // The contrast case for the reduce_motion test above: an untheme'd (no
928        // reduce_motion) SharedAxis::X switch DOES offset the incoming child.
929        let v1: PatternSwitcherView<(), _> =
930            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0));
931        let mut w = build(&v1);
932        let size = w.layout(&mut LayoutCtx::new(), &loose());
933
934        let v2: PatternSwitcherView<(), _> =
935            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0));
936        let mut counter = 0u64;
937        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
938
939        w.paint(
940            &mut PaintCtx::new(Point::ZERO, size),
941            &mut RecordingScene::default(),
942        );
943        assert!(!w.reduce_motion);
944        // At the seed frame (p=0) SharedAxis::X offsets the incoming child by the
945        // full 30dp slide.
946        assert_eq!(w.child.origin().x, 30.0);
947    }
948
949    // --- No-transition steady state paints the child once, plainly ---
950
951    #[test]
952    fn steady_state_paints_the_single_child_with_no_layer_or_transform() {
953        let v: PatternSwitcherView<(), _> =
954            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
955        let mut w = build(&v);
956        let size = w.layout(&mut LayoutCtx::new(), &loose());
957
958        let mut scene = RecordingScene::default();
959        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
960        assert_eq!(scene.rects.len(), 1, "the single child paints its fill");
961        assert!(
962            scene.layers.is_empty(),
963            "no crossfade layer at steady state"
964        );
965        assert!(
966            scene.transforms.is_empty(),
967            "no scale transform at steady state"
968        );
969    }
970
971    // --- Discard: a staged child at alpha 0 paints into a DiscardScene sink,
972    //     recording no ops and no zero-alpha layer (mirrors nav::navigator's
973    //     paint_page_layer discard branch). ---
974
975    #[test]
976    fn alpha_zero_staged_child_records_no_ops_and_no_zero_alpha_layer() {
977        let leaf = leaf_any(10.0, 10.0);
978        let mut counter = 0u64;
979        let mut pod = crate::authoring::build_child(&leaf, &mut BuildCtx::new(&mut counter));
980        let size = pod.layout_child(&mut LayoutCtx::new(), &loose());
981        pod.set_origin(Point::ZERO);
982
983        let mut scene = RecordingScene::default();
984        let layer = PatternLayer {
985            dx: 0.0,
986            dy: 0.0,
987            alpha: 0.0,
988            scale: 1.0,
989        };
990        paint_staged_child(
991            &mut pod,
992            &mut PaintCtx::new(Point::ZERO, size),
993            &mut scene,
994            layer,
995            1,
996        );
997
998        assert!(scene.rects.is_empty(), "an alpha-0 child records no fill");
999        assert!(
1000            scene.layers.is_empty(),
1001            "no zero-alpha push_layer is recorded either"
1002        );
1003    }
1004
1005    // --- Snapshot bracket: a staged child paints through push_snapshot/
1006    //     pop_snapshot keyed by snapshot_base (incoming) / snapshot_base + 1
1007    //     (exiting), never a push_layer/push_transform bracket of its own —
1008    //     mirrors nav::navigator's paint_page_layer snapshot bracket. ---
1009
1010    #[test]
1011    fn staged_children_paint_through_a_snapshot_bracket_keyed_by_snapshot_base() {
1012        // FadeScale (unlike FadeThrough/SharedAxis's hard split) fades both
1013        // children simultaneously over its first 30%, so a mid-window frame
1014        // has both the incoming and the frozen exiting child at alpha > 0 at
1015        // once — the case that proves each gets its OWN key.
1016        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1017        let v1: PatternSwitcherView<(), _> =
1018            pattern_switcher(1u32, FadeScale, leaf_any(10.0, 10.0)).timing(timing);
1019        let mut w = build(&v1);
1020        let size = w.layout(&mut LayoutCtx::new(), &loose());
1021
1022        let v2: PatternSwitcherView<(), _> =
1023            pattern_switcher(2u32, FadeScale, leaf_any(10.0, 10.0)).timing(timing);
1024        let mut counter = 0u64;
1025        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1026
1027        // Seed frame: the first `advance` only seeds the clock (progress 0).
1028        w.paint(
1029            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1030            &mut SnapshotRecordingScene::default(),
1031        );
1032
1033        // 15ms into the 100ms linear duration: progress 0.15, inside
1034        // FadeScale's [0, 0.30] simultaneous-fade window.
1035        let base = w.snapshot_base;
1036        let mut scene = SnapshotRecordingScene::default();
1037        w.paint(
1038            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(15.0)),
1039            &mut scene,
1040        );
1041
1042        assert_eq!(
1043            scene.snapshots.len(),
1044            2,
1045            "both the incoming and frozen exiting child paint through a \
1046             snapshot bracket: {:?}",
1047            scene.snapshots
1048        );
1049        assert_eq!(scene.snapshot_pops, 2, "each bracket pops exactly once");
1050        assert!(
1051            scene.layers.is_empty(),
1052            "no push_layer bracket of its own: {:?}",
1053            scene.layers
1054        );
1055        assert_eq!(
1056            scene.transform_pushes, 0,
1057            "no push_transform bracket of its own"
1058        );
1059
1060        let keys: Vec<u64> = scene.snapshots.iter().map(|(k, ..)| *k).collect();
1061        assert!(
1062            keys.contains(&base),
1063            "the incoming child is keyed by snapshot_base: {keys:?}"
1064        );
1065        assert!(
1066            keys.contains(&(base + 1)),
1067            "the frozen exiting child is keyed by snapshot_base + 1: {keys:?}"
1068        );
1069        for (_, _, _, alpha, _) in &scene.snapshots {
1070            assert!(*alpha > 0.0, "an alpha-0 child never reaches the bracket");
1071        }
1072    }
1073
1074    #[test]
1075    fn bracket_origin_follows_the_pod_dx_in_slide_patterns() {
1076        // SharedAxis::X slides children by 30dp (incoming from +30, exiting to -30).
1077        // The bracket origin must follow the pod's absolute paint origin —
1078        // ctx.origin() + (dx, dy) — so the cached texture is slide-invariant and
1079        // the body lands inside the texture without cropping. This test verifies
1080        // the bracket origin includes the dx offset during a slide transition.
1081        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1082        let v1: PatternSwitcherView<(), _> =
1083            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0)).timing(timing);
1084        let mut w = build(&v1);
1085        let size = w.layout(&mut LayoutCtx::new(), &loose());
1086
1087        let v2: PatternSwitcherView<(), _> =
1088            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0)).timing(timing);
1089        let mut counter = 0u64;
1090        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1091
1092        // Seed frame: the first `advance` only seeds the clock (progress 0).
1093        w.paint(
1094            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1095            &mut SnapshotRecordingScene::default(),
1096        );
1097
1098        // At p=0 (seed frame), SharedAxis::X has:
1099        // - Exiting child: dx=0.0, alpha=1.0 (visible)
1100        // - Incoming child: dx=30.0, alpha=0.0 (discarded, not visible)
1101        // So only the exiting child paints through a snapshot bracket.
1102        let mut scene = SnapshotRecordingScene::default();
1103        w.paint(
1104            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1105            &mut scene,
1106        );
1107
1108        assert_eq!(
1109            scene.snapshots.len(),
1110            1,
1111            "only the exiting child is visible at p=0"
1112        );
1113
1114        // The exiting child has dx=0.0 at p=0, so its bracket origin should be
1115        // at ctx.origin() = (0.0, 0.0).
1116        let (_, origin, _, _, _) = scene.snapshots[0];
1117        assert_eq!(
1118            origin,
1119            Point::new(0.0, 0.0),
1120            "exiting child bracket origin at x=0.0 when dx=0 (was {:?})",
1121            origin
1122        );
1123
1124        // At a mid-transition frame like p=0.5 (50ms into 100ms duration),
1125        // SharedAxis::X has:
1126        // - Exiting child: dx=-15.0 (sliding out), alpha < 1.0
1127        // - Incoming child: dx=15.0 (sliding in), alpha > 0.0
1128        // Both are potentially visible and should have bracket origins that
1129        // include their respective dx values. However, SharedAxis uses a hard
1130        // split, so only one is above alpha 0 at any instant. At p=0.5 (past
1131        // the ~0.35 split), the incoming child is visible.
1132        let mut scene_mid = SnapshotRecordingScene::default();
1133        w.paint(
1134            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(50.0)),
1135            &mut scene_mid,
1136        );
1137
1138        // At p=0.5, the incoming child has dx = (1.0 - 0.5) * 30 = 15.0 and
1139        // is visible (past the split). Its bracket origin should reflect this.
1140        assert_eq!(
1141            scene_mid.snapshots.len(),
1142            1,
1143            "at mid-frame (p=0.5), only one child is visible through a snapshot"
1144        );
1145        let (_, origin_mid, _, _, _) = scene_mid.snapshots[0];
1146        assert_eq!(
1147            origin_mid,
1148            Point::new(15.0, 0.0),
1149            "incoming child bracket origin at x=15.0 when dx=15.0 (was {:?})",
1150            origin_mid
1151        );
1152    }
1153
1154    // --- snapshot_base is reserved once, at construction, and outlives every
1155    //     switch this widget instance stages. ---
1156
1157    #[test]
1158    fn snapshot_base_is_stable_across_multiple_switches_on_the_same_widget() {
1159        let v1: PatternSwitcherView<(), _> =
1160            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1161        let mut w = build(&v1);
1162        let base = w.snapshot_base;
1163        let mut counter = 0u64;
1164
1165        let v2: PatternSwitcherView<(), _> =
1166            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0));
1167        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1168        assert_eq!(
1169            w.snapshot_base, base,
1170            "a switch does not reassign the key pair"
1171        );
1172
1173        let v3: PatternSwitcherView<(), _> =
1174            pattern_switcher(3u32, FadeThrough, leaf_any(10.0, 10.0));
1175        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
1176        assert_eq!(
1177            w.snapshot_base, base,
1178            "a second switch on the same widget still keeps the same key pair"
1179        );
1180    }
1181
1182    // --- Two switcher instances never alias each other's cache slot. ---
1183
1184    #[test]
1185    fn two_switcher_instances_get_disjoint_snapshot_key_pairs() {
1186        let v: PatternSwitcherView<(), _> =
1187            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1188        let w1 = build(&v);
1189        let w2 = build(&v);
1190        assert_ne!(
1191            w1.snapshot_base, w2.snapshot_base,
1192            "two switcher instances never share a snapshot key pair"
1193        );
1194    }
1195}