Skip to main content

rosace_widgets/tree/
screen_transition_view.rs

1use std::sync::{Arc, Mutex};
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_nav::ScreenTransition;
5use rosace_render::DrawCommand;
6use super::hero::{self, HeroRole};
7use super::{BoxedWidget, LayoutCtx, PaintCtx, Widget, avail_h, avail_w, intersect_rect};
8
9/// Paints the current screen, and — while a `ScreenNav`-driven transition
10/// is in progress — the previous screen too, each offset by the shared
11/// `ScreenTransition`'s spring-eased enter/exit values (D108/Phase 26 Step
12/// 3). Not generic over the app's route enum: it only needs already-built
13/// widgets plus opaque `u64` identity keys (`ScreenNav::current_key`/
14/// `previous_key`/`stack_keys`) and the transition handle
15/// `ScreenNav::transition_handle()` returns — the same way `ScrollView`
16/// needs only a `ScrollController`, not the app's own types.
17///
18/// # Per-screen persistence (2026-08-01)
19/// Each screen's subtree is addressed by its `incoming_key`/`outgoing_key`
20/// through `PaintCtx::child_keyed`, not positionally — see
21/// `render_tree.rs`'s module doc ("Identity" section) for the full story.
22/// Without this, two different screens landing at the same tree position
23/// (which they always do here — every screen paints as "the incoming
24/// child") would alias scroll offset, animation state, and everything else
25/// sticky onto whatever screen last occupied that position: navigating to a
26/// new screen could inherit a stale, out-of-bounds scroll offset (visibly
27/// springing back to valid bounds on arrival), and navigating back would
28/// find its OWN position reset instead of where it was left. With keys,
29/// each screen gets its own permanent slot, reused (state intact, exactly
30/// where it was left) whenever that screen becomes current again, and
31/// released via `valid_keys`/`prune_keyed_children` only once it's actually
32/// popped off the nav stack — mirroring Flutter's `Navigator`, which keeps
33/// every pushed route's Element tree alive in its `Overlay` until popped,
34/// not just the current one.
35///
36/// `rsc new`'s generated `app.rs` uses this in place of handing the
37/// current screen's widget straight to `Scaffold::new(...)`.
38pub struct ScreenTransitionView {
39    incoming: BoxedWidget,
40    incoming_key: u64,
41    outgoing: Option<BoxedWidget>,
42    outgoing_key: Option<u64>,
43    transition: Arc<Mutex<ScreenTransition>>,
44    /// Every route currently on the nav stack (`ScreenNav::stack_keys()`) —
45    /// anything cached under a key NOT in this list gets released this
46    /// frame (see `RenderTree::prune_keyed_children`).
47    valid_keys: Vec<u64>,
48}
49
50impl ScreenTransitionView {
51    pub fn new(
52        incoming: impl Widget + 'static,
53        incoming_key: u64,
54        outgoing: Option<BoxedWidget>,
55        outgoing_key: Option<u64>,
56        transition: Arc<Mutex<ScreenTransition>>,
57        valid_keys: Vec<u64>,
58    ) -> Self {
59        Self {
60            incoming: Box::new(incoming),
61            incoming_key,
62            outgoing,
63            outgoing_key,
64            transition,
65            valid_keys,
66        }
67    }
68}
69
70impl Widget for ScreenTransitionView {
71    fn layout(&self, ctx: &LayoutCtx) -> rosace_core::types::Size {
72        let constraints = ctx.constraints;
73        rosace_core::types::Size { width: avail_w(constraints), height: avail_h(constraints) }
74    }
75
76    fn paint(&self, ctx: &mut PaintCtx) {
77        let vp = ctx.rect;
78        let dt = rosace_animate::frame_dt().max(0.0001);
79
80        // Release any screen no longer on the nav stack — see this struct's
81        // own doc comment and `RenderTree::prune_keyed_children`.
82        ctx.tree.borrow_mut().prune_keyed_children(ctx.node, &self.valid_keys);
83
84        let (ex, ey, ox, oy, progress, is_complete) = {
85            let mut t = self.transition.lock().unwrap_or_else(|e| e.into_inner());
86            t.set_viewport(vp.size.width, vp.size.height);
87            t.update(dt)
88        };
89
90        let animating = !is_complete && ctx.theme.animation.enabled;
91
92        if animating {
93            // Clip both layers to the viewport — an in-flight slide must
94            // not paint outside its own screen's bounds, same reasoning as
95            // ScrollView::paint_base's clip around its child.
96            ctx.record(DrawCommand::PushClip { rect: vp });
97            let effective_clip = ctx.clip_rect.and_then(|parent| intersect_rect(parent, vp)).unwrap_or(vp);
98
99            // D108/Phase 26 Step 5: any `Hero`-tagged widget painted while a
100            // role is active captures itself instead of painting in place —
101            // see `hero.rs`. Both sides always get marked (even when there's
102            // no `outgoing` widget yet, e.g. the very first screen) so a
103            // stale role never leaks into an unrelated later paint pass.
104            if let Some(outgoing) = &self.outgoing {
105                hero::set_active_role(Some(HeroRole::Outgoing));
106                let rect = Rect { origin: Point { x: vp.origin.x + ox, y: vp.origin.y + oy }, size: vp.size };
107                let mut child_ctx = match self.outgoing_key {
108                    Some(key) => ctx.child_keyed(rect, key),
109                    None => ctx.child(rect),
110                };
111                child_ctx.clip_rect = Some(effective_clip);
112                outgoing.paint(&mut child_ctx);
113            }
114
115            hero::set_active_role(Some(HeroRole::Incoming));
116            let rect = Rect { origin: Point { x: vp.origin.x + ex, y: vp.origin.y + ey }, size: vp.size };
117            let mut child_ctx = ctx.child_keyed(rect, self.incoming_key);
118            child_ctx.clip_rect = Some(effective_clip);
119            self.incoming.paint(&mut child_ctx);
120            hero::set_active_role(None);
121
122            // Paint each matched Hero pair once, on top of both screens,
123            // at a rect LERP'd between its outgoing and incoming captures
124            // by the transition's progress — the floating "flight" element.
125            let t = progress.clamp(0.0, 1.0);
126            for (_tag, out_rect, _out_pic, in_rect, in_pic) in hero::drain_pairs() {
127                let interp = lerp_rect(out_rect, in_rect, t);
128                ctx.replay_morphed(&in_pic, in_rect, interp);
129            }
130
131            ctx.record(DrawCommand::PopClip);
132            ctx.request_animation();
133        } else {
134            // Steady state — paint only the incoming screen at zero offset,
135            // identical output to handing it straight to Scaffold::new(...).
136            // No active role: `Hero`-tagged widgets are plain pass-throughs.
137            self.incoming.paint(&mut ctx.child_keyed(vp, self.incoming_key));
138        }
139    }
140}
141
142/// Linear interpolation between two rects' position AND size at `t` (0..1).
143fn lerp_rect(a: Rect, b: Rect, t: f32) -> Rect {
144    Rect {
145        origin: Point {
146            x: a.origin.x + (b.origin.x - a.origin.x) * t,
147            y: a.origin.y + (b.origin.y - a.origin.y) * t,
148        },
149        size: Size {
150            width: a.size.width + (b.size.width - a.size.width) * t,
151            height: a.size.height + (b.size.height - a.size.height) * t,
152        },
153    }
154}