Skip to main content

teksilo_widgets/animations/
crossfade.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Crossfade` — when an external `Signal<K>` changes, the
5//! previous content fades out while the new content fades in over
6//! the same window. Like [`Switcher`](crate::primitives::Switcher),
7//! but animated.
8//!
9//! ```ignore
10//! let tab = Signal::new(Tab::Overview);
11//! ctx.add(
12//!     Crossfade::new(tab.clone(), |t| match t {
13//!         Tab::Overview => Box::new(overview_panel()),
14//!         Tab::Details  => Box::new(details_panel()),
15//!     }),
16//! );
17//! ```
18//!
19//! ## Behavior
20//!
21//! On each `key` change, both the previous-key widget and the
22//! current-key widget are rebuilt (via the supplied builder) and
23//! mounted side-by-side in a `ZStack`. The previous fades 1→0 while
24//! the current fades 0→1 over the configured duration. On the *next*
25//! key change, the previously-outgoing widget is torn down and the
26//! cycle repeats.
27//!
28//! Builders should be cheap — they may run more than once per
29//! lifetime as the user navigates through several keys. For data-
30//! heavy panels, hoist expensive state out of the builder closure.
31//!
32//! ## Reduced motion
33//!
34//! Honours `prefers-reduced-motion`: snaps the opacity changes
35//! instead of tweening (instant swap).
36
37use std::time::Duration;
38
39use teksilo_canvas::{Rect, SizeProposal};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::binding::BindingLevel;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::signal::Signal;
44use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
45use teksilo_core::widget_id::WidgetId;
46
47use crate::primitives::ZStack;
48
49/// Animated swap between widgets keyed by an external signal.
50pub struct Crossfade<K: Eq + Clone + 'static> {
51    key_signal: Signal<K>,
52    builder: Box<dyn Fn(&K) -> Box<dyn Widget>>,
53    duration: Option<Duration>,
54    last_key: Option<K>,
55    root_child_id: Option<WidgetId>,
56}
57
58impl<K: Eq + Clone + 'static> Crossfade<K> {
59    /// New `Crossfade` driven by `key_signal`. The `builder` closure
60    /// constructs the widget for a given key value. Builders can be
61    /// invoked multiple times across the widget's lifetime as the
62    /// user transitions through keys.
63    pub fn new(key_signal: Signal<K>, builder: impl Fn(&K) -> Box<dyn Widget> + 'static) -> Self {
64        Self {
65            key_signal,
66            builder: Box::new(builder),
67            duration: None,
68            last_key: None,
69            root_child_id: None,
70        }
71    }
72
73    /// Override the crossfade duration. Default: `MotionTokens::duration_normal`.
74    pub fn duration(mut self, duration: Duration) -> Self {
75        self.duration = Some(duration);
76        self
77    }
78}
79
80impl<K: Eq + Clone + 'static> std::fmt::Debug for Crossfade<K> {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("Crossfade")
83            .field("duration", &self.duration)
84            .finish()
85    }
86}
87
88impl<K: Eq + Clone + 'static> Widget for Crossfade<K> {
89    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
90        let current_key = self.key_signal.get();
91        let prev_key = self.last_key.take();
92        let key_changed = prev_key.as_ref().is_some_and(|p| p != &current_key);
93
94        let duration = self.duration.unwrap_or(ctx.theme().motion.duration_normal);
95        let easing = ctx.theme().motion.easing_standard;
96        let reduced = ctx.prefers_reduced_motion();
97
98        let mut zstack = ZStack::new();
99
100        if key_changed {
101            let prev_key = prev_key.expect("key_changed implies prev_key is Some");
102            let outgoing = (self.builder)(&prev_key);
103            let outgoing_id = ctx.add_boxed(outgoing);
104            let opacity = ctx.animated_signal(1.0);
105            ctx.set_opacity(outgoing_id, opacity.clone());
106            // Bind the outgoing's *visibility* to its own opacity so
107            // it goes dormant once the fade reaches ~zero. Without
108            // this the outgoing stays mounted in the ZStack at full
109            // natural size — the wrapper's reported size becomes
110            // `max(outgoing, incoming)` until the next key change,
111            // and any layout-driving ancestor (SmoothSize, …) never
112            // observes the shrink.
113            ctx.visible_when(outgoing_id, opacity.map(|&o| o > 0.005));
114            if reduced {
115                opacity.set(0.0);
116            } else {
117                opacity.animate_to(0.0, duration, easing);
118            }
119            zstack = zstack.add_child(outgoing_id);
120        }
121
122        let incoming = (self.builder)(&current_key);
123        let incoming_id = ctx.add_boxed(incoming);
124        let initial = if key_changed { 0.0 } else { 1.0 };
125        let opacity = ctx.animated_signal(initial);
126        ctx.set_opacity(incoming_id, opacity.clone());
127        if key_changed {
128            if reduced {
129                opacity.set(1.0);
130            } else {
131                opacity.animate_to(1.0, duration, easing);
132            }
133        }
134        zstack = zstack.add_child(incoming_id);
135
136        // Trigger a full rebuild on key change so the next transition
137        // can mount fresh outgoing+incoming pair.
138        let self_id = ctx.self_id();
139        let registry = ctx.binding_registry();
140        self.key_signal
141            .bind_to(self_id, registry, BindingLevel::Rebuild);
142
143        self.last_key = Some(current_key);
144        let root = ctx.add(zstack);
145        self.root_child_id = Some(root);
146        vec![root]
147    }
148
149    fn layout_response(
150        &self,
151        proposal: SizeProposal,
152        ctx: &LayoutContext,
153    ) -> teksilo_core::widget::LayoutResponse {
154        self.root_child_id
155            .and_then(|id| ctx.child_size(id, proposal))
156            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
157            .into()
158    }
159
160    fn place_children(
161        &self,
162        bounds: Rect,
163        _proposal: SizeProposal,
164        children: &mut [WidgetPlacement],
165        _ctx: &LayoutContext,
166    ) {
167        for child in children.iter_mut() {
168            child.origin = bounds.origin();
169            child.size = bounds.size();
170        }
171    }
172
173    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
174        // Animation wrapper. The active subtree owns its own a11y.
175    }
176
177    fn children(&self) -> Vec<WidgetId> {
178        self.root_child_id.into_iter().collect()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::primitives::TextWidget;
186    use teksilo_canvas::Size;
187    use teksilo_core::widget_tree::WidgetTree;
188    use teksilo_i18n::lit;
189
190    fn count_set_opacity(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
191        frame
192            .draw_order
193            .iter()
194            .filter_map(|c| match c {
195                teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
196                _ => None,
197            })
198            .collect()
199    }
200
201    #[test]
202    fn first_build_shows_initial_key_at_full_opacity() {
203        let key = Signal::new(0_u32);
204        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
205        tree.add(Crossfade::new(key, |k| {
206            Box::new(TextWidget::new(lit!(format!("page {k}"))))
207        }));
208        tree.layout(SizeProposal {
209            width: Some(200.0),
210            height: None,
211        });
212        let frame = tree.render();
213        let ops = count_set_opacity(&frame);
214        // Single visible child at opacity 1.0 — exactly one
215        // SetOpacity scope around it.
216        assert_eq!(ops.len(), 1);
217        assert!((ops[0] - 1.0).abs() < 1e-6);
218    }
219
220    #[test]
221    fn key_change_starts_overlap_with_two_opacity_scopes() {
222        let key = Signal::new(0_u32);
223        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
224        tree.add(Crossfade::new(key.clone(), |k| {
225            Box::new(TextWidget::new(lit!(format!("page {k}"))))
226        }));
227        tree.layout(SizeProposal {
228            width: Some(200.0),
229            height: None,
230        });
231
232        key.set(1);
233        tree.layout(SizeProposal {
234            width: Some(200.0),
235            height: None,
236        });
237        // Mid-tween: tick a bit so animations have started but not
238        // completed. Two SetOpacity scopes (outgoing + incoming).
239        tree.tick_animations(Duration::from_millis(50));
240        tree.layout(SizeProposal {
241            width: Some(200.0),
242            height: None,
243        });
244        let frame = tree.render();
245        let ops = count_set_opacity(&frame);
246        assert_eq!(
247            ops.len(),
248            2,
249            "during transition, outgoing and incoming should both have opacity scopes"
250        );
251        // One opacity should be > 0.5 (incoming approaching 1) or
252        // < 0.5 (outgoing approaching 0). Just sanity-check both are
253        // strictly between 0 and 1.
254        for o in &ops {
255            assert!(*o >= 0.0 && *o <= 1.0, "opacity must be in [0, 1], got {o}");
256        }
257    }
258
259    #[test]
260    fn outgoing_goes_dormant_after_fade_so_layout_can_shrink() {
261        // Regression: when transitioning from a tall content key to a
262        // short content key, Crossfade used to keep the tall outgoing
263        // mounted at opacity=0, so the wrapper's reported size stayed
264        // at max(tall, short) = tall forever. A SmoothSize ancestor
265        // would never observe the shrink. Bind the outgoing's
266        // visibility to its opacity so it goes dormant once faded.
267        use crate::primitives::FixedSize;
268
269        #[derive(Debug)]
270        struct Sized(f32);
271        impl Widget for Sized {
272            fn layout_response(
273                &self,
274                _p: SizeProposal,
275                _c: &LayoutContext,
276            ) -> teksilo_core::widget::LayoutResponse {
277                Size::new(40.0, self.0).into()
278            }
279        }
280
281        let key = Signal::new(0_u32);
282        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
283        // Wrap so we can read the wrapper's bounds (which equal the
284        // Crossfade's reported size).
285        let id = tree.add(FixedSize::new().child(Crossfade::new(
286            key.clone(),
287            |&k| -> Box<dyn Widget> {
288                let h = if k == 0 { 100.0 } else { 30.0 };
289                Box::new(Sized(h))
290            },
291        )));
292        tree.layout(SizeProposal {
293            width: None,
294            height: None,
295        });
296        let initial = tree.bounds(id);
297        assert!((initial.height - 100.0).abs() < 0.5);
298
299        // Transition tall → short. After the fade duration plus a
300        // layout pass to drain pending animations, the outgoing
301        // (tall) widget must be dormant so the wrapper shrinks.
302        key.set(1);
303        // Two layouts to drain the queued animate_to onto the
304        // scheduler, then tick well past the fade duration.
305        tree.layout(SizeProposal {
306            width: None,
307            height: None,
308        });
309        tree.layout(SizeProposal {
310            width: None,
311            height: None,
312        });
313        tree.tick_animations(Duration::from_millis(400));
314        tree.layout(SizeProposal {
315            width: None,
316            height: None,
317        });
318        let after = tree.bounds(id);
319        assert!(
320            (after.height - 30.0).abs() < 1.0,
321            "after fade-out, wrapper should shrink to incoming's natural height; got {}",
322            after.height
323        );
324    }
325
326    #[test]
327    fn reduced_motion_snaps_instantly() {
328        let key = Signal::new(0_u32);
329        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
330        tree.set_accessibility_preferences(false, true, 1.0);
331        tree.add(Crossfade::new(key.clone(), |k| {
332            Box::new(TextWidget::new(lit!(format!("page {k}"))))
333        }));
334        tree.layout(SizeProposal {
335            width: Some(200.0),
336            height: None,
337        });
338
339        key.set(1);
340        tree.layout(SizeProposal {
341            width: Some(200.0),
342            height: None,
343        });
344        assert!(
345            !tree.has_active_animations(),
346            "reduced-motion path must not register animations"
347        );
348    }
349}