Skip to main content

teksilo_widgets/animations/
scale.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Scale` — wraps a child and animates a uniform 2D scale on its
5//! entire subtree when an external `Prop<bool>` toggles. Drives a
6//! `progress: Signal<f32>` ∈ [0, 1] (0 = invisible, 1 = at rest) and
7//! applies it as a centered (or origin-pivoted) scale transform via
8//! [`BuildContext::set_transform`] — the renderer's transform stack
9//! composes it onto the subtree.
10//!
11//! ```ignore
12//! let visible = ctx.signal(false);
13//! ctx.add(Scale::new(visible.clone()).child(card));
14//! visible.set(true);   // scale-in around the slot center
15//! ```
16//!
17//! ## Two layout modes
18//!
19//! - **Visual-only (default)** — `reflow=false`. The slot stays at the
20//!   child's natural size at all scale values; only the *visual content*
21//!   shrinks/grows around the chosen origin. Use for: overlay enter/exit,
22//!   "boop" feedback on a Card, focus emphasis. Pair with `Center`
23//!   origin (the default).
24//! - **Reflow** — `.reflow(true)`. The wrapper's `layout_response`
25//!   returns `child_size * progress`, so siblings reflow as the child
26//!   shrinks to nothing. The visual content scales by the same factor,
27//!   fitting exactly within the shrunken slot. Use for: a Card that
28//!   disappears by shrinking with surrounding cards filling the gap.
29//!   Pair with `TopLeading` origin (so the visual stays anchored at
30//!   the slot's top-left as it shrinks — otherwise the visual drifts
31//!   while the slot shrinks).
32//!
33//! ## Why this isn't just `Collapse`
34//!
35//! `Collapse` animates only one axis (height by default) and "wipes"
36//! content via clipping — text inside stays at full size, only the
37//! visible portion shrinks. `Scale` shrinks uniformly on both axes,
38//! and text/icons visually get smaller. Different visual vocabulary,
39//! different use cases.
40//!
41//! ## Reduced motion
42//!
43//! Honours `prefers-reduced-motion`: snaps progress to its end value
44//! (visible / hidden) instead of tweening.
45
46use std::cell::Cell;
47use std::rc::Rc;
48use std::time::Duration;
49
50use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
51use teksilo_core::accessibility::AccessNodeBuilder;
52use teksilo_core::binding::BindingLevel;
53use teksilo_core::build_context::BuildContext;
54use teksilo_core::signal::{Prop, Signal};
55use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
56use teksilo_core::widget_id::WidgetId;
57use teksilo_tokens::Easing;
58
59/// Pivot point for the scale matrix, expressed relative to the
60/// wrapper's slot rectangle.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ScaleOrigin {
63    /// Scale around the centre of the slot. Default for visual-only mode.
64    Center,
65    /// Pin the top-leading corner; content grows/shrinks toward the bottom-trailing.
66    TopLeading,
67    /// Pin the top-trailing corner; content grows/shrinks toward the bottom-leading.
68    TopTrailing,
69    /// Pin the bottom-leading corner; content grows/shrinks toward the top-trailing.
70    BottomLeading,
71    /// Pin the bottom-trailing corner; content grows/shrinks toward the top-leading.
72    BottomTrailing,
73}
74
75impl ScaleOrigin {
76    /// Compute the world-space pivot point given the wrapper's
77    /// rendered slot. Honours RTL by flipping Leading/Trailing.
78    /// Shared by `Scale` and `Rotate`.
79    pub(crate) fn pivot_world(self, bounds: Rect, is_rtl: bool) -> Point {
80        let (x_anchor, y_anchor) = match self {
81            Self::Center => (Anchor::Mid, Anchor::Mid),
82            Self::TopLeading => (Anchor::Leading, Anchor::Start),
83            Self::TopTrailing => (Anchor::Trailing, Anchor::Start),
84            Self::BottomLeading => (Anchor::Leading, Anchor::End),
85            Self::BottomTrailing => (Anchor::Trailing, Anchor::End),
86        };
87        let x = match (x_anchor, is_rtl) {
88            (Anchor::Mid, _) => bounds.x + bounds.width * 0.5,
89            (Anchor::Leading, false) | (Anchor::Trailing, true) => bounds.x,
90            (Anchor::Trailing, false) | (Anchor::Leading, true) => bounds.x + bounds.width,
91            (Anchor::Start, _) | (Anchor::End, _) => unreachable!(),
92        };
93        let y = match y_anchor {
94            Anchor::Start => bounds.y,
95            Anchor::Mid => bounds.y + bounds.height * 0.5,
96            Anchor::End => bounds.y + bounds.height,
97            Anchor::Leading | Anchor::Trailing => unreachable!(),
98        };
99        Point::new(x, y)
100    }
101}
102
103#[derive(Clone, Copy)]
104enum Anchor {
105    Leading,
106    Trailing,
107    Start,
108    Mid,
109    End,
110}
111
112/// Scale matrix `T(pivot) * S(scale) * T(-pivot)` — uniform scale
113/// around a pivot point in world coords.
114fn centered_scale(pivot: Point, scale: f32) -> Transform2D {
115    Transform2D {
116        m: [
117            scale,
118            0.0,
119            0.0,
120            scale,
121            pivot.x * (1.0 - scale),
122            pivot.y * (1.0 - scale),
123        ],
124    }
125}
126
127/// Wraps a child widget and animates a uniform 2D visual scale on its
128/// subtree when an external `Prop<bool>` toggles between visible and hidden.
129pub struct Scale {
130    visible: Prop<bool>,
131    reflow: bool,
132    origin: ScaleOrigin,
133    duration: Option<Duration>,
134    easing: Option<Easing>,
135    pending_child: Option<PendingChild>,
136    child_id: Option<WidgetId>,
137    /// 0 = fully scaled out, 1 = at rest. Animated.
138    progress: Option<Signal<f32>>,
139    /// Output: the actual transform matrix the render walker reads via
140    /// `set_transform`. Updated from `place_children` once we know the
141    /// world-space pivot (bounds aren't available in `layout_response`).
142    transform_signal: Option<Signal<Transform2D>>,
143    /// Last natural-size measurement; `place_children` reads it to
144    /// place the child at full natural while the slot may be smaller.
145    natural_size: Cell<Size>,
146    /// Last bounds the wrapper was placed in. Used by the progress
147    /// observer to recompute the transform matrix on every animation
148    /// tick *without* triggering relayout via `Relayout` binding —
149    /// avoids hitting the layout pipeline 60× per second for purely
150    /// visual scale animations.
151    last_bounds: Rc<Cell<Rect>>,
152    /// Captured at build(); place_children uses it to resolve
153    /// Leading/Trailing origins when the layout context's RTL flag
154    /// can't otherwise be threaded into the matrix-recompute observer.
155    last_is_rtl: Rc<Cell<bool>>,
156}
157
158impl Scale {
159    /// Create a scale wrapper bound to `visible`; accepts a static `bool`
160    /// or a reactive `Signal<bool>`. Defaults: visual-only (no layout
161    /// reflow), `Center` origin, `MotionTokens::duration_normal` +
162    /// `easing_standard`.
163    pub fn new(visible: impl Into<Prop<bool>>) -> Self {
164        Self {
165            visible: visible.into(),
166            reflow: false,
167            origin: ScaleOrigin::Center,
168            duration: None,
169            easing: None,
170            pending_child: None,
171            child_id: None,
172            progress: None,
173            transform_signal: None,
174            natural_size: Cell::new(Size::ZERO),
175            last_bounds: Rc::new(Cell::new(Rect::ZERO)),
176            last_is_rtl: Rc::new(Cell::new(false)),
177        }
178    }
179
180    /// When `true`, the wrapper's reported size shrinks with progress
181    /// (siblings reflow). Pair with `.origin(ScaleOrigin::TopLeading)`
182    /// for the "card removal" pattern. Default: `false` (visual-only).
183    pub fn reflow(mut self, reflow: bool) -> Self {
184        self.reflow = reflow;
185        self
186    }
187
188    /// Pivot point for the scale matrix. Default `Center` for visual-
189    /// only mode; consider `TopLeading` when `reflow=true`.
190    pub fn origin(mut self, origin: ScaleOrigin) -> Self {
191        self.origin = origin;
192        self
193    }
194
195    /// Override the tween duration. Default: `MotionTokens::duration_normal`.
196    pub fn duration(mut self, duration: Duration) -> Self {
197        self.duration = Some(duration);
198        self
199    }
200
201    /// Override the easing. Default: `MotionTokens::easing_standard`.
202    pub fn easing(mut self, easing: Easing) -> Self {
203        self.easing = Some(easing);
204        self
205    }
206
207    /// Inline child widget (deferred insertion).
208    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
209        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
210        self
211    }
212
213    /// Pre-registered child by `WidgetId`.
214    pub fn child_id(mut self, id: WidgetId) -> Self {
215        self.pending_child = Some(PendingChild::Id(id));
216        self
217    }
218}
219
220impl std::fmt::Debug for Scale {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("Scale")
223            .field("reflow", &self.reflow)
224            .field("origin", &self.origin)
225            .finish()
226    }
227}
228
229impl Widget for Scale {
230    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
231        if let Some(pending) = self.pending_child.take() {
232            self.child_id = Some(match pending {
233                PendingChild::Id(id) => id,
234                PendingChild::Deferred(w) => ctx.add_boxed(w),
235            });
236        }
237        let Some(child_id) = self.child_id else {
238            return vec![];
239        };
240
241        let initial = if self.visible.get() { 1.0 } else { 0.0 };
242        let progress = ctx.animated_signal(initial);
243        let transform_signal = ctx.signal(Transform2D::IDENTITY);
244
245        // Apply the transform via the render walker scope.
246        let id = ctx.self_id();
247        ctx.set_transform(id, transform_signal.clone());
248
249        // Reflow mode: progress also drives the wrapper's reported
250        // size — bind at Relayout so each tick re-runs layout_response.
251        // Visual-only mode: progress only drives repaint via the
252        // transform_signal observer (registered below); no Relayout
253        // binding on progress itself.
254        if self.reflow {
255            let registry = ctx.binding_registry();
256            progress.bind_to(id, registry, BindingLevel::Relayout);
257        }
258
259        // Recompute the transform matrix on every progress tick. Reads
260        // last_bounds (set by place_children) and writes to
261        // transform_signal — that signal's RepaintOnly binding then
262        // marks self for repaint, no relayout for visual-only mode.
263        let last_bounds = self.last_bounds.clone();
264        let last_is_rtl = self.last_is_rtl.clone();
265        let origin = self.origin;
266        let transform_for_observer = transform_signal.clone();
267        ctx.effect(&progress, move |&p| {
268            let p = p.clamp(0.0, 1.0);
269            let bounds = last_bounds.get();
270            let pivot = origin.pivot_world(bounds, last_is_rtl.get());
271            transform_for_observer.set(centered_scale(pivot, p));
272        });
273
274        self.progress = Some(progress.clone());
275        self.transform_signal = Some(transform_signal);
276
277        // Drive the scale on visibility flips.
278        if let Prop::Bound(visible_signal) = &self.visible {
279            let visible_signal = visible_signal.clone();
280            let scale_anim = if let Some(d) = self.duration {
281                ctx.animate().duration(d)
282            } else {
283                ctx.animate().normal()
284            };
285            let scale_anim = if let Some(e) = self.easing {
286                scale_anim.easing(e)
287            } else {
288                scale_anim.standard()
289            };
290            let progress_for_effect = progress;
291            ctx.effect(&visible_signal, move |&v| {
292                let target = if v { 1.0 } else { 0.0 };
293                scale_anim.to_or_snap(&progress_for_effect, target);
294            });
295        }
296
297        vec![child_id]
298    }
299
300    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
301        let Some(child_id) = self.child_id else {
302            return proposal.resolve(0.0, 0.0).into();
303        };
304        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
305        self.natural_size.set(natural);
306        if self.reflow {
307            let p = self
308                .progress
309                .as_ref()
310                .map(|s| s.get().clamp(0.0, 1.0))
311                .unwrap_or(1.0);
312            Size::new(natural.width * p, natural.height * p).into()
313        } else {
314            natural.into()
315        }
316    }
317
318    fn place_children(
319        &self,
320        bounds: Rect,
321        _proposal: SizeProposal,
322        children: &mut [WidgetPlacement],
323        ctx: &LayoutContext,
324    ) {
325        // Capture bounds + RTL for the progress observer; recompute
326        // and publish the transform now so the very first frame paints
327        // at the correct matrix even before any animation tick.
328        self.last_bounds.set(bounds);
329        self.last_is_rtl.set(ctx.is_rtl());
330        if let (Some(progress), Some(t_sig)) = (&self.progress, &self.transform_signal) {
331            let p = progress.get().clamp(0.0, 1.0);
332            let pivot = self.origin.pivot_world(bounds, ctx.is_rtl());
333            t_sig.set(centered_scale(pivot, p));
334        }
335
336        // Lay the child at full natural — the transform scales it to
337        // fit the wrapper's (potentially shrunken in reflow mode)
338        // bounds. Same trick Collapse uses for clean clipping.
339        let natural = self.natural_size.get();
340        for child in children.iter_mut() {
341            child.origin = Point::new(bounds.x, bounds.y);
342            child.size = natural;
343        }
344    }
345
346    fn clips_children(&self) -> bool {
347        // Reflow mode: child renders at natural, slot is smaller —
348        // clip the overflow. Visual-only mode: scaled-up content can
349        // still overshoot the slot; clip to keep siblings safe.
350        true
351    }
352
353    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
354        // Visual-modulation wrapper. Child owns its own a11y.
355    }
356
357    fn children(&self) -> Vec<WidgetId> {
358        self.child_id.into_iter().collect()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use std::time::Duration;
365
366    use super::*;
367    use crate::primitives::TextWidget;
368    use teksilo_core::widget_tree::WidgetTree;
369    use teksilo_i18n::lit;
370
371    #[test]
372    fn starts_visible_when_signal_true_emits_identity_skip() {
373        // Initial visible=true → progress=1 → scale matrix = identity →
374        // walker should NOT emit a PushTransform pair (identity skip).
375        let visible = Signal::new(true);
376        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
377        tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
378        tree.layout(SizeProposal {
379            width: Some(200.0),
380            height: None,
381        });
382        let frame = tree.render();
383        let push_count = frame
384            .draw_order
385            .iter()
386            .filter(|c| matches!(c, teksilo_canvas::DrawCommand::PushTransform(_)))
387            .count();
388        assert_eq!(
389            push_count, 0,
390            "identity transform must be skipped, draw_order = {:?}",
391            frame.draw_order
392        );
393    }
394
395    #[test]
396    fn starts_hidden_when_signal_false_emits_zero_scale() {
397        // Initial visible=false → progress=0 → scale matrix has scale
398        // factor 0 (degenerate). PushTransform should emit with that
399        // matrix.
400        let visible = Signal::new(false);
401        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
402        tree.add(Scale::new(visible).child(TextWidget::new(lit!("hello"))));
403        tree.layout(SizeProposal {
404            width: Some(200.0),
405            height: None,
406        });
407        let frame = tree.render();
408        let pushes: Vec<&Transform2D> = frame
409            .draw_order
410            .iter()
411            .filter_map(|c| match c {
412                teksilo_canvas::DrawCommand::PushTransform(t) => Some(t),
413                _ => None,
414            })
415            .collect();
416        assert_eq!(pushes.len(), 1);
417        // Scale factor lives at m[0] and m[3].
418        assert!(pushes[0].m[0].abs() < 1e-3);
419        assert!(pushes[0].m[3].abs() < 1e-3);
420    }
421
422    #[test]
423    fn reflow_true_changes_layout_size() {
424        // With reflow=true, the wrapper's bounds shrink as progress
425        // ticks toward 0.
426        let visible = Signal::new(true);
427        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
428        let id = tree.add(
429            Scale::new(visible.clone())
430                .reflow(true)
431                .duration(Duration::from_millis(100))
432                .child(TextWidget::new(lit!("content"))),
433        );
434        tree.layout(SizeProposal {
435            width: Some(300.0),
436            height: None,
437        });
438        let initial_h = tree.bounds(id).height;
439        assert!(initial_h > 0.0);
440
441        visible.set(false);
442        // Drain pending animation onto scheduler, then tick to roughly
443        // halfway through the 100ms tween.
444        tree.layout(SizeProposal {
445            width: Some(300.0),
446            height: None,
447        });
448        tree.tick_animations(Duration::from_millis(50));
449        tree.layout(SizeProposal {
450            width: Some(300.0),
451            height: None,
452        });
453        let mid_h = tree.bounds(id).height;
454        assert!(
455            mid_h < initial_h * 0.95,
456            "halfway through scale-out, height ({}) should be visibly less than initial ({})",
457            mid_h,
458            initial_h,
459        );
460        assert!(
461            mid_h > 0.0,
462            "halfway through scale-out, height ({}) should not yet be zero",
463            mid_h,
464        );
465    }
466
467    #[test]
468    fn reflow_false_keeps_layout_size_constant() {
469        // Default (reflow=false): wrapper bounds stay at natural at
470        // all progress values; only the visual scales.
471        let visible = Signal::new(true);
472        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
473        let id = tree.add(
474            Scale::new(visible.clone())
475                .duration(Duration::from_millis(100))
476                .child(TextWidget::new(lit!("content"))),
477        );
478        tree.layout(SizeProposal {
479            width: Some(300.0),
480            height: None,
481        });
482        let initial_size = tree.bounds(id).size();
483
484        visible.set(false);
485        tree.layout(SizeProposal {
486            width: Some(300.0),
487            height: None,
488        });
489        tree.tick_animations(Duration::from_millis(50));
490        tree.layout(SizeProposal {
491            width: Some(300.0),
492            height: None,
493        });
494        let mid_size = tree.bounds(id).size();
495        assert_eq!(
496            initial_size, mid_size,
497            "visual-only scale must not change layout"
498        );
499    }
500
501    #[test]
502    fn reduced_motion_snaps_scale() {
503        let visible = Signal::new(true);
504        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
505        tree.set_accessibility_preferences(false, true, 1.0);
506        tree.add(Scale::new(visible.clone()).child(TextWidget::new(lit!("x"))));
507        tree.layout(SizeProposal {
508            width: Some(200.0),
509            height: None,
510        });
511
512        visible.set(false);
513        // to_or_snap under reduced motion sets directly — no animation
514        // should be queued onto the scheduler.
515        assert!(
516            !tree.has_active_animations(),
517            "reduced-motion path must not register a scale animation"
518        );
519    }
520}