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 Widget + 'static) -> Self {
137 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
138 self
139 }
140
141 pub fn child_id(mut self, id: WidgetId) -> Self {
143 self.pending_child = Some(PendingChild::Id(id));
144 self
145 }
146
147 fn animates_width(&self) -> bool {
148 matches!(self.axes, SmoothSizeAxes::Width | SmoothSizeAxes::Both)
149 }
150
151 fn animates_height(&self) -> bool {
152 matches!(self.axes, SmoothSizeAxes::Height | SmoothSizeAxes::Both)
153 }
154}
155
156impl Default for SmoothSize {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162impl std::fmt::Debug for SmoothSize {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.debug_struct("SmoothSize")
165 .field("axes", &self.axes)
166 .field("duration", &self.duration)
167 .finish()
168 }
169}
170
171impl Widget for SmoothSize {
172 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
173 if let Some(pending) = self.pending_child.take() {
174 self.child_id = Some(match pending {
175 PendingChild::Id(id) => id,
176 PendingChild::Deferred(w) => ctx.add_boxed(w),
177 });
178 }
179 let Some(child_id) = self.child_id else {
180 return vec![];
181 };
182
183 let w_sig = ctx.animated_signal(0.0);
187 let h_sig = ctx.animated_signal(0.0);
188
189 let id = ctx.self_id();
192 let registry = ctx.binding_registry();
193 w_sig.bind_to(id, registry, BindingLevel::Relayout);
194 h_sig.bind_to(id, registry, BindingLevel::Relayout);
195
196 self.width_anim = Some(w_sig);
197 self.height_anim = Some(h_sig);
198 self.reduced_motion = ctx.prefers_reduced_motion();
199
200 vec![child_id]
201 }
202
203 fn layout_response(
204 &self,
205 proposal: SizeProposal,
206 ctx: &LayoutContext,
207 ) -> teksilo_core::widget::LayoutResponse {
208 let Some(child_id) = self.child_id else {
209 return (proposal.resolve(0.0, 0.0)).into();
210 };
211 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
212 self.natural_size.set(natural);
213
214 let (Some(w_sig), Some(h_sig)) = (self.width_anim.as_ref(), self.height_anim.as_ref())
215 else {
216 return (natural).into();
218 };
219
220 let last = self.last_target.get();
221 let width_target_changed = (natural.width - last.width).abs() > SIZE_CHANGE_EPSILON;
222 let height_target_changed = (natural.height - last.height).abs() > SIZE_CHANGE_EPSILON;
223
224 if width_target_changed || height_target_changed {
225 self.last_target.set(natural);
226 let snap = self.reduced_motion || self.needs_initial_snap.get();
230 self.needs_initial_snap.set(false);
231 if snap {
232 w_sig.set(natural.width);
233 h_sig.set(natural.height);
234 } else {
235 let duration = self.duration.unwrap_or(ctx.theme.motion.duration_normal);
236 let easing = self.easing.unwrap_or(ctx.theme.motion.easing_standard);
237 if self.animates_width() && width_target_changed {
238 w_sig.animate_to(natural.width, duration, easing);
239 } else if !self.animates_width() {
240 w_sig.set(natural.width);
241 }
242 if self.animates_height() && height_target_changed {
243 h_sig.animate_to(natural.height, duration, easing);
244 } else if !self.animates_height() {
245 h_sig.set(natural.height);
246 }
247 }
248 }
249
250 Size::new(w_sig.get().max(0.0), h_sig.get().max(0.0)).into()
251 }
252
253 fn place_children(
254 &self,
255 bounds: Rect,
256 _proposal: SizeProposal,
257 children: &mut [WidgetPlacement],
258 _ctx: &LayoutContext,
259 ) {
260 let natural = self.natural_size.get();
264 for child in children.iter_mut() {
265 child.origin = Point::new(bounds.x, bounds.y);
266 child.size = natural;
267 }
268 }
269
270 fn clips_children(&self) -> bool {
271 true
274 }
275
276 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
277 }
279
280 fn children(&self) -> Vec<WidgetId> {
281 self.child_id.into_iter().collect()
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::primitives::{FixedSize, TextWidget};
289 use teksilo_core::widget_tree::WidgetTree;
290 use teksilo_i18n::lit;
291
292 #[test]
293 fn first_measurement_snaps_to_natural_no_grow_in_animation() {
294 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
299 let id = tree.add(
300 SmoothSize::new()
301 .duration(Duration::from_millis(500))
302 .child(FixedSize::new().width(180.0).height(70.0)),
303 );
304 tree.layout(SizeProposal {
307 width: None,
308 height: None,
309 });
310 let b = tree.bounds(id);
311 assert!(
312 (b.width - 180.0).abs() < 0.5 && (b.height - 70.0).abs() < 0.5,
313 "first-frame size must equal natural; got ({}, {})",
314 b.width,
315 b.height
316 );
317 assert!(
318 !tree.has_active_animations(),
319 "first-frame snap must not register an animation"
320 );
321 }
322
323 #[test]
324 fn subsequent_change_animates() {
325 let width_signal = Signal::new(100.0_f32);
329 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
330 let id = tree.add(
331 SmoothSize::new()
332 .duration(Duration::from_millis(200))
333 .child(FixedSize::new().width(width_signal.clone()).height(50.0)),
334 );
335 tree.layout(SizeProposal {
337 width: None,
338 height: None,
339 });
340 let initial = tree.bounds(id);
341 assert!((initial.width - 100.0).abs() < 0.5);
342
343 width_signal.set(250.0);
345 tree.layout(SizeProposal {
348 width: None,
349 height: None,
350 });
351 tree.layout(SizeProposal {
355 width: None,
356 height: None,
357 });
358 let mid = tree.bounds(id);
359 assert!(
360 tree.has_active_animations(),
361 "size change must kick off a tween (got bounds {:?})",
362 mid
363 );
364 assert!(
365 mid.width < 240.0,
366 "mid-tween width should still be near the start, got {}",
367 mid.width
368 );
369
370 tree.tick_animations(Duration::from_millis(250));
372 tree.layout(SizeProposal {
373 width: None,
374 height: None,
375 });
376 let final_b = tree.bounds(id);
377 assert!(
378 (final_b.width - 250.0).abs() < 1.0,
379 "after tween, width should reach 250; got {}",
380 final_b.width
381 );
382 }
383
384 #[test]
385 fn reduced_motion_snaps_to_natural() {
386 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
387 tree.set_accessibility_preferences(false, true, 1.0);
388 let id = tree.add(SmoothSize::new().child(FixedSize::new().width(150.0).height(60.0)));
389 tree.layout(SizeProposal {
390 width: None,
391 height: None,
392 });
393 tree.layout(SizeProposal {
397 width: None,
398 height: None,
399 });
400 let b = tree.bounds(id);
401 assert!(
402 (b.width - 150.0).abs() < 0.5 && (b.height - 60.0).abs() < 0.5,
403 "expected (150, 60), got ({}, {})",
404 b.width,
405 b.height
406 );
407 assert!(
408 !tree.has_active_animations(),
409 "reduced-motion path must not register animations"
410 );
411 }
412
413 #[test]
414 fn empty_smooth_size_is_safe() {
415 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
416 tree.add(SmoothSize::new());
417 tree.layout(SizeProposal::exact(100.0, 50.0));
418 let _ = tree.render();
419 }
420
421 #[test]
422 fn axes_width_only_pins_height() {
423 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
425 let id = tree.add(
426 SmoothSize::new()
427 .axes(SmoothSizeAxes::Width)
428 .duration(Duration::from_millis(100))
429 .child(TextWidget::new(lit!("hi"))),
430 );
431 tree.layout(SizeProposal {
432 width: None,
433 height: None,
434 });
435 tree.tick_animations(Duration::from_millis(150));
436 tree.layout(SizeProposal {
437 width: None,
438 height: None,
439 });
440 let b = tree.bounds(id);
441 assert!(b.height > 0.0);
442 }
443}