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