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, torn down at the switch, only pixels survive
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.
30//!
31//! **Then, in the same rebuild, the outgoing subtree is torn down**
32//! (`View::teardown` through the view that last built it). From that point it
33//! is logically out of the retained tree: every navigator under it reports
34//! `is_mounted() == false` (and so claims no back press), every back
35//! registrant's liveness count drops, and every
36//! [`Component`](frust_core::Component) disposes its reactive owner — on the
37//! very frame the key changes, not when the exit animation ends. What the
38//! switcher keeps is only the torn-down widget tree's **pixels**: the pod is
39//! laid out and painted until progress reaches `1.0`, never rebuilt and never
40//! routed, then dropped on the next rebuild (or when a newer switch supersedes
41//! it, or in the switcher's own teardown) — a plain drop each time, never a
42//! second teardown.
43//!
44//! The teardown has to happen at the freeze because that is the only point at
45//! which both halves are in hand: the old view (`prev.child`, gone after this
46//! rebuild — an [`AnyView`] is not `Clone`, and the widget is not generic over
47//! `State`, so it cannot be stashed until settle) and the old pod. A bare drop
48//! at settle would skip `View::teardown` entirely, so a navigator or a back
49//! fallback under a switched-away section would stay "mounted" forever and
50//! keep swallowing back presses everywhere else in the app.
51//!
52//! Painting after teardown is sound for the widgets a switcher hosts: a
53//! navigator's teardown finalizes any in-flight page transition and tears its
54//! pages down but keeps their pods, which it paints like any static stack; a
55//! [`frust_core::ComponentWidget`]'s paint is a pass-through to its pod that
56//! never touches its (now disposed) owner, and its defensive `Drop` disposal is
57//! idempotent; a plain leaf's teardown is a no-op. The focus contract above has
58//! already cleared the pod's focus link before the teardown runs, so the
59//! teardown's own focus-orphan gate sees no live link and releases nothing a
60//! second time.
61//!
62//! # Compositing: paint-only, route-to-incoming keeps input correct
63//!
64//! A resolved [`PatternLayer`](super::patterns::PatternLayer) is applied as a
65//! pod-origin offset (so a slide's paint and hit-test move together), a
66//! `push_layer` opacity, and a `push_transform` scale about the child's centre.
67//! `push_layer`/`push_transform` are **paint-only** (no hit-test effect); it is
68//! the route-to-incoming-only rule above — not the transforms — that keeps
69//! input correct mid-transition.
70//!
71//! # Timing & reduced motion
72//!
73//! Timing defaults from the theme's [`MotionScheme`](frust_theme::MotionScheme)
74//! (`durations.base` + `easing.effects`), overridable with
75//! [`.timing(...)`](PatternSwitcherView::timing); with no theme threaded it
76//! falls back to [`FALLBACK_DURATION`] + [`Curve::Emphasized`]. A
77//! `reduce_motion` theme collapses *every* pattern to a fast
78//! ([`REDUCE_MOTION_DURATION`]) linear [`FadeThrough`] crossfade — the same hard
79//! accessibility rule `nav::transition::resolve_spec` enforces.
80//!
81//! # Scaffold contract
82//!
83//! This file owns its own contents only — it never edits `motion/mod.rs`'s
84//! module list or re-export block (see that module's docs; the facade
85//! re-exports `motion` wholesale, so these types ride along under
86//! `frust::motion::*`).
87
88use std::time::Duration;
89
90use frust_core::{
91    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Curve, DiscardScene, EditingState,
92    EventCtx, EventResult, ImeState, InputEvent, LayoutCtx, PaintCtx, PaintScene, RetainedKey,
93    SemanticsCtx, View, Widget, any,
94};
95use frust_theme::Theme;
96use kurbo::{Affine, Point, Size, Vec2};
97
98use crate::ChildKey;
99use crate::Timing;
100use crate::motion::patterns::{FadeThrough, PatternLayer, TransitionPattern};
101use crate::nav::transition::{TransitionDriver, make_driver};
102use crate::repaint_boundary::{RetainedBoundary, within};
103
104/// The unthemed fallback transition duration (the Glyph `base` token, 220ms) —
105/// used when no theme is threaded to resolve a `MotionScheme` from (bare-core
106/// tests, pre-theme apps). See the [module docs](self)'s timing section.
107pub const FALLBACK_DURATION: Duration = Duration::from_millis(220);
108
109/// The reduced-motion collapse duration: `reduce_motion` flattens every pattern
110/// to a `≤120ms` linear [`FadeThrough`] crossfade — the Glyph design system's
111/// hard accessibility rule, matching `nav::transition`'s
112/// [`REDUCE_MOTION_DURATION`](crate::nav::transition) collapse.
113pub const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
114
115/// Resolve the effective [`Timing`] for a switch: an explicit builder value
116/// always wins; otherwise the theme's `base` duration + `effects` easing,
117/// falling back to [`FALLBACK_DURATION`] + [`Curve::Emphasized`] when no theme
118/// is threaded.
119fn resolve_switch_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
120    explicit.unwrap_or_else(|| match theme {
121        Some(theme) => Timing::Duration(
122            Duration::from_secs_f64(theme.motion.durations.base / 1000.0),
123            theme.motion.easing.effects,
124        ),
125        None => Timing::Duration(FALLBACK_DURATION, Curve::Emphasized),
126    })
127}
128
129/// Paint one staged child: offset its pod origin by the layer's `dx`/`dy` (so
130/// paint and hit-testing move together), then paint it the way
131/// `nav::navigator`'s `paint_page_layer` paints a transition page.
132///
133/// When `alpha == 0` the child is fully invisible this frame: rather than
134/// rasterize it under a zero-opacity layer, it paints into a [`DiscardScene`]
135/// sink — the pass still has to run for its side effects (animating
136/// descendants advancing), but nothing it records is ever composited. A step
137/// with no scale component (a slide, a fade) paints through the child's
138/// retained-page `boundary` at the slid origin and the layer's alpha: both the
139/// live and the frozen exiting child are usually static during a switch (only
140/// their position/alpha move), so the first frame — progress 0, the exiting
141/// child still at rest — records the page and later frames composite it. The
142/// boundary only records while the child lies wholly inside the switcher's own
143/// rect. A step with a scale component paints inline under a `push_transform`
144/// scale about the child's centre and a `push_layer` when `alpha < 1`, since a
145/// page is only ever served at the scale it was recorded at.
146fn paint_staged_child(
147    pod: &mut ChildPod,
148    boundary: &mut RetainedBoundary,
149    ctx: &mut PaintCtx,
150    scene: &mut dyn PaintScene,
151    layer: PatternLayer,
152) {
153    pod.set_origin(Point::new(layer.dx, layer.dy));
154    let alpha = layer.alpha.clamp(0.0, 1.0);
155
156    if alpha <= 0.0 {
157        boundary.paint_plain(pod, ctx, &mut DiscardScene);
158        return;
159    }
160
161    let origin = ctx.origin();
162    let size = ctx.size();
163    let child_origin = origin + Vec2::new(layer.dx, layer.dy);
164
165    if (layer.scale - 1.0).abs() <= f64::EPSILON {
166        let may_record = within(child_origin, size, origin, size);
167        boundary.paint(pod, ctx, scene, child_origin, size, alpha, may_record);
168        return;
169    }
170
171    let center = Point::new(
172        child_origin.x + size.width / 2.0,
173        child_origin.y + size.height / 2.0,
174    );
175    let layered = alpha < 1.0;
176    scene.push_transform(Affine::scale_about(layer.scale, center));
177    if layered {
178        scene.push_layer(child_origin, size, alpha);
179    }
180    boundary.paint_plain(pod, ctx, scene);
181    if layered {
182        scene.pop_layer();
183    }
184    scene.pop_transform();
185}
186
187/// The cleared/inactive IME surface published after a switch so the platform
188/// keyboard hides deterministically (mirrors `nav::navigator`'s
189/// `cleared_ime_state`).
190fn cleared_ime_state() -> ImeState {
191    ImeState {
192        active: false,
193        editing: EditingState {
194            text: String::new(),
195            selection_base: -1,
196            selection_extent: -1,
197            composing_base: -1,
198            composing_extent: -1,
199        },
200        caret: None,
201        content_type: Default::default(),
202        suppress_soft_keyboard: false,
203    }
204}
205
206/// A keyed single-slot child-switch container. See the [module docs](self).
207pub struct PatternSwitcherView<State: 'static, P: TransitionPattern + Clone + 'static> {
208    key: ChildKey,
209    pattern: P,
210    timing: Option<Timing>,
211    reverse: bool,
212    child: AnyView<State>,
213}
214
215/// Stage a keyed child switch under `pattern`: whenever `key` changes identity,
216/// the previous child animates out and `child` animates in. See the
217/// [module docs](self).
218pub fn pattern_switcher<State, P, V>(
219    key: impl Into<ChildKey>,
220    pattern: P,
221    child: V,
222) -> PatternSwitcherView<State, P>
223where
224    State: 'static,
225    P: TransitionPattern + Clone + 'static,
226    V: View<State>,
227{
228    PatternSwitcherView {
229        key: key.into(),
230        pattern,
231        timing: None,
232        reverse: false,
233        child: any(child),
234    }
235}
236
237/// PascalCase alias for [`pattern_switcher`] (mirrors the `AnimatedOpacity`/
238/// `animated_opacity` dual naming).
239#[allow(non_snake_case)]
240pub fn PatternSwitcher<State, P, V>(
241    key: impl Into<ChildKey>,
242    pattern: P,
243    child: V,
244) -> PatternSwitcherView<State, P>
245where
246    State: 'static,
247    P: TransitionPattern + Clone + 'static,
248    V: View<State>,
249{
250    pattern_switcher(key, pattern, child)
251}
252
253impl<State: 'static, P: TransitionPattern + Clone + 'static> PatternSwitcherView<State, P> {
254    /// Override the default (theme-resolved) transition timing.
255    pub fn timing(mut self, timing: Timing) -> Self {
256        self.timing = Some(timing);
257        self
258    }
259
260    /// Flip the pattern's directional motion (a "back"/reverse switch). Has no
261    /// effect on the non-directional patterns ([`FadeThrough`]/`FadeScale`).
262    pub fn reverse(mut self, reverse: bool) -> Self {
263        self.reverse = reverse;
264        self
265    }
266}
267
268/// The retained widget for a [`PatternSwitcherView`]. Generic only over the
269/// pattern `P` (the frozen exiting child is retained as a bare
270/// [`ChildPod`](frust_core::ChildPod), so no `State` is captured — which is why
271/// it is torn down at the switch; see the [module docs](self)'s frozen-child
272/// section).
273pub struct PatternSwitcherWidget<P: TransitionPattern + Clone + 'static> {
274    key: ChildKey,
275    child: ChildPod,
276    /// The frozen outgoing child: already torn down at the switch, retained
277    /// only to paint its last state until the transition settles, then
278    /// dropped (never torn down a second time).
279    exiting: Option<ChildPod>,
280    pattern: P,
281    explicit_timing: Option<Timing>,
282    reverse: bool,
283    /// The `0→1` progress driver, created lazily on the first paint of a
284    /// transition (a `BuildCtx` has no theme to resolve timing/`reduce_motion`
285    /// from — mirrors `motion::animated`'s deferred retarget).
286    driver: Option<TransitionDriver>,
287    /// A transition was staged by the last rebuild; the next paint resolves
288    /// timing and creates [`Self::driver`].
289    pending_start: bool,
290    /// The driver reached rest this paint; the next rebuild finalizes (drops the
291    /// exiting child).
292    settled: bool,
293    /// The active transition collapsed to a `reduce_motion` crossfade (resolved
294    /// once at start, stable for the whole transition).
295    reduce_motion: bool,
296    /// A cleared IME surface must be published on the next paint (deterministic
297    /// keyboard hide after the switch).
298    needs_ime_clear: bool,
299    /// The retained-page boundary the live child paints through. With
300    /// [`exiting_boundary`](Self::exiting_boundary) it draws on a pair of keys
301    /// reserved once from [`frust_core::RetainedKey::mint_range`] at
302    /// construction and stable for the widget's lifetime. Both children can be
303    /// on screen staged together, so they need distinct keys — one key for both
304    /// would alias their pages. A switch hands the live child's boundary to the
305    /// frozen exiting pod with the pod itself, so a page recorded while the
306    /// child was live still serves it as it leaves.
307    child_boundary: RetainedBoundary,
308    /// The retained-page boundary the frozen exiting child paints through (see
309    /// [`child_boundary`](Self::child_boundary)).
310    exiting_boundary: RetainedBoundary,
311}
312
313impl<State: 'static, P: TransitionPattern + Clone + 'static> View<State>
314    for PatternSwitcherView<State, P>
315{
316    type Element = PatternSwitcherWidget<P>;
317
318    fn build(&self, ctx: &mut BuildCtx<'_>) -> PatternSwitcherWidget<P> {
319        let keys = RetainedKey::mint_range(2);
320        PatternSwitcherWidget {
321            key: self.key,
322            child: crate::authoring::build_child(&self.child, ctx),
323            exiting: None,
324            pattern: self.pattern.clone(),
325            explicit_timing: self.timing,
326            reverse: self.reverse,
327            driver: None,
328            pending_start: false,
329            settled: false,
330            reduce_motion: false,
331            needs_ime_clear: false,
332            child_boundary: RetainedBoundary::with_key(keys),
333            exiting_boundary: RetainedBoundary::with_key(RetainedKey(keys.get() + 1)),
334        }
335    }
336
337    fn rebuild(
338        &self,
339        prev: &Self,
340        element: &mut PatternSwitcherWidget<P>,
341        ctx: &mut BuildCtx<'_>,
342    ) -> ChangeFlags {
343        let mut flags = ChangeFlags::NONE;
344
345        // Pattern/timing/reverse are paint-time inputs; refresh them (a pattern
346        // swap mid-flight is unusual but harmless — the running driver is
347        // untouched). Only flag PAINT when a value actually changed.
348        element.pattern = self.pattern.clone();
349        if element.explicit_timing != self.timing {
350            element.explicit_timing = self.timing;
351            flags |= ChangeFlags::PAINT;
352        }
353        if element.reverse != self.reverse {
354            element.reverse = self.reverse;
355            flags |= ChangeFlags::PAINT;
356        }
357
358        // Finalize a just-settled transition (paint flagged `settled` and
359        // requested this frame): drop the frozen exiting child. It was already
360        // torn down at the switch, so this is a plain drop — never a second
361        // teardown (a `ComponentWidget`'s `Drop` disposal is idempotent).
362        if element.settled {
363            element.exiting = None;
364            element.driver = None;
365            element.settled = false;
366        }
367
368        if self.key != element.key {
369            // --- Identity change: stage a transition. ---
370            // A new switch supersedes any still-running one: drop its exiting
371            // pod, which was torn down when IT was frozen — a plain drop here.
372            element.exiting = None;
373            element.driver = None;
374            element.reduce_motion = false;
375
376            // Freeze the current incoming child as the exiting child; build the
377            // new incoming child fresh in its place.
378            let mut old = std::mem::replace(
379                &mut element.child,
380                crate::authoring::build_child(&self.child, ctx),
381            );
382
383            // Container-suppresses-routing contract (docs/CODE_STANDARDS.md):
384            // synthetic Cancel -> focus clear -> cleared-IME publish, in order.
385            if old.is_active() {
386                crate::authoring::cancel_pod(&mut old);
387                old.set_active(false);
388            }
389            if old.is_focused() {
390                old.set_focused(false);
391                // The cleared-IME publish is the third step of that contract, and
392                // it belongs INSIDE this arm: publishing an inactive surface is a
393                // full focus/IME **session release** at the root, not a value
394                // update (`docs/CORE_ARCHITECTURE.md`'s Focus/IME Lifecycle), and
395                // it bubbles last-write-wins. A switcher whose own child never
396                // held focus has no session to end — raising the flag anyway
397                // would release whatever *unrelated* subtree does own one (a
398                // field painted earlier in the same frame), with no self-heal.
399                //
400                // Gated on the same composition every severing site in the crate
401                // uses (`frust-widgets`' `mark_orphan_if_live`): the outgoing
402                // pod's own link ANDed with the rebuild-pass chain down to this
403                // switcher, so a *stale* flag under an already-blurred ancestor
404                // publishes nothing either. `ctx.has_focus()` is unaffected by the
405                // `build_child` above — that descent restores the chain on the
406                // way out.
407                if ctx.has_focus() {
408                    element.needs_ime_clear = true;
409                }
410            }
411
412            // Tear the outgoing subtree down NOW, through the view that last
413            // built it — the only point where `prev.child` and the old pod are
414            // both in hand (module docs). Navigators under it unmount and stop
415            // claiming back, back registrants drop their liveness, components
416            // dispose their owners. The pod is kept only to paint its pixels
417            // until settle. The capture/focus steps above already ran, so
418            // `teardown_child` finds the pod neither active (no second
419            // `Cancel`) nor focused (its orphan gate sees no live link, and the
420            // descent runs under a cleared chain, so nothing below re-releases
421            // the session the IME clear above already ends).
422            crate::authoring::teardown_child(&prev.child, &mut old, ctx);
423
424            element.exiting = Some(old);
425            // The frozen pod keeps its boundary (and any page it recorded
426            // while live); the fresh child takes the other key, forgetting
427            // whatever that key last recorded for a previous exiting child.
428            std::mem::swap(&mut element.child_boundary, &mut element.exiting_boundary);
429            element.child_boundary = RetainedBoundary::with_key(element.child_boundary.key());
430            element.key = self.key;
431            element.pending_start = true;
432            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
433        } else {
434            // --- Same identity: an ordinary in-place reconcile, no transition. ---
435            flags |=
436                crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
437        }
438
439        flags
440    }
441
442    fn teardown(&self, element: &mut PatternSwitcherWidget<P>, ctx: &mut BuildCtx<'_>) {
443        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
444        // The frozen exiting child (if a transition is still running) was torn
445        // down at the switch that froze it; only its pixels were left, so a
446        // plain drop finishes it — never a second teardown (module docs).
447        element.exiting = None;
448    }
449}
450
451impl<P: TransitionPattern + Clone + 'static> Widget for PatternSwitcherWidget<P> {
452    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
453        let size = self.child.layout_child(ctx, bc);
454        self.child.set_origin(Point::ZERO);
455        // Lay out the frozen exiting child too (under the same constraints) so it
456        // has a valid size to paint at during the transition. Its origin is set
457        // per-frame in `paint` from the pattern layer.
458        if let Some(exiting) = &mut self.exiting {
459            exiting.layout_child(ctx, bc);
460            exiting.set_origin(Point::ZERO);
461        }
462        size
463    }
464
465    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
466        // Lazily create the driver now a theme is in scope (reduce_motion +
467        // timing resolution), mirroring `motion::animated`'s deferred retarget.
468        if self.pending_start {
469            let theme = Theme::from_paint_ctx(ctx);
470            let reduce = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
471            let timing = if reduce {
472                Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear)
473            } else {
474                resolve_switch_timing(theme, self.explicit_timing)
475            };
476            self.driver = Some(make_driver(timing).0);
477            self.reduce_motion = reduce;
478            self.pending_start = false;
479        }
480
481        if let (Some(exiting), Some(driver)) = (self.exiting.as_mut(), self.driver.as_mut()) {
482            let adv = driver.advance(ctx.frame_time());
483            let size = ctx.size();
484            // reduce_motion collapses any pattern to a FadeThrough crossfade.
485            let (incoming_layer, exiting_layer) = if self.reduce_motion {
486                FadeThrough.resolve(adv.value, self.reverse, size)
487            } else {
488                self.pattern.resolve(adv.value, self.reverse, size)
489            };
490            // Paint the exiting child (below) then the incoming child (on top),
491            // each through its own boundary — see `Self::child_boundary`.
492            paint_staged_child(
493                exiting,
494                &mut self.exiting_boundary,
495                ctx,
496                scene,
497                exiting_layer,
498            );
499            paint_staged_child(
500                &mut self.child,
501                &mut self.child_boundary,
502                ctx,
503                scene,
504                incoming_layer,
505            );
506
507            if adv.animating {
508                ctx.request_frame();
509            }
510            if adv.done {
511                // Settle: finalize on the next rebuild; request one more frame so
512                // that rebuild runs (mirrors the navigator's settle contract).
513                self.settled = true;
514                ctx.request_frame();
515            }
516        } else {
517            self.child_boundary.paint_plain(&mut self.child, ctx, scene);
518        }
519
520        // Deterministic IME hide after a switch (navigator contract). Publishing
521        // a cleared surface bubbles up through `ChildPod::paint_child`.
522        if self.needs_ime_clear {
523            ctx.publish_ime_state(cleared_ime_state());
524            self.needs_ime_clear = false;
525        }
526    }
527
528    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
529        // Route only to the incoming child; the frozen exiting child left the
530        // event path at transition start (synthetically cancelled) — module docs.
531        crate::authoring::route_event_single(&mut self.child, ctx, event)
532    }
533
534    fn semantics(&self, ctx: &mut SemanticsCtx) {
535        // Forward the incoming (destination) child; the transient exiting child
536        // is mid-removal and is not reported.
537        self.child.semantics_child(ctx);
538    }
539
540    crate::authoring::visit_children!(exiting, child);
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::motion::patterns::{FadeScale, SharedAxis};
547    use crate::test_support::{RecordingScene, leaf_any};
548    use crate::{FlexView, column};
549    use frust_core::{
550        FrameTime, PointerButton, PointerEvent, PointerPhase, RenderRoot, RetainedLedger,
551    };
552    use kurbo::Rect;
553    use std::cell::Cell;
554    use std::rc::Rc;
555
556    fn ft_ms(ms: f64) -> FrameTime {
557        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
558    }
559
560    fn build<S: 'static, V: View<S>>(view: &V) -> V::Element {
561        let mut counter = 0u64;
562        view.build(&mut BuildCtx::new(&mut counter))
563    }
564
565    fn down(x: f64, y: f64) -> InputEvent {
566        InputEvent::Pointer(PointerEvent {
567            phase: PointerPhase::Down,
568            position: Point::new(x, y),
569            button: PointerButton::Primary,
570        })
571    }
572
573    fn loose() -> BoxConstraints {
574        BoxConstraints::loose(Size::new(100.0, 100.0))
575    }
576
577    /// A recording scene that separates `push_retained`/`pop_retained` calls
578    /// from `push_layer`/`push_transform` ones. `crate::test_support`'s shared
579    /// `RecordingScene` doesn't override `push_retained`, so it would fold a
580    /// retained bracket into the trait's emulating default — a `push_transform`
581    /// and `push_layer` pair — indistinguishable from a plain bracket. This one
582    /// keeps them apart so a test can assert a staged child painted through
583    /// the retained bracket specifically, and through no other bracket.
584    #[derive(Default)]
585    struct RetainedRecordingScene {
586        rects: Vec<(Point, Size)>,
587        layers: Vec<(Point, Size, f32)>,
588        transform_pushes: u32,
589        retained_pushes: Vec<(u64, Point, Size, f32, f64)>,
590        retained_pops: u32,
591        draws: Vec<(u64, Point, Size, f32, f64)>,
592    }
593    impl PaintScene for RetainedRecordingScene {
594        fn fill_rect(&mut self, origin: Point, size: Size, _color: peniko::Color) {
595            self.rects.push((origin, size));
596        }
597        fn draw_text(&mut self, _origin: Point, _text: &str) {}
598        fn push_layer(&mut self, origin: Point, size: Size, alpha: f32) {
599            self.layers.push((origin, size, alpha));
600        }
601        fn push_transform(&mut self, _transform: kurbo::Affine) {
602            self.transform_pushes += 1;
603        }
604        fn push_retained(&mut self, key: u64, origin: Point, size: Size, alpha: f32, scale: f64) {
605            self.retained_pushes.push((key, origin, size, alpha, scale));
606        }
607        fn pop_retained(&mut self) {
608            self.retained_pops += 1;
609        }
610        fn draw_retained(&mut self, key: u64, origin: Point, size: Size, alpha: f32, scale: f64) {
611            self.draws.push((key, origin, size, alpha, scale));
612        }
613    }
614
615    /// An interactive child that captures the pointer + takes focus on `Down`,
616    /// records a synthetic `Cancel` into a shared cell, records its widget's
617    /// drop into `torn`, and its `View::teardown` into `teardowns` — the
618    /// recording interactive child the tests below assert against (mirrors the
619    /// navigator's recording tests). The two counters are kept apart on
620    /// purpose: a frozen exiting child is torn down at the switch but dropped
621    /// only at settle.
622    struct Recorder {
623        cancelled: Rc<Cell<bool>>,
624        torn: Rc<Cell<u32>>,
625        teardowns: Rc<Cell<u32>>,
626    }
627    struct RecorderWidget {
628        cancelled: Rc<Cell<bool>>,
629        torn: Rc<Cell<u32>>,
630    }
631    impl View<()> for Recorder {
632        type Element = RecorderWidget;
633        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RecorderWidget {
634            RecorderWidget {
635                cancelled: self.cancelled.clone(),
636                torn: self.torn.clone(),
637            }
638        }
639        fn rebuild(
640            &self,
641            _prev: &Self,
642            _element: &mut RecorderWidget,
643            _ctx: &mut BuildCtx<'_>,
644        ) -> ChangeFlags {
645            ChangeFlags::NONE
646        }
647        fn teardown(&self, _element: &mut RecorderWidget, _ctx: &mut BuildCtx<'_>) {
648            self.teardowns.set(self.teardowns.get() + 1);
649        }
650    }
651    impl Widget for RecorderWidget {
652        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
653            bc.max()
654        }
655        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
656        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
657            if let InputEvent::Pointer(p) = event {
658                match p.phase {
659                    PointerPhase::Down => {
660                        ctx.capture_pointer();
661                        ctx.request_focus();
662                        return EventResult::Handled;
663                    }
664                    // A Cancel arm only clears internal flags (never app state) —
665                    // recording into an owned `Rc<Cell>` is an internal flag.
666                    PointerPhase::Cancel => {
667                        self.cancelled.set(true);
668                        return EventResult::Handled;
669                    }
670                    _ => {}
671                }
672            }
673            EventResult::Ignored
674        }
675    }
676    impl Drop for RecorderWidget {
677        fn drop(&mut self) {
678            self.torn.set(self.torn.get() + 1);
679        }
680    }
681
682    fn recorder(cancelled: &Rc<Cell<bool>>, torn: &Rc<Cell<u32>>) -> Recorder {
683        Recorder {
684            cancelled: cancelled.clone(),
685            torn: torn.clone(),
686            teardowns: Rc::new(Cell::new(0)),
687        }
688    }
689
690    /// A [`Recorder`] whose `View::teardown` count is observable too.
691    fn counting_recorder(torn: &Rc<Cell<u32>>, teardowns: &Rc<Cell<u32>>) -> Recorder {
692        Recorder {
693            cancelled: Rc::new(Cell::new(false)),
694            torn: torn.clone(),
695            teardowns: teardowns.clone(),
696        }
697    }
698
699    fn reduced_motion_theme() -> Theme {
700        let mut theme = Theme::neutral();
701        theme.motion.reduce_motion = true;
702        theme
703    }
704
705    // --- Identity change transitions; same identity doesn't ---
706
707    #[test]
708    fn same_identity_rebuild_does_not_transition_but_changed_identity_does() {
709        let v1: PatternSwitcherView<(), _> =
710            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
711        let mut w = build(&v1);
712        assert!(w.exiting.is_none());
713
714        // Same key: an ordinary reconcile, no transition staged.
715        let v2: PatternSwitcherView<(), _> =
716            pattern_switcher(1u32, FadeThrough, leaf_any(20.0, 20.0));
717        let mut counter = 0u64;
718        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
719        assert!(
720            w.exiting.is_none(),
721            "same identity must not stage a transition"
722        );
723        assert!(!w.pending_start);
724
725        // Changed key: the old child is frozen as the exiting child.
726        let v3: PatternSwitcherView<(), _> =
727            pattern_switcher(2u32, FadeThrough, leaf_any(30.0, 30.0));
728        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
729        assert!(
730            w.exiting.is_some(),
731            "an identity change must retain the exiting child"
732        );
733        assert!(w.pending_start, "the next paint starts the driver");
734        assert_eq!(w.key, 2u32.into());
735    }
736
737    // --- Exiting child gets Cancel + focus/IME clear ---
738
739    #[test]
740    fn identity_change_cancels_focus_and_clears_ime_on_the_exiting_child() {
741        let cancelled = Rc::new(Cell::new(false));
742        let torn = Rc::new(Cell::new(0u32));
743        let v1: PatternSwitcherView<(), _> =
744            pattern_switcher(1u32, FadeThrough, recorder(&cancelled, &torn));
745        let mut w = build(&v1);
746        let size = w.layout(&mut LayoutCtx::new(), &loose());
747
748        // Arm the child: a Down captures the pointer and takes focus.
749        let mut st = ();
750        let mut ectx = EventCtx::new(&mut st, Point::ZERO, size);
751        w.event(&mut ectx, &down(5.0, 5.0));
752        assert!(w.child.is_active(), "the child captured the pointer");
753        assert!(w.child.is_focused(), "the child took focus");
754
755        // Switch identity: the armed child becomes the exiting child and gets the
756        // capture-cancel -> focus-clear -> IME-clear sequence.
757        let v2: PatternSwitcherView<(), _> =
758            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0));
759        let mut counter = 0u64;
760        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
761
762        assert!(
763            cancelled.get(),
764            "the exiting child received a synthetic Cancel"
765        );
766        let exiting = w.exiting.as_ref().expect("exiting child retained");
767        assert!(
768            !exiting.is_focused(),
769            "the exiting child's focus was cleared"
770        );
771        assert!(!exiting.is_active(), "its capture path was released");
772        assert!(
773            w.needs_ime_clear,
774            "an IME clear is queued for the next paint"
775        );
776
777        // The paint after the switch publishes the cleared IME surface.
778        let mut pctx = PaintCtx::new(Point::ZERO, size);
779        let mut scene = RecordingScene::default();
780        w.paint(&mut pctx, &mut scene);
781        let ime = pctx
782            .take_ime_state()
783            .expect("a cleared IME surface was published on the post-switch paint");
784        assert!(!ime.active);
785        assert!(ime.editing.text.is_empty());
786        assert!(!w.needs_ime_clear, "the queued clear was consumed");
787    }
788
789    // --- The freeze-time teardown releases no session a second time: the
790    //     focus link is cleared before `teardown_child` runs, so its orphan
791    //     gate sees nothing live; the IME clear stays the one release. ---
792
793    #[test]
794    fn freeze_teardown_of_a_focused_child_raises_no_second_focus_release() {
795        let cancelled = Rc::new(Cell::new(false));
796        let torn = Rc::new(Cell::new(0u32));
797        let teardowns = Rc::new(Cell::new(0u32));
798        let v1: PatternSwitcherView<(), _> = pattern_switcher(
799            1u32,
800            FadeThrough,
801            Recorder {
802                cancelled: cancelled.clone(),
803                torn: torn.clone(),
804                teardowns: teardowns.clone(),
805            },
806        );
807        let mut w = build(&v1);
808        let size = w.layout(&mut LayoutCtx::new(), &loose());
809        let mut st = ();
810        let mut ectx = EventCtx::new(&mut st, Point::ZERO, size);
811        w.event(&mut ectx, &down(5.0, 5.0));
812        assert!(w.child.is_active() && w.child.is_focused());
813
814        let _ = frust_core::take_focus_orphaned();
815        let v2: PatternSwitcherView<(), _> =
816            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0));
817        let mut counter = 0u64;
818        // `BuildCtx::new` seeds a live chain: the focused pod WAS on it.
819        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
820
821        assert_eq!(
822            teardowns.get(),
823            1,
824            "the focused child was torn down at the switch"
825        );
826        assert!(cancelled.get(), "cancelled exactly as before");
827        assert!(w.needs_ime_clear, "the IME clear is the session release");
828        assert!(
829            !frust_core::take_focus_orphaned(),
830            "the teardown must not raise a second (orphan) release"
831        );
832    }
833
834    // --- Cross-subtree survival: a switcher whose OWN child never held focus
835    //     must not release somebody else's live session (review-fix-3, FC). ---
836
837    /// The IME surface the sibling field owns for the whole test below.
838    fn field_surface() -> ImeState {
839        ImeState {
840            active: true,
841            editing: EditingState {
842                text: "query".to_string(),
843                selection_base: 5,
844                selection_extent: 5,
845                composing_base: -1,
846                composing_extent: -1,
847            },
848            caret: Some(Rect::new(0.0, 0.0, 1.0, 12.0)),
849            content_type: Default::default(),
850            suppress_soft_keyboard: false,
851        }
852    }
853
854    /// A **persistent** field-shaped leaf: it claims focus and publishes on
855    /// `Down`, and republishes the same surface on every paint while it still
856    /// holds focus — a real `TextInput`'s behavior, and the reason paint order
857    /// (field first, switcher second) decides the last write. Bounded size so it
858    /// can sit as an inflexible child on a `Column`'s unbounded main axis.
859    struct PersistentField {
860        size: Size,
861    }
862    struct PersistentFieldWidget {
863        size: Size,
864    }
865    impl<S: 'static> View<S> for PersistentField {
866        type Element = PersistentFieldWidget;
867        fn build(&self, _ctx: &mut BuildCtx<'_>) -> PersistentFieldWidget {
868            PersistentFieldWidget { size: self.size }
869        }
870        fn rebuild(
871            &self,
872            _prev: &Self,
873            _element: &mut PersistentFieldWidget,
874            _ctx: &mut BuildCtx<'_>,
875        ) -> ChangeFlags {
876            ChangeFlags::NONE
877        }
878    }
879    impl Widget for PersistentFieldWidget {
880        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
881            bc.constrain(self.size)
882        }
883        fn paint(&mut self, ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
884            if ctx.has_focus() {
885                ctx.publish_ime_state(field_surface());
886            }
887        }
888        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
889            if let InputEvent::Pointer(p) = event
890                && p.phase == PointerPhase::Down
891            {
892                ctx.request_focus();
893                ctx.publish_ime_state(field_surface());
894                return EventResult::Handled;
895            }
896            EventResult::Ignored
897        }
898    }
899
900    #[test]
901    fn identity_change_in_an_unfocused_switcher_leaves_a_sibling_fields_session_alive() {
902        // A persistent field as the EARLIER sibling of an unrelated switcher, so
903        // the field paints FIRST and anything the switcher published would win
904        // last-write-wins on the way to the root. Rows are 40 tall: the field
905        // owns `y ∈ [0, 40)`, the switcher `y ∈ [40, 80)`.
906        let key = Rc::new(Cell::new(1u32));
907        let mut app = {
908            let key = key.clone();
909            move |_: &mut ()| {
910                column()
911                    .child(PersistentField {
912                        size: Size::new(100.0, 40.0),
913                    })
914                    .child(pattern_switcher(
915                        key.get(),
916                        FadeThrough,
917                        leaf_any(100.0, 40.0),
918                    ))
919            }
920        };
921        let mut root: RenderRoot<(), FlexView<()>> = RenderRoot::new();
922        let mut state = ();
923        root.rebuild(&mut app, &mut state);
924        root.layout(Size::new(100.0, 100.0));
925        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
926
927        // Tap inside the field's row — never the switcher's, which would blur the
928        // field on the way in (blur-on-outside-tap) and hide the defect.
929        root.event(&mut state, &down(5.0, 5.0));
930        assert!(
931            root.is_focus_active(),
932            "the sibling field owns the focus session"
933        );
934        assert_eq!(
935            root.ime_state().map(|s| s.editing.text),
936            Some("query".to_string()),
937            "…and the shell-facing surface is the field's"
938        );
939        let generation = root.focus_ime_generation();
940
941        // The switcher's identity changes. Its own child never held focus, so
942        // there is no session here to end.
943        key.set(2);
944        root.rebuild(&mut app, &mut state);
945        root.layout(Size::new(100.0, 100.0));
946        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
947
948        assert!(
949            root.is_focus_active(),
950            "an identity change in an unfocused switcher must not release the \
951             sibling field's session"
952        );
953        let ime = root
954            .ime_state()
955            .expect("the identity change dropped the sibling field's IME surface entirely");
956        assert!(ime.active, "the field's surface was left inactive");
957        assert_eq!(
958            ime.editing.text, "query",
959            "the field's surface was replaced with the switcher's cleared one"
960        );
961        assert_eq!(
962            root.focus_ime_generation(),
963            generation,
964            "an identity change in an unfocused switcher is not a focus/IME edge"
965        );
966    }
967
968    // --- Exiting torn down at the switch, its pod retained (painting) until
969    //     progress 1.0, then dropped — never torn down a second time. ---
970
971    #[test]
972    fn exiting_child_is_torn_down_at_the_switch_and_dropped_after_settle() {
973        let torn = Rc::new(Cell::new(0u32));
974        let teardowns = Rc::new(Cell::new(0u32));
975        let short = Timing::Duration(Duration::from_millis(100), Curve::Linear);
976        let v1: PatternSwitcherView<(), _> =
977            pattern_switcher(1u32, FadeThrough, counting_recorder(&torn, &teardowns)).timing(short);
978        let mut w = build(&v1);
979        let size = w.layout(&mut LayoutCtx::new(), &loose());
980
981        // Switch identity -> transition begins. The outgoing subtree is torn
982        // down on this very rebuild; its pod is retained (not dropped).
983        let v2: PatternSwitcherView<(), _> =
984            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
985        let mut counter = 0u64;
986        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
987        assert!(w.exiting.is_some());
988        assert_eq!(teardowns.get(), 1, "torn down at the switch, exactly once");
989        assert_eq!(
990            torn.get(),
991            0,
992            "its pod is not dropped while the transition runs"
993        );
994
995        // First paint creates + seeds the driver (still mid-flight).
996        w.paint(
997            &mut PaintCtx::new(Point::ZERO, size),
998            &mut RecordingScene::default(),
999        );
1000        assert!(w.exiting.is_some(), "retained during the transition");
1001        assert_eq!(torn.get(), 0);
1002        assert!(!w.settled);
1003
1004        // Drive the retained driver to rest directly (PaintCtx::set_frame_time is
1005        // crate-private to frust-core, mirroring the animated.rs tests).
1006        w.driver
1007            .as_mut()
1008            .expect("driver created on first paint")
1009            .advance(ft_ms(10_000.0));
1010
1011        // The next paint observes the settled driver and flags finalize.
1012        w.paint(
1013            &mut PaintCtx::new(Point::ZERO, size),
1014            &mut RecordingScene::default(),
1015        );
1016        assert!(w.settled, "paint flags settle once the driver reaches rest");
1017        assert!(
1018            w.exiting.is_some(),
1019            "still retained until the finalize rebuild"
1020        );
1021        assert_eq!(torn.get(), 0);
1022
1023        // The finalize rebuild (same identity, key 2) drops the exiting pod —
1024        // a plain drop, no second teardown.
1025        let v3: PatternSwitcherView<(), _> =
1026            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
1027        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
1028        assert!(w.exiting.is_none(), "exiting dropped after settle");
1029        assert_eq!(
1030            torn.get(),
1031            1,
1032            "exiting dropped exactly once, after progress 1.0"
1033        );
1034        assert_eq!(
1035            teardowns.get(),
1036            1,
1037            "the settle drop is not a second teardown"
1038        );
1039        assert!(!w.settled);
1040        assert!(w.driver.is_none());
1041
1042        // And the switcher's own teardown touches nothing already finished.
1043        v3.teardown(&mut w, &mut BuildCtx::new(&mut counter));
1044        assert_eq!((teardowns.get(), torn.get()), (1, 1));
1045    }
1046
1047    // --- A navigator under the outgoing child leaves the tree AT the switch:
1048    //     unmounted and claiming no back press on that very rebuild, while its
1049    //     frozen pod keeps painting until settle. ---
1050
1051    #[test]
1052    fn a_navigator_in_the_outgoing_child_unmounts_on_the_switch_but_still_paints() {
1053        use crate::test_support::leaf;
1054        use crate::{NavigatorController, navigator};
1055
1056        let ctrl: NavigatorController<()> = NavigatorController::new();
1057        let short = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1058        let section = |key: u32| -> PatternSwitcherView<(), FadeThrough> {
1059            let child = if key == 1 {
1060                any(navigator(&ctrl, || leaf(10.0, 10.0)))
1061            } else {
1062                leaf_any(10.0, 10.0)
1063            };
1064            pattern_switcher(key, FadeThrough, child).timing(short)
1065        };
1066        let mut counter = 0u64;
1067        let v1 = section(1);
1068        let mut w = build(&v1);
1069        assert!(ctrl.is_mounted());
1070
1071        // Depth 2 through a same-key rebuild: the navigator claims back.
1072        ctrl.push(|| leaf(40.0, 40.0));
1073        let v2 = section(1);
1074        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1075        assert_eq!(ctrl.depth(), 2);
1076        assert!(
1077            ctrl.back_interest(),
1078            "a mounted depth-2 navigator claims back"
1079        );
1080
1081        // Switch the key: the very same rebuild unmounts it and silences it.
1082        let v3 = section(2);
1083        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
1084        assert!(!ctrl.is_mounted(), "torn down at the switch, not at settle");
1085        assert!(
1086            !ctrl.back_interest(),
1087            "a torn-down navigator claims nothing, with no re-wire in between"
1088        );
1089
1090        // ...while its frozen pod keeps painting its top page (40x40) until
1091        // settle. FadeThrough at p=0: the exiting child is fully opaque.
1092        let size = w.layout(&mut LayoutCtx::new(), &loose());
1093        let mut scene = RecordingScene::default();
1094        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
1095        assert!(
1096            scene.rects.iter().any(|(_, s)| *s == Size::new(40.0, 40.0)),
1097            "the torn-down navigator's top page still paints: {:?}",
1098            scene.rects
1099        );
1100
1101        // Settle and finalize: dropped, and still unmounted (no double count).
1102        w.driver
1103            .as_mut()
1104            .expect("driver created on first paint")
1105            .advance(ft_ms(10_000.0));
1106        w.paint(
1107            &mut PaintCtx::new(Point::ZERO, size),
1108            &mut RecordingScene::default(),
1109        );
1110        assert!(w.settled);
1111        let v4 = section(2);
1112        v4.rebuild(&v3, &mut w, &mut BuildCtx::new(&mut counter));
1113        assert!(w.exiting.is_none());
1114        assert!(!ctrl.is_mounted());
1115        assert!(!ctrl.back_interest());
1116    }
1117
1118    // --- A Component in the outgoing child is torn down (its owner disposed)
1119    //     exactly once across the freeze, the settle drop and the switcher's
1120    //     own teardown. Its child view's `View::teardown` only runs from the
1121    //     component's teardown (never from its `Drop`), so its count is the
1122    //     component's teardown count; `State` drops with the widget. ---
1123
1124    struct ProbeComp {
1125        teardowns: Rc<Cell<u32>>,
1126        state_drops: Rc<Cell<u32>>,
1127    }
1128    struct ProbeState {
1129        drops: Rc<Cell<u32>>,
1130    }
1131    impl Drop for ProbeState {
1132        fn drop(&mut self) {
1133            self.drops.set(self.drops.get() + 1);
1134        }
1135    }
1136    impl frust_core::Component for ProbeComp {
1137        type State = ProbeState;
1138        fn init(&self) -> ProbeState {
1139            ProbeState {
1140                drops: self.state_drops.clone(),
1141            }
1142        }
1143        fn build(&self, _state: &mut ProbeState) -> impl View<ProbeState> {
1144            TeardownLeaf {
1145                teardowns: self.teardowns.clone(),
1146            }
1147        }
1148    }
1149    /// A leaf over any state that counts its `View::teardown`s.
1150    struct TeardownLeaf {
1151        teardowns: Rc<Cell<u32>>,
1152    }
1153    impl<S: 'static> View<S> for TeardownLeaf {
1154        type Element = crate::test_support::LeafWidget;
1155        fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
1156            View::<()>::build(&crate::test_support::leaf(30.0, 30.0), ctx)
1157        }
1158        fn rebuild(
1159            &self,
1160            _prev: &Self,
1161            _element: &mut Self::Element,
1162            _ctx: &mut BuildCtx<'_>,
1163        ) -> ChangeFlags {
1164            ChangeFlags::NONE
1165        }
1166        fn teardown(&self, _element: &mut Self::Element, _ctx: &mut BuildCtx<'_>) {
1167            self.teardowns.set(self.teardowns.get() + 1);
1168        }
1169    }
1170
1171    #[test]
1172    fn a_component_in_the_outgoing_child_is_torn_down_exactly_once() {
1173        let teardowns = Rc::new(Cell::new(0u32));
1174        let state_drops = Rc::new(Cell::new(0u32));
1175        let short = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1176        let comp = || {
1177            any(frust_core::component(ProbeComp {
1178                teardowns: teardowns.clone(),
1179                state_drops: state_drops.clone(),
1180            }))
1181        };
1182        let mut counter = 0u64;
1183
1184        // (1) Freeze -> settle drop -> switcher teardown.
1185        let v1: PatternSwitcherView<(), _> =
1186            pattern_switcher(1u32, FadeThrough, comp()).timing(short);
1187        let mut w = build(&v1);
1188        let size = w.layout(&mut LayoutCtx::new(), &loose());
1189        let v2: PatternSwitcherView<(), _> =
1190            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
1191        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1192        assert_eq!(
1193            teardowns.get(),
1194            1,
1195            "the component is torn down at the freeze"
1196        );
1197        assert_eq!(
1198            state_drops.get(),
1199            0,
1200            "its widget (and state) is still painting"
1201        );
1202
1203        // A torn-down component still paints its pod (pass-through).
1204        let mut scene = RecordingScene::default();
1205        w.layout(&mut LayoutCtx::new(), &loose());
1206        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
1207        assert!(
1208            scene.rects.iter().any(|(_, s)| *s == Size::new(30.0, 30.0)),
1209            "the disposed component's subtree still paints: {:?}",
1210            scene.rects
1211        );
1212
1213        w.driver
1214            .as_mut()
1215            .expect("driver created on first paint")
1216            .advance(ft_ms(10_000.0));
1217        w.paint(
1218            &mut PaintCtx::new(Point::ZERO, size),
1219            &mut RecordingScene::default(),
1220        );
1221        let v3: PatternSwitcherView<(), _> =
1222            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
1223        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
1224        assert!(w.exiting.is_none());
1225        assert_eq!(state_drops.get(), 1, "dropped once at settle");
1226        assert_eq!(
1227            teardowns.get(),
1228            1,
1229            "the settle drop is not a second teardown"
1230        );
1231        v3.teardown(&mut w, &mut BuildCtx::new(&mut counter));
1232        assert_eq!((teardowns.get(), state_drops.get()), (1, 1));
1233
1234        // (2) Freeze -> switcher teardown mid-transition (the exiting pod is
1235        //     still retained when the switcher itself leaves the tree).
1236        teardowns.set(0);
1237        state_drops.set(0);
1238        let v1: PatternSwitcherView<(), _> =
1239            pattern_switcher(1u32, FadeThrough, comp()).timing(short);
1240        let mut w = build(&v1);
1241        let v2: PatternSwitcherView<(), _> =
1242            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0)).timing(short);
1243        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1244        assert_eq!((teardowns.get(), state_drops.get()), (1, 0));
1245        v2.teardown(&mut w, &mut BuildCtx::new(&mut counter));
1246        assert!(
1247            w.exiting.is_none(),
1248            "the switcher's teardown drops the exiting pod"
1249        );
1250        assert_eq!(
1251            (teardowns.get(), state_drops.get()),
1252            (1, 1),
1253            "torn down once at the freeze, dropped once by the switcher's teardown"
1254        );
1255    }
1256
1257    // --- Supersede mid-transition (A -> B -> C before settle): each outgoing
1258    //     child is torn down exactly once, at its own freeze. ---
1259
1260    #[test]
1261    fn superseding_switches_tear_each_outgoing_child_down_exactly_once() {
1262        let counters = || (Rc::new(Cell::new(0u32)), Rc::new(Cell::new(0u32)));
1263        let (a_drop, a_td) = counters();
1264        let (b_drop, b_td) = counters();
1265        let (c_drop, c_td) = counters();
1266        let mut counter = 0u64;
1267
1268        let va: PatternSwitcherView<(), _> =
1269            pattern_switcher(1u32, FadeThrough, counting_recorder(&a_drop, &a_td));
1270        let mut w = build(&va);
1271        let vb: PatternSwitcherView<(), _> =
1272            pattern_switcher(2u32, FadeThrough, counting_recorder(&b_drop, &b_td));
1273        vb.rebuild(&va, &mut w, &mut BuildCtx::new(&mut counter));
1274        assert_eq!(
1275            (a_td.get(), a_drop.get()),
1276            (1, 0),
1277            "A frozen: torn down, kept"
1278        );
1279        assert_eq!((b_td.get(), b_drop.get()), (0, 0), "B is live");
1280
1281        // C supersedes before A's exit settles: A's pod is dropped (no second
1282        // teardown), B is frozen and torn down.
1283        let vc: PatternSwitcherView<(), _> =
1284            pattern_switcher(3u32, FadeThrough, counting_recorder(&c_drop, &c_td));
1285        vc.rebuild(&vb, &mut w, &mut BuildCtx::new(&mut counter));
1286        assert_eq!(
1287            (a_td.get(), a_drop.get()),
1288            (1, 1),
1289            "A superseded: dropped only"
1290        );
1291        assert_eq!(
1292            (b_td.get(), b_drop.get()),
1293            (1, 0),
1294            "B frozen: torn down, kept"
1295        );
1296        assert_eq!((c_td.get(), c_drop.get()), (0, 0), "C is live");
1297
1298        // The switcher's own teardown: C (live) is torn down through its view,
1299        // B (frozen) is only dropped.
1300        vc.teardown(&mut w, &mut BuildCtx::new(&mut counter));
1301        assert_eq!((a_td.get(), a_drop.get()), (1, 1));
1302        assert_eq!((b_td.get(), b_drop.get()), (1, 1), "B dropped, not re-torn");
1303        assert_eq!(
1304            c_td.get(),
1305            1,
1306            "the live child is torn down by the switcher's teardown"
1307        );
1308        drop(w);
1309        assert_eq!(
1310            (a_td.get(), b_td.get(), c_td.get()),
1311            (1, 1, 1),
1312            "each child torn down exactly once"
1313        );
1314        assert_eq!((a_drop.get(), b_drop.get(), c_drop.get()), (1, 1, 1));
1315    }
1316
1317    // --- Same-key rebuilds reconcile in place: no transition, no teardown. ---
1318
1319    #[test]
1320    fn same_key_rebuilds_reconcile_in_place_with_no_teardown() {
1321        let torn = Rc::new(Cell::new(0u32));
1322        let teardowns = Rc::new(Cell::new(0u32));
1323        let mut counter = 0u64;
1324        let make = || -> PatternSwitcherView<(), FadeThrough> {
1325            pattern_switcher(7u32, FadeThrough, counting_recorder(&torn, &teardowns))
1326        };
1327        let mut prev = make();
1328        let mut w = build(&prev);
1329        for _ in 0..3 {
1330            let next = make();
1331            next.rebuild(&prev, &mut w, &mut BuildCtx::new(&mut counter));
1332            prev = next;
1333        }
1334        assert!(w.exiting.is_none(), "no transition staged");
1335        assert_eq!(
1336            (teardowns.get(), torn.get()),
1337            (0, 0),
1338            "the live child was reconciled in place: never torn down, never dropped"
1339        );
1340    }
1341
1342    // --- reduce_motion collapses to a fast crossfade ---
1343
1344    #[test]
1345    fn reduce_motion_collapses_to_fast_crossfade() {
1346        let theme = reduced_motion_theme();
1347        // Configure a *directional* pattern (SharedAxis::X slides the incoming
1348        // child by 30dp) so the collapse to a non-directional crossfade is
1349        // observable in the child's pod origin.
1350        let v1: PatternSwitcherView<(), _> =
1351            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0));
1352        let mut w = build(&v1);
1353        let size = w.layout(&mut LayoutCtx::new(), &loose());
1354
1355        let v2: PatternSwitcherView<(), _> =
1356            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0));
1357        let mut counter = 0u64;
1358        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1359
1360        let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
1361        w.paint(&mut pctx, &mut RecordingScene::default());
1362
1363        assert!(
1364            w.reduce_motion,
1365            "reduce_motion must be resolved from the threaded theme"
1366        );
1367        // FadeThrough collapse: the incoming child never slides (dx stays 0),
1368        // unlike the configured SharedAxis::X (which offsets it by 30dp at p=0).
1369        assert_eq!(
1370            w.child.origin(),
1371            Point::ZERO,
1372            "reduce_motion collapses to a non-directional crossfade (no slide)"
1373        );
1374    }
1375
1376    #[test]
1377    fn without_reduce_motion_the_configured_directional_pattern_slides() {
1378        // The contrast case for the reduce_motion test above: an untheme'd (no
1379        // reduce_motion) SharedAxis::X switch DOES offset the incoming child.
1380        let v1: PatternSwitcherView<(), _> =
1381            pattern_switcher(1u32, SharedAxis::X, leaf_any(10.0, 10.0));
1382        let mut w = build(&v1);
1383        let size = w.layout(&mut LayoutCtx::new(), &loose());
1384
1385        let v2: PatternSwitcherView<(), _> =
1386            pattern_switcher(2u32, SharedAxis::X, leaf_any(10.0, 10.0));
1387        let mut counter = 0u64;
1388        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1389
1390        w.paint(
1391            &mut PaintCtx::new(Point::ZERO, size),
1392            &mut RecordingScene::default(),
1393        );
1394        assert!(!w.reduce_motion);
1395        // At the seed frame (p=0) SharedAxis::X offsets the incoming child by the
1396        // full 30dp slide.
1397        assert_eq!(w.child.origin().x, 30.0);
1398    }
1399
1400    // --- No-transition steady state paints the child once, plainly ---
1401
1402    #[test]
1403    fn steady_state_paints_the_single_child_with_no_layer_or_transform() {
1404        let v: PatternSwitcherView<(), _> =
1405            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1406        let mut w = build(&v);
1407        let size = w.layout(&mut LayoutCtx::new(), &loose());
1408
1409        let mut scene = RecordingScene::default();
1410        w.paint(&mut PaintCtx::new(Point::ZERO, size), &mut scene);
1411        assert_eq!(scene.rects.len(), 1, "the single child paints its fill");
1412        assert!(
1413            scene.layers.is_empty(),
1414            "no crossfade layer at steady state"
1415        );
1416        assert!(
1417            scene.transforms.is_empty(),
1418            "no scale transform at steady state"
1419        );
1420    }
1421
1422    // --- Discard: a staged child at alpha 0 paints into a DiscardScene sink,
1423    //     recording no ops and no zero-alpha layer (mirrors nav::navigator's
1424    //     paint_page_layer discard branch). ---
1425
1426    #[test]
1427    fn alpha_zero_staged_child_records_no_ops_and_no_zero_alpha_layer() {
1428        let leaf = leaf_any(10.0, 10.0);
1429        let mut counter = 0u64;
1430        let mut pod = crate::authoring::build_child(&leaf, &mut BuildCtx::new(&mut counter));
1431        let size = pod.layout_child(&mut LayoutCtx::new(), &loose());
1432        pod.set_origin(Point::ZERO);
1433
1434        let mut scene = RecordingScene::default();
1435        let layer = PatternLayer {
1436            dx: 0.0,
1437            dy: 0.0,
1438            alpha: 0.0,
1439            scale: 1.0,
1440        };
1441        paint_staged_child(
1442            &mut pod,
1443            &mut RetainedBoundary::new(),
1444            &mut PaintCtx::new(Point::ZERO, size),
1445            &mut scene,
1446            layer,
1447        );
1448
1449        assert!(scene.rects.is_empty(), "an alpha-0 child records no fill");
1450        assert!(
1451            scene.layers.is_empty(),
1452            "no zero-alpha push_layer is recorded either"
1453        );
1454    }
1455
1456    // --- Retained boundaries: a staged child at a slide/fade step paints
1457    //     through its own boundary — a recording bracket on the seed frame
1458    //     (the exiting child still at rest), the retained page once it is
1459    //     resident — and a scale step paints inline. Mirrors nav::navigator's
1460    //     paint_page_layer. ---
1461
1462    /// A ledger reporting `keys` resident as 10x10 pages at scale 1 — the
1463    /// extent and scale a `leaf_any(10.0, 10.0)` child's boundary asks for
1464    /// under a recorder with no transform.
1465    fn resident(keys: &[RetainedKey]) -> RetainedLedger {
1466        RetainedLedger::from_entries(1, keys.iter().map(|k| (k.get(), (10, 10), 1.0)))
1467    }
1468
1469    /// Switch `v1`'s widget to a second key with the same pattern, returning
1470    /// the widget, its size, and the key the child held before the switch.
1471    fn switched<P: TransitionPattern + Clone + 'static>(
1472        pattern: P,
1473    ) -> (PatternSwitcherWidget<P>, Size, RetainedKey) {
1474        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
1475        let v1: PatternSwitcherView<(), _> =
1476            pattern_switcher(1u32, pattern.clone(), leaf_any(10.0, 10.0)).timing(timing);
1477        let mut w = build(&v1);
1478        let size = w.layout(&mut LayoutCtx::new(), &loose());
1479        let live_key = w.child_boundary.key();
1480        let v2: PatternSwitcherView<(), _> =
1481            pattern_switcher(2u32, pattern, leaf_any(10.0, 10.0)).timing(timing);
1482        let mut counter = 0u64;
1483        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1484        w.layout(&mut LayoutCtx::new(), &loose());
1485        (w, size, live_key)
1486    }
1487
1488    #[test]
1489    fn the_seed_frame_records_the_exiting_child_under_the_key_it_held_while_live() {
1490        let (mut w, size, live_key) = switched(SharedAxis::X);
1491        assert_eq!(
1492            w.exiting_boundary.key(),
1493            live_key,
1494            "the frozen pod keeps the boundary it painted through while live"
1495        );
1496        assert_ne!(
1497            w.child_boundary.key(),
1498            live_key,
1499            "the fresh child takes the other key"
1500        );
1501
1502        let ledger = RetainedLedger::empty();
1503        let mut scene = RetainedRecordingScene::default();
1504        w.paint(
1505            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)).with_retained_ledger(&ledger),
1506            &mut scene,
1507        );
1508        // p = 0: the exiting child sits at rest (dx 0, alpha 1); the incoming
1509        // one is at alpha 0 (discarded).
1510        assert_eq!(
1511            scene.retained_pushes,
1512            vec![(live_key.get(), Point::ZERO, size, 1.0, 1.0)],
1513            "one recording bracket, keyed by the exiting child's own key, at scale 1"
1514        );
1515        assert_eq!(scene.retained_pops, 1, "the bracket is balanced");
1516        assert!(scene.draws.is_empty(), "nothing is resident yet");
1517    }
1518
1519    #[test]
1520    fn a_clean_resident_exiting_child_composites_its_page_as_it_fades() {
1521        let (mut w, size, live_key) = switched(FadeScale);
1522        let empty = RetainedLedger::empty();
1523        w.paint(
1524            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)).with_retained_ledger(&empty),
1525            &mut RetainedRecordingScene::default(),
1526        );
1527
1528        // 15ms into the 100ms linear duration: progress 0.15, inside
1529        // FadeScale's simultaneous-fade window — both children visible.
1530        let ledger = resident(&[live_key]);
1531        let mut scene = RetainedRecordingScene::default();
1532        w.paint(
1533            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(15.0)).with_retained_ledger(&ledger),
1534            &mut scene,
1535        );
1536        assert_eq!(
1537            scene.draws.len(),
1538            1,
1539            "the exiting page is drawn: {:?}",
1540            scene.draws
1541        );
1542        let (key, origin, draw_size, alpha, scale) = scene.draws[0];
1543        assert_eq!(
1544            (key, origin, draw_size, scale),
1545            (live_key.get(), Point::ZERO, size, 1.0)
1546        );
1547        assert!(
1548            alpha > 0.0 && alpha < 1.0,
1549            "at the fade's own alpha (was {alpha})"
1550        );
1551        assert_eq!(
1552            scene.rects.len(),
1553            1,
1554            "only the incoming child paints a body; the exiting one is composited"
1555        );
1556        assert!(
1557            scene.retained_pushes.is_empty(),
1558            "the incoming child's scale step is never bracketed: {:?}",
1559            scene.retained_pushes
1560        );
1561        assert_eq!(
1562            scene.transform_pushes, 1,
1563            "the incoming child's scale step paints inline under a push_transform"
1564        );
1565    }
1566
1567    #[test]
1568    fn without_a_ledger_staged_children_paint_with_no_bracket() {
1569        let (mut w, size, _) = switched(FadeScale);
1570        w.paint(
1571            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)),
1572            &mut RetainedRecordingScene::default(),
1573        );
1574        let mut scene = RetainedRecordingScene::default();
1575        w.paint(
1576            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(15.0)),
1577            &mut scene,
1578        );
1579        assert!(scene.retained_pushes.is_empty() && scene.draws.is_empty());
1580        assert_eq!(scene.rects.len(), 2, "both children paint their bodies");
1581        assert_eq!(scene.layers.len(), 2, "each under its own fade layer");
1582    }
1583
1584    #[test]
1585    fn a_slid_child_is_never_recorded_while_it_overhangs_the_switcher() {
1586        // SharedAxis::X slides children by 30dp. Past the split (p = 0.5) the
1587        // incoming child sits at dx = 15: partly outside the switcher's rect,
1588        // where a renderer would refuse to retain it, so it paints directly.
1589        let (mut w, size, _) = switched(SharedAxis::X);
1590        let ledger = RetainedLedger::empty();
1591        w.paint(
1592            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(0.0)).with_retained_ledger(&ledger),
1593            &mut RetainedRecordingScene::default(),
1594        );
1595        let mut scene = RetainedRecordingScene::default();
1596        w.paint(
1597            &mut PaintCtx::for_test(Point::ZERO, size, ft_ms(50.0)).with_retained_ledger(&ledger),
1598            &mut scene,
1599        );
1600        assert!(
1601            scene.retained_pushes.is_empty(),
1602            "no bracket: {:?}",
1603            scene.retained_pushes
1604        );
1605        assert_eq!(
1606            scene.rects,
1607            vec![(Point::new(15.0, 0.0), size)],
1608            "the incoming child paints at its slid origin"
1609        );
1610    }
1611
1612    // --- The key pair is reserved once, at construction, and outlives every
1613    //     switch this widget instance stages. ---
1614
1615    fn key_pair<P: TransitionPattern + Clone + 'static>(w: &PatternSwitcherWidget<P>) -> [u64; 2] {
1616        let mut keys = [w.child_boundary.key().get(), w.exiting_boundary.key().get()];
1617        keys.sort_unstable();
1618        keys
1619    }
1620
1621    #[test]
1622    fn the_key_pair_is_stable_across_multiple_switches_on_the_same_widget() {
1623        let v1: PatternSwitcherView<(), _> =
1624            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1625        let mut w = build(&v1);
1626        let pair = key_pair(&w);
1627        assert_eq!(pair[1], pair[0] + 1, "a reserved consecutive pair");
1628        let mut counter = 0u64;
1629
1630        let v2: PatternSwitcherView<(), _> =
1631            pattern_switcher(2u32, FadeThrough, leaf_any(10.0, 10.0));
1632        v2.rebuild(&v1, &mut w, &mut BuildCtx::new(&mut counter));
1633        assert_eq!(
1634            key_pair(&w),
1635            pair,
1636            "a switch does not reassign the key pair"
1637        );
1638
1639        let v3: PatternSwitcherView<(), _> =
1640            pattern_switcher(3u32, FadeThrough, leaf_any(10.0, 10.0));
1641        v3.rebuild(&v2, &mut w, &mut BuildCtx::new(&mut counter));
1642        assert_eq!(
1643            key_pair(&w),
1644            pair,
1645            "a second switch on the same widget still keeps the same key pair"
1646        );
1647    }
1648
1649    // --- Two switcher instances never alias each other's cache slot. ---
1650
1651    #[test]
1652    fn two_switcher_instances_get_disjoint_key_pairs() {
1653        let v: PatternSwitcherView<(), _> =
1654            pattern_switcher(1u32, FadeThrough, leaf_any(10.0, 10.0));
1655        let w1 = build(&v);
1656        let w2 = build(&v);
1657        let (a, b) = (key_pair(&w1), key_pair(&w2));
1658        assert!(
1659            a.iter().all(|k| !b.contains(k)),
1660            "two switcher instances never share a key: {a:?} vs {b:?}"
1661        );
1662    }
1663}