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::{FlexView, column};
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, 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()
767                    .child(PersistentField {
768                        size: Size::new(100.0, 40.0),
769                    })
770                    .child(pattern_switcher(
771                        key.get(),
772                        FadeThrough,
773                        leaf_any(100.0, 40.0),
774                    ))
775            }
776        };
777        let mut root: RenderRoot<(), FlexView<()>> = RenderRoot::new();
778        let mut state = ();
779        root.rebuild(&mut app, &mut state);
780        root.layout(Size::new(100.0, 100.0));
781        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
782
783        // Tap inside the field's row — never the switcher's, which would blur the
784        // field on the way in (blur-on-outside-tap) and hide the defect.
785        root.event(&mut state, &down(5.0, 5.0));
786        assert!(
787            root.is_focus_active(),
788            "the sibling field owns the focus session"
789        );
790        assert_eq!(
791            root.ime_state().map(|s| s.editing.text),
792            Some("query".to_string()),
793            "…and the shell-facing surface is the field's"
794        );
795        let generation = root.focus_ime_generation();
796
797        // The switcher's identity changes. Its own child never held focus, so
798        // there is no session here to end.
799        key.set(2);
800        root.rebuild(&mut app, &mut state);
801        root.layout(Size::new(100.0, 100.0));
802        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
803
804        assert!(
805            root.is_focus_active(),
806            "an identity change in an unfocused switcher must not release the \
807             sibling field's session"
808        );
809        let ime = root
810            .ime_state()
811            .expect("the identity change dropped the sibling field's IME surface entirely");
812        assert!(ime.active, "the field's surface was left inactive");
813        assert_eq!(
814            ime.editing.text, "query",
815            "the field's surface was replaced with the switcher's cleared one"
816        );
817        assert_eq!(
818            root.focus_ime_generation(),
819            generation,
820            "an identity change in an unfocused switcher is not a focus/IME edge"
821        );
822    }
823
824    // --- Exiting retained until progress 1.0, then torn down ---
825
826    #[test]
827    fn exiting_child_is_retained_until_settle_then_torn_down() {
828        let cancelled = Rc::new(Cell::new(false));
829        let torn = Rc::new(Cell::new(0u32));
830        let short = Timing::Duration(Duration::from_millis(100), Curve::Linear);
831        let v1: PatternSwitcherView<(), _> =
832            pattern_switcher(1u32, FadeThrough, recorder(&cancelled, &torn)).timing(short);
833        let mut w = build(&v1);
834        let size = w.layout(&mut LayoutCtx::new(), &loose());
835
836        // Switch identity -> transition begins; exiting retained, not torn down.
837        let v2: PatternSwitcherView<(), _> =
838            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
839        let mut counter = 0u64;
840        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
841        assert!(w.exiting.is_some());
842        assert_eq!(
843            torn.get(),
844            0,
845            "exiting not torn down while the transition runs"
846        );
847
848        // First paint creates + seeds the driver (still mid-flight).
849        w.paint(
850            &mut PaintCtx::new(Point::ZERO, size),
851            &mut RecordingScene::default(),
852        );
853        assert!(w.exiting.is_some(), "retained during the transition");
854        assert_eq!(torn.get(), 0);
855        assert!(!w.settled);
856
857        // Drive the retained driver to rest directly (PaintCtx::set_frame_time is
858        // crate-private to frust-core, mirroring the animated.rs tests).
859        w.driver
860            .as_mut()
861            .expect("driver created on first paint")
862            .advance(ft_ms(10_000.0));
863
864        // The next paint observes the settled driver and flags finalize.
865        w.paint(
866            &mut PaintCtx::new(Point::ZERO, size),
867            &mut RecordingScene::default(),
868        );
869        assert!(w.settled, "paint flags settle once the driver reaches rest");
870        assert!(
871            w.exiting.is_some(),
872            "still retained until the finalize rebuild"
873        );
874        assert_eq!(torn.get(), 0);
875
876        // The finalize rebuild (same identity, key 2) tears the exiting child down.
877        let v3: PatternSwitcherView<(), _> =
878            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
879        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
880        assert!(w.exiting.is_none(), "exiting dropped after settle");
881        assert_eq!(
882            torn.get(),
883            1,
884            "exiting torn down exactly once, after progress 1.0"
885        );
886        assert!(!w.settled);
887        assert!(w.driver.is_none());
888    }
889
890    // --- reduce_motion collapses to a fast crossfade ---
891
892    #[test]
893    fn reduce_motion_collapses_to_fast_crossfade() {
894        let theme = reduced_motion_theme();
895        // Configure a *directional* pattern (SharedAxis::X slides the incoming
896        // child by 30dp) so the collapse to a non-directional crossfade is
897        // observable in the child's pod origin.
898        let v1: PatternSwitcherView<(), _> =
899            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0));
900        let mut w = build(&v1);
901        let size = w.layout(&mut LayoutCtx::new(), &loose());
902
903        let v2: PatternSwitcherView<(), _> =
904            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0));
905        let mut counter = 0u64;
906        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
907
908        let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
909        w.paint(&mut pctx, &mut RecordingScene::default());
910
911        assert!(
912            w.reduce_motion,
913            "reduce_motion must be resolved from the threaded theme"
914        );
915        // FadeThrough collapse: the incoming child never slides (dx stays 0),
916        // unlike the configured SharedAxis::X (which offsets it by 30dp at p=0).
917        assert_eq!(
918            w.child.origin(),
919            Point::ZERO,
920            "reduce_motion collapses to a non-directional crossfade (no slide)"
921        );
922    }
923
924    #[test]
925    fn without_reduce_motion_the_configured_directional_pattern_slides() {
926        // The contrast case for the reduce_motion test above: an untheme'd (no
927        // reduce_motion) SharedAxis::X switch DOES offset the incoming child.
928        let v1: PatternSwitcherView<(), _> =
929            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0));
930        let mut w = build(&v1);
931        let size = w.layout(&mut LayoutCtx::new(), &loose());
932
933        let v2: PatternSwitcherView<(), _> =
934            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0));
935        let mut counter = 0u64;
936        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
937
938        w.paint(
939            &mut PaintCtx::new(Point::ZERO, size),
940            &mut RecordingScene::default(),
941        );
942        assert!(!w.reduce_motion);
943        // At the seed frame (p=0) SharedAxis::X offsets the incoming child by the
944        // full 30dp slide.
945        assert_eq!(w.child.origin().x, 30.0);
946    }
947
948    // --- No-transition steady state paints the child once, plainly ---
949
950    #[test]
951    fn steady_state_paints_the_single_child_with_no_layer_or_transform() {
952        let v: PatternSwitcherView<(), _> =
953            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
954        let mut w = build(&v);
955        let size = w.layout(&mut LayoutCtx::new(), &loose());
956
957        let mut scene = RecordingScene::default();
958        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
959        assert_eq!(scene.rects.len(), 1, "the single child paints its fill");
960        assert!(
961            scene.layers.is_empty(),
962            "no crossfade layer at steady state"
963        );
964        assert!(
965            scene.transforms.is_empty(),
966            "no scale transform at steady state"
967        );
968    }
969
970    // --- Discard: a staged child at alpha 0 paints into a DiscardScene sink,
971    //     recording no ops and no zero-alpha layer (mirrors nav::navigator's
972    //     paint_page_layer discard branch). ---
973
974    #[test]
975    fn alpha_zero_staged_child_records_no_ops_and_no_zero_alpha_layer() {
976        let leaf = leaf_any(10.0, 10.0);
977        let mut counter = 0u64;
978        let mut pod = crate::authoring::build_child(&leaf, &mut BuildCtx::new(&mut counter));
979        let size = pod.layout_child(&mut LayoutCtx::new(), &loose());
980        pod.set_origin(Point::ZERO);
981
982        let mut scene = RecordingScene::default();
983        let layer = PatternLayer {
984            dx: 0.0,
985            dy: 0.0,
986            alpha: 0.0,
987            scale: 1.0,
988        };
989        paint_staged_child(
990            &mut pod,
991            &mut PaintCtx::new(Point::ZERO, size),
992            &mut scene,
993            layer,
994            1,
995        );
996
997        assert!(scene.rects.is_empty(), "an alpha-0 child records no fill");
998        assert!(
999            scene.layers.is_empty(),
1000            "no zero-alpha push_layer is recorded either"
1001        );
1002    }
1003
1004    // --- Snapshot bracket: a staged child paints through push_snapshot/
1005    //     pop_snapshot keyed by snapshot_base (incoming) / snapshot_base + 1
1006    //     (exiting), never a push_layer/push_transform bracket of its own —
1007    //     mirrors nav::navigator's paint_page_layer snapshot bracket. ---
1008
1009    #[test]
1010    fn staged_children_paint_through_a_snapshot_bracket_keyed_by_snapshot_base() {
1011        // FadeScale (unlike FadeThrough/SharedAxis's hard split) fades both
1012        // children simultaneously over its first 30%, so a mid-window frame
1013        // has both the incoming and the frozen exiting child at alpha > 0 at
1014        // once — the case that proves each gets its OWN key.
1015        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1016        let v1: PatternSwitcherView<(), _> =
1017            pattern_switcher(1u32, FadeScale, leaf_any(10.0, 10.0)).timing(timing);
1018        let mut w = build(&v1);
1019        let size = w.layout(&mut LayoutCtx::new(), &loose());
1020
1021        let v2: PatternSwitcherView<(), _> =
1022            pattern_switcher(2u32, FadeScale, leaf_any(10.0, 10.0)).timing(timing);
1023        let mut counter = 0u64;
1024        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1025
1026        // Seed frame: the first `advance` only seeds the clock (progress 0).
1027        w.paint(
1028            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1029            &mut SnapshotRecordingScene::default(),
1030        );
1031
1032        // 15ms into the 100ms linear duration: progress 0.15, inside
1033        // FadeScale's [0, 0.30] simultaneous-fade window.
1034        let base = w.snapshot_base;
1035        let mut scene = SnapshotRecordingScene::default();
1036        w.paint(
1037            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(15.0)),
1038            &mut scene,
1039        );
1040
1041        assert_eq!(
1042            scene.snapshots.len(),
1043            2,
1044            "both the incoming and frozen exiting child paint through a \
1045             snapshot bracket: {:?}",
1046            scene.snapshots
1047        );
1048        assert_eq!(scene.snapshot_pops, 2, "each bracket pops exactly once");
1049        assert!(
1050            scene.layers.is_empty(),
1051            "no push_layer bracket of its own: {:?}",
1052            scene.layers
1053        );
1054        assert_eq!(
1055            scene.transform_pushes, 0,
1056            "no push_transform bracket of its own"
1057        );
1058
1059        let keys: Vec<u64> = scene.snapshots.iter().map(|(k, ..)| *k).collect();
1060        assert!(
1061            keys.contains(&base),
1062            "the incoming child is keyed by snapshot_base: {keys:?}"
1063        );
1064        assert!(
1065            keys.contains(&(base + 1)),
1066            "the frozen exiting child is keyed by snapshot_base + 1: {keys:?}"
1067        );
1068        for (_, _, _, alpha, _) in &scene.snapshots {
1069            assert!(*alpha > 0.0, "an alpha-0 child never reaches the bracket");
1070        }
1071    }
1072
1073    #[test]
1074    fn bracket_origin_follows_the_pod_dx_in_slide_patterns() {
1075        // SharedAxis::X slides children by 30dp (incoming from +30, exiting to -30).
1076        // The bracket origin must follow the pod's absolute paint origin —
1077        // ctx.origin() + (dx, dy) — so the cached texture is slide-invariant and
1078        // the body lands inside the texture without cropping. This test verifies
1079        // the bracket origin includes the dx offset during a slide transition.
1080        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1081        let v1: PatternSwitcherView<(), _> =
1082            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0)).timing(timing);
1083        let mut w = build(&v1);
1084        let size = w.layout(&mut LayoutCtx::new(), &loose());
1085
1086        let v2: PatternSwitcherView<(), _> =
1087            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0)).timing(timing);
1088        let mut counter = 0u64;
1089        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1090
1091        // Seed frame: the first `advance` only seeds the clock (progress 0).
1092        w.paint(
1093            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1094            &mut SnapshotRecordingScene::default(),
1095        );
1096
1097        // At p=0 (seed frame), SharedAxis::X has:
1098        // - Exiting child: dx=0.0, alpha=1.0 (visible)
1099        // - Incoming child: dx=30.0, alpha=0.0 (discarded, not visible)
1100        // So only the exiting child paints through a snapshot bracket.
1101        let mut scene = SnapshotRecordingScene::default();
1102        w.paint(
1103            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1104            &mut scene,
1105        );
1106
1107        assert_eq!(
1108            scene.snapshots.len(),
1109            1,
1110            "only the exiting child is visible at p=0"
1111        );
1112
1113        // The exiting child has dx=0.0 at p=0, so its bracket origin should be
1114        // at ctx.origin() = (0.0, 0.0).
1115        let (_, origin, _, _, _) = scene.snapshots[0];
1116        assert_eq!(
1117            origin,
1118            Point::new(0.0, 0.0),
1119            "exiting child bracket origin at x=0.0 when dx=0 (was {:?})",
1120            origin
1121        );
1122
1123        // At a mid-transition frame like p=0.5 (50ms into 100ms duration),
1124        // SharedAxis::X has:
1125        // - Exiting child: dx=-15.0 (sliding out), alpha < 1.0
1126        // - Incoming child: dx=15.0 (sliding in), alpha > 0.0
1127        // Both are potentially visible and should have bracket origins that
1128        // include their respective dx values. However, SharedAxis uses a hard
1129        // split, so only one is above alpha 0 at any instant. At p=0.5 (past
1130        // the ~0.35 split), the incoming child is visible.
1131        let mut scene_mid = SnapshotRecordingScene::default();
1132        w.paint(
1133            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(50.0)),
1134            &mut scene_mid,
1135        );
1136
1137        // At p=0.5, the incoming child has dx = (1.0 - 0.5) * 30 = 15.0 and
1138        // is visible (past the split). Its bracket origin should reflect this.
1139        assert_eq!(
1140            scene_mid.snapshots.len(),
1141            1,
1142            "at mid-frame (p=0.5), only one child is visible through a snapshot"
1143        );
1144        let (_, origin_mid, _, _, _) = scene_mid.snapshots[0];
1145        assert_eq!(
1146            origin_mid,
1147            Point::new(15.0, 0.0),
1148            "incoming child bracket origin at x=15.0 when dx=15.0 (was {:?})",
1149            origin_mid
1150        );
1151    }
1152
1153    // --- snapshot_base is reserved once, at construction, and outlives every
1154    //     switch this widget instance stages. ---
1155
1156    #[test]
1157    fn snapshot_base_is_stable_across_multiple_switches_on_the_same_widget() {
1158        let v1: PatternSwitcherView<(), _> =
1159            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1160        let mut w = build(&v1);
1161        let base = w.snapshot_base;
1162        let mut counter = 0u64;
1163
1164        let v2: PatternSwitcherView<(), _> =
1165            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0));
1166        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1167        assert_eq!(
1168            w.snapshot_base, base,
1169            "a switch does not reassign the key pair"
1170        );
1171
1172        let v3: PatternSwitcherView<(), _> =
1173            pattern_switcher(3u32, FadeThrough, leaf_any(10.0, 10.0));
1174        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
1175        assert_eq!(
1176            w.snapshot_base, base,
1177            "a second switch on the same widget still keeps the same key pair"
1178        );
1179    }
1180
1181    // --- Two switcher instances never alias each other's cache slot. ---
1182
1183    #[test]
1184    fn two_switcher_instances_get_disjoint_snapshot_key_pairs() {
1185        let v: PatternSwitcherView<(), _> =
1186            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1187        let w1 = build(&v);
1188        let w2 = build(&v);
1189        assert_ne!(
1190            w1.snapshot_base, w2.snapshot_base,
1191            "two switcher instances never share a snapshot key pair"
1192        );
1193    }
1194}