teksilo_widgets/animations/
smooth_size.rs1use std::cell::Cell;
41use std::time::Duration;
42
43use teksilo_canvas::{Point, Rect, Size, SizeProposal};
44use teksilo_core::accessibility::AccessNodeBuilder;
45use teksilo_core::binding::BindingLevel;
46use teksilo_core::build_context::BuildContext;
47use teksilo_core::signal::Signal;
48use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
49use teksilo_core::widget_id::WidgetId;
50use teksilo_tokens::Easing;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SmoothSizeAxes {
57 Width,
59 Height,
61 Both,
63}
64
65const SIZE_CHANGE_EPSILON: f32 = 0.5;
66
67pub struct SmoothSize {
70 axes: SmoothSizeAxes,
71 duration: Option<Duration>,
72 easing: Option<Easing>,
73 pending_child: Option<PendingChild>,
74 child_id: Option<WidgetId>,
75 width_anim: Option<Signal<f32>>,
78 height_anim: Option<Signal<f32>>,
80 last_target: Cell<Size>,
84 natural_size: Cell<Size>,
88 reduced_motion: bool,
91 needs_initial_snap: Cell<bool>,
96}
97
98impl SmoothSize {
99 pub fn new() -> Self {
101 Self {
102 axes: SmoothSizeAxes::Both,
103 duration: None,
104 easing: None,
105 pending_child: None,
106 child_id: None,
107 width_anim: None,
108 height_anim: None,
109 last_target: Cell::new(Size::ZERO),
110 natural_size: Cell::new(Size::ZERO),
111 reduced_motion: false,
112 needs_initial_snap: Cell::new(true),
113 }
114 }
115
116 pub fn axes(mut self, axes: SmoothSizeAxes) -> Self {
119 self.axes = axes;
120 self
121 }
122
123 pub fn duration(mut self, duration: Duration) -> Self {
125 self.duration = Some(duration);
126 self
127 }
128
129 pub fn easing(mut self, easing: Easing) -> Self {
131 self.easing = Some(easing);
132 self
133 }
134
135 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
137 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
138 self
139 }
140 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
147 match widget {
148 Some(w) => self.child(w),
149 None => self,
150 }
151 }
152
153 fn animates_width(&self) -> bool {
154 matches!(self.axes, SmoothSizeAxes::Width | SmoothSizeAxes::Both)
155 }
156
157 fn animates_height(&self) -> bool {
158 matches!(self.axes, SmoothSizeAxes::Height | SmoothSizeAxes::Both)
159 }
160}
161
162impl Default for SmoothSize {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168impl std::fmt::Debug for SmoothSize {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.debug_struct("SmoothSize")
171 .field("axes", &self.axes)
172 .field("duration", &self.duration)
173 .finish()
174 }
175}
176
177impl Widget for SmoothSize {
178 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
179 if let Some(pending) = self.pending_child.take() {
180 self.child_id = Some(match pending {
181 PendingChild::Id(id) => id,
182 PendingChild::Deferred(w) => ctx.add_boxed(w),
183 });
184 }
185 let Some(child_id) = self.child_id else {
186 return vec![];
187 };
188
189 let w_sig = ctx.animated_signal(0.0);
193 let h_sig = ctx.animated_signal(0.0);
194
195 let id = ctx.self_id();
198 let registry = ctx.binding_registry();
199 w_sig.bind_to(id, registry, BindingLevel::Relayout);
200 h_sig.bind_to(id, registry, BindingLevel::Relayout);
201
202 self.width_anim = Some(w_sig);
203 self.height_anim = Some(h_sig);
204 self.reduced_motion = ctx.prefers_reduced_motion();
205
206 vec![child_id]
207 }
208
209 fn layout_response(
210 &self,
211 proposal: SizeProposal,
212 ctx: &LayoutContext,
213 ) -> teksilo_core::widget::LayoutResponse {
214 let Some(child_id) = self.child_id else {
215 return (proposal.resolve(0.0, 0.0)).into();
216 };
217 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
218 self.natural_size.set(natural);
219
220 let (Some(w_sig), Some(h_sig)) = (self.width_anim.as_ref(), self.height_anim.as_ref())
221 else {
222 return (natural).into();
224 };
225
226 let last = self.last_target.get();
227 let width_target_changed = (natural.width - last.width).abs() > SIZE_CHANGE_EPSILON;
228 let height_target_changed = (natural.height - last.height).abs() > SIZE_CHANGE_EPSILON;
229
230 if width_target_changed || height_target_changed {
231 self.last_target.set(natural);
232 let snap = self.reduced_motion || self.needs_initial_snap.get();
236 self.needs_initial_snap.set(false);
237 if snap {
238 w_sig.set(natural.width);
239 h_sig.set(natural.height);
240 } else {
241 let duration = self.duration.unwrap_or(ctx.theme.motion.duration_normal);
242 let easing = self.easing.unwrap_or(ctx.theme.motion.easing_standard);
243 if self.animates_width() && width_target_changed {
244 w_sig.animate_to(natural.width, duration, easing);
245 } else if !self.animates_width() {
246 w_sig.set(natural.width);
247 }
248 if self.animates_height() && height_target_changed {
249 h_sig.animate_to(natural.height, duration, easing);
250 } else if !self.animates_height() {
251 h_sig.set(natural.height);
252 }
253 }
254 }
255
256 Size::new(w_sig.get().max(0.0), h_sig.get().max(0.0)).into()
257 }
258
259 fn place_children(
260 &self,
261 bounds: Rect,
262 _proposal: SizeProposal,
263 children: &mut [WidgetPlacement],
264 _ctx: &LayoutContext,
265 ) {
266 let natural = self.natural_size.get();
270 for child in children.iter_mut() {
271 child.origin = Point::new(bounds.x, bounds.y);
272 child.size = natural;
273 }
274 }
275
276 fn clips_children(&self) -> bool {
277 true
280 }
281
282 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
283 }
285
286 fn children(&self) -> Vec<WidgetId> {
287 self.child_id.into_iter().collect()
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::primitives::{FixedSize, TextWidget};
295 use teksilo_core::widget_tree::WidgetTree;
296 use teksilo_i18n::lit;
297
298 #[test]
299 fn first_measurement_snaps_to_natural_no_grow_in_animation() {
300 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
305 let id = tree.add(
306 SmoothSize::new()
307 .duration(Duration::from_millis(500))
308 .child(FixedSize::new().width(180.0).height(70.0)),
309 );
310 tree.layout(SizeProposal {
313 width: None,
314 height: None,
315 });
316 let b = tree.bounds(id);
317 assert!(
318 (b.width - 180.0).abs() < 0.5 && (b.height - 70.0).abs() < 0.5,
319 "first-frame size must equal natural; got ({}, {})",
320 b.width,
321 b.height
322 );
323 assert!(
324 !tree.has_active_animations(),
325 "first-frame snap must not register an animation"
326 );
327 }
328
329 #[test]
330 fn subsequent_change_animates() {
331 let width_signal = Signal::new(100.0_f32);
335 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
336 let id = tree.add(
337 SmoothSize::new()
338 .duration(Duration::from_millis(200))
339 .child(FixedSize::new().width(width_signal.clone()).height(50.0)),
340 );
341 tree.layout(SizeProposal {
343 width: None,
344 height: None,
345 });
346 let initial = tree.bounds(id);
347 assert!((initial.width - 100.0).abs() < 0.5);
348
349 width_signal.set(250.0);
351 tree.layout(SizeProposal {
354 width: None,
355 height: None,
356 });
357 tree.layout(SizeProposal {
361 width: None,
362 height: None,
363 });
364 let mid = tree.bounds(id);
365 assert!(
366 tree.has_active_animations(),
367 "size change must kick off a tween (got bounds {:?})",
368 mid
369 );
370 assert!(
371 mid.width < 240.0,
372 "mid-tween width should still be near the start, got {}",
373 mid.width
374 );
375
376 tree.tick_animations(Duration::from_millis(250));
378 tree.layout(SizeProposal {
379 width: None,
380 height: None,
381 });
382 let final_b = tree.bounds(id);
383 assert!(
384 (final_b.width - 250.0).abs() < 1.0,
385 "after tween, width should reach 250; got {}",
386 final_b.width
387 );
388 }
389
390 #[test]
391 fn reduced_motion_snaps_to_natural() {
392 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
393 tree.set_accessibility_preferences(false, true, 1.0);
394 let id = tree.add(SmoothSize::new().child(FixedSize::new().width(150.0).height(60.0)));
395 tree.layout(SizeProposal {
396 width: None,
397 height: None,
398 });
399 tree.layout(SizeProposal {
403 width: None,
404 height: None,
405 });
406 let b = tree.bounds(id);
407 assert!(
408 (b.width - 150.0).abs() < 0.5 && (b.height - 60.0).abs() < 0.5,
409 "expected (150, 60), got ({}, {})",
410 b.width,
411 b.height
412 );
413 assert!(
414 !tree.has_active_animations(),
415 "reduced-motion path must not register animations"
416 );
417 }
418
419 #[test]
420 fn empty_smooth_size_is_safe() {
421 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
422 tree.add(SmoothSize::new());
423 tree.layout(SizeProposal::exact(100.0, 50.0));
424 let _ = tree.render();
425 }
426
427 #[test]
428 fn axes_width_only_pins_height() {
429 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
431 let id = tree.add(
432 SmoothSize::new()
433 .axes(SmoothSizeAxes::Width)
434 .duration(Duration::from_millis(100))
435 .child(TextWidget::new(lit!("hi"))),
436 );
437 tree.layout(SizeProposal {
438 width: None,
439 height: None,
440 });
441 tree.tick_animations(Duration::from_millis(150));
442 tree.layout(SizeProposal {
443 width: None,
444 height: None,
445 });
446 let b = tree.bounds(id);
447 assert!(b.height > 0.0);
448 }
449}