Skip to main content

teksilo_widgets/animations/
smooth_size.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `SmoothSize` — auto-sizes the slot to fit the child's intrinsic
5//! size, but tweens the change instead of jumping. The "empty panel
6//! that suddenly must grow gracefully to accept new content" pattern.
7//!
8//! ```ignore
9//! ctx.add(
10//!     SmoothSize::new()
11//!         .axes(SmoothSizeAxes::Both)
12//!         .child(Panel::new().child(content_signal)),
13//! );
14//! ```
15//!
16//! For *explicit* size animation (target is a numeric signal you
17//! already drive, e.g. a sidebar width), use the existing
18//! `FixedSize::new().width(animated_signal)` + `Signal::animate_to`
19//! pattern instead — that path doesn't need to measure the child every
20//! frame.
21//!
22//! ## Layout semantics
23//!
24//! - The wrapper measures the child's natural size at the proposal
25//!   each layout pass.
26//! - When the natural size differs from the current animation target
27//!   (above 0.5 px), kicks off a new tween.
28//! - `size_that_fits` returns the *current animated value* — what the
29//!   wrapper actually occupies right now, not the target.
30//! - The child is always laid out at its full natural size and clipped
31//!   to the wrapper's smaller animated bounds. Same trick as
32//!   [`Collapse`](super::Collapse) — the child's own internal layout
33//!   doesn't reflow each frame, only the clip rect changes.
34//!
35//! ## Reduced motion
36//!
37//! Honours `prefers-reduced-motion`: snaps to the natural size each
38//! layout pass instead of tweening.
39
40use 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/// Which axes participate in the size tween. Use `Width` or `Height`
53/// to leave the other axis tracking the child's natural size
54/// instantly.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SmoothSizeAxes {
57    /// Animate width changes only; height snaps to natural immediately.
58    Width,
59    /// Animate height changes only; width snaps to natural immediately.
60    Height,
61    /// Animate both width and height changes. Default.
62    Both,
63}
64
65const SIZE_CHANGE_EPSILON: f32 = 0.5;
66
67/// Wraps a child widget and animates the wrapper's reported size toward
68/// the child's current natural size whenever that size changes.
69pub struct SmoothSize {
70    axes: SmoothSizeAxes,
71    duration: Option<Duration>,
72    easing: Option<Easing>,
73    pending_child: Option<PendingChild>,
74    child_id: Option<WidgetId>,
75    /// Animated current width. `BuildContext::animated_signal` —
76    /// scheduler-registered, bound to self at Relayout level.
77    width_anim: Option<Signal<f32>>,
78    /// Animated current height. Same shape as `width_anim`.
79    height_anim: Option<Signal<f32>>,
80    /// Last natural size we kicked off a tween toward. Stored so a
81    /// fresh `size_that_fits` call only initiates a new animation
82    /// when the child's measure has actually changed.
83    last_target: Cell<Size>,
84    /// Latest measured natural size. `place_children` reads it so the
85    /// child is laid out at full natural dimensions (the framework
86    /// clips the overflow against the wrapper's animated bounds).
87    natural_size: Cell<Size>,
88    /// Reduced-motion snapshot taken at build(). Skips the tween path
89    /// and snaps both signals straight to the new natural each frame.
90    reduced_motion: bool,
91    /// `true` until the first natural-size measurement. The first
92    /// measurement *snaps* the size signals — without this guard the
93    /// wrapper would visibly animate from 0×0 up to the child's
94    /// natural size every time it first appears.
95    needs_initial_snap: Cell<bool>,
96}
97
98impl SmoothSize {
99    /// New wrapper. Both axes animate by default.
100    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    /// Restrict the tween to one axis (the other tracks the child's
117    /// natural size instantly).
118    pub fn axes(mut self, axes: SmoothSizeAxes) -> Self {
119        self.axes = axes;
120        self
121    }
122
123    /// Override the tween duration. Default: `MotionTokens::duration_normal`.
124    pub fn duration(mut self, duration: Duration) -> Self {
125        self.duration = Some(duration);
126        self
127    }
128
129    /// Override the easing curve. Default: `MotionTokens::easing_standard`.
130    pub fn easing(mut self, easing: Easing) -> Self {
131        self.easing = Some(easing);
132        self
133    }
134
135    /// Inline child widget (deferred insertion).
136    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    /// Pre-registered child by `WidgetId`.
142    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        // Both signals exist whether or not the axis animates — keeps
184        // size_that_fits branch-free. For pinned axes we just skip
185        // the animate_to call.
186        let w_sig = ctx.animated_signal(0.0);
187        let h_sig = ctx.animated_signal(0.0);
188
189        // Bind both to self at Relayout level so each animation tick
190        // triggers a fresh size_that_fits / place_children pass.
191        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            // build() hasn't run yet — fall back to natural size.
217            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            // First measurement snaps. Reduced motion always snaps.
227            // Otherwise tween only the axes that actually changed and
228            // are configured to animate.
229            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        // Lay the child out at its FULL natural size — let the
261        // framework's clip pass crop the overflow against the
262        // wrapper's smaller animated bounds. Same trick as Collapse.
263        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        // Required: child laid out at natural, wrapper bounds are
272        // smaller during the tween.
273        true
274    }
275
276    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
277        // Layout-animation wrapper. The child owns its own a11y.
278    }
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        // Regression: SmoothSize used to animate from 0 → natural on
295        // its very first appearance, producing a visible "grow from
296        // nothing" glitch. The first measurement must snap to the
297        // child's natural size; only *changes* should tween.
298        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        // Single layout, NO animation tick: the wrapper must already
305        // report (180, 70), not 0×0 or anything mid-tween.
306        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        // After the initial snap, a change in the child's natural
326        // size (here driven through a Signal-bound FixedSize) must
327        // trigger an in-flight animation rather than another snap.
328        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        // Initial snap to 100×50.
336        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        // Bump the child's intrinsic width.
344        width_signal.set(250.0);
345        // First layout: SmoothSize sees the new natural and queues
346        // an animate_to on its width signal.
347        tree.layout(SizeProposal {
348            width: None,
349            height: None,
350        });
351        // Second layout: process_pending_animations drains the queued
352        // request into the scheduler. Bounds remain at the *current*
353        // animated value (still close to 100, not yet 250).
354        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        // Tick to completion.
371        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        // First layout pass: width_anim/height_anim = 0 still get set
394        // to natural via the snap path. A second layout reads the new
395        // values.
396        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        // With axes=Width, height should snap to natural; width animates.
424        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}