teksilo_widgets/animations/
crossfade.rs1use 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
49pub 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 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 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 != ¤t_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 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)(¤t_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 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 }
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 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 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 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 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 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 key.set(1);
303 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}