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}