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 teksilo_core::IntoTeksiChild) -> Self {
137        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
138        self
139    }
140    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
141    ///
142    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
143    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
144    /// adds no arena node, so nothing is laid out, painted, or published to the
145    /// accessibility tree, and a stack applies no spacing around it.
146    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        // Both signals exist whether or not the axis animates — keeps
190        // size_that_fits branch-free. For pinned axes we just skip
191        // the animate_to call.
192        let w_sig = ctx.animated_signal(0.0);
193        let h_sig = ctx.animated_signal(0.0);
194
195        // Bind both to self at Relayout level so each animation tick
196        // triggers a fresh size_that_fits / place_children pass.
197        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            // build() hasn't run yet — fall back to natural size.
223            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            // First measurement snaps. Reduced motion always snaps.
233            // Otherwise tween only the axes that actually changed and
234            // are configured to animate.
235            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        // Lay the child out at its FULL natural size — let the
267        // framework's clip pass crop the overflow against the
268        // wrapper's smaller animated bounds. Same trick as Collapse.
269        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        // Required: child laid out at natural, wrapper bounds are
278        // smaller during the tween.
279        true
280    }
281
282    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
283        // Layout-animation wrapper. The child owns its own a11y.
284    }
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        // Regression: SmoothSize used to animate from 0 → natural on
301        // its very first appearance, producing a visible "grow from
302        // nothing" glitch. The first measurement must snap to the
303        // child's natural size; only *changes* should tween.
304        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        // Single layout, NO animation tick: the wrapper must already
311        // report (180, 70), not 0×0 or anything mid-tween.
312        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        // After the initial snap, a change in the child's natural
332        // size (here driven through a Signal-bound FixedSize) must
333        // trigger an in-flight animation rather than another snap.
334        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        // Initial snap to 100×50.
342        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        // Bump the child's intrinsic width.
350        width_signal.set(250.0);
351        // First layout: SmoothSize sees the new natural and queues
352        // an animate_to on its width signal.
353        tree.layout(SizeProposal {
354            width: None,
355            height: None,
356        });
357        // Second layout: process_pending_animations drains the queued
358        // request into the scheduler. Bounds remain at the *current*
359        // animated value (still close to 100, not yet 250).
360        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        // Tick to completion.
377        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        // First layout pass: width_anim/height_anim = 0 still get set
400        // to natural via the snap path. A second layout reads the new
401        // values.
402        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        // With axes=Width, height should snap to natural; width animates.
430        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}