Skip to main content

teksilo_widgets/animations/
shake.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Shake` — wraps a child and plays a damped horizontal oscillation
5//! whenever an external trigger `Signal<u32>` is bumped. The classic
6//! invalid-input feedback: wrong password, failed form validation,
7//! "no more results" wall.
8//!
9//! ```ignore
10//! let shake_trigger = ctx.signal(0_u32);
11//! ctx.add(
12//!     Shake::new(shake_trigger.clone())
13//!         .child(text_input_field),
14//! );
15//! // ...elsewhere, on validation failure:
16//! shake_trigger.set(shake_trigger.get() + 1);
17//! ```
18//!
19//! ## Layout semantics
20//!
21//! Layout-stable: the wrapper reports the child's full natural size
22//! and clips the oscillating-out-of-bounds excursions on each side.
23//! Siblings don't reflow. The shake is a pure visual offset.
24//!
25//! ## Reduced motion
26//!
27//! Honours `prefers-reduced-motion`: the trigger no-ops. The widget
28//! is still focusable / interactive — the visual feedback just
29//! doesn't play. Pair with another a11y-friendly cue (red border,
30//! error text) when error state must be communicated.
31
32use std::cell::Cell;
33use std::time::Duration;
34
35use teksilo_canvas::{Point, Rect, Size, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::binding::BindingLevel;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::signal::Signal;
40use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
41use teksilo_core::widget_id::WidgetId;
42use teksilo_tokens::Easing;
43
44const DEFAULT_AMPLITUDE: f32 = 8.0;
45const DEFAULT_CYCLES: f32 = 4.0;
46
47/// Wraps a child and plays a damped horizontal-oscillation shake
48/// each time the trigger signal value changes.
49pub struct Shake {
50    trigger: Signal<u32>,
51    amplitude: f32,
52    /// `None` → fall back to `MotionTokens::duration_slow` at build
53    /// time so a re-themed motion stack flows through.
54    duration: Option<Duration>,
55    cycles: f32,
56    pending_child: Option<PendingChild>,
57    child_id: Option<WidgetId>,
58    /// Linear 0..1 progress driving the shake. `Cell` is fine because
59    /// it's paired with the framework's signal; the natural_size cell
60    /// follows the same pattern as Slide / Collapse.
61    progress: Option<Signal<f32>>,
62    natural_size: Cell<Size>,
63}
64
65impl Shake {
66    /// Build a shake wrapper. Bumping `trigger` (any new value) plays
67    /// one shake cycle.
68    pub fn new(trigger: Signal<u32>) -> Self {
69        Self {
70            trigger,
71            amplitude: DEFAULT_AMPLITUDE,
72            duration: None,
73            cycles: DEFAULT_CYCLES,
74            pending_child: None,
75            child_id: None,
76            progress: None,
77            natural_size: Cell::new(Size::ZERO),
78        }
79    }
80
81    /// Peak horizontal offset in logical pixels. Default 8 px.
82    pub fn amplitude(mut self, px: f32) -> Self {
83        self.amplitude = px.max(0.0);
84        self
85    }
86
87    /// Override the total shake duration. Default:
88    /// `MotionTokens::duration_slow` (~300 ms) — the same one-shot
89    /// "this should feel deliberate" budget dialogs use.
90    pub fn duration(mut self, duration: Duration) -> Self {
91        self.duration = Some(duration);
92        self
93    }
94
95    /// Number of full back-and-forth oscillations within `duration`.
96    /// Default 4 cycles. Higher = jitterier; lower = wobblier.
97    pub fn cycles(mut self, cycles: f32) -> Self {
98        self.cycles = cycles.max(0.5);
99        self
100    }
101
102    /// Inline child widget (deferred insertion).
103    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
104        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
105        self
106    }
107    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
108    ///
109    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
110    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
111    /// adds no arena node, so nothing is laid out, painted, or published to the
112    /// accessibility tree, and a stack applies no spacing around it.
113    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
114        match widget {
115            Some(w) => self.child(w),
116            None => self,
117        }
118    }
119}
120
121impl std::fmt::Debug for Shake {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("Shake")
124            .field("amplitude", &self.amplitude)
125            .field("duration", &self.duration)
126            .field("cycles", &self.cycles)
127            .finish()
128    }
129}
130
131impl Widget for Shake {
132    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
133        if let Some(pending) = self.pending_child.take() {
134            self.child_id = Some(match pending {
135                PendingChild::Id(id) => id,
136                PendingChild::Deferred(w) => ctx.add_boxed(w),
137            });
138        }
139        let Some(child_id) = self.child_id else {
140            return vec![];
141        };
142
143        // 1.0 = at-rest (no shake offset). The shake formula maps
144        // (1-t) * sin(...) so progress=1 → zero offset, progress=0 →
145        // peak amplitude (start of the oscillation).
146        let progress = ctx.animated_signal(1.0);
147        self.progress = Some(progress.clone());
148
149        let id = ctx.self_id();
150        let registry = ctx.binding_registry();
151        progress.bind_to(id, registry, BindingLevel::Relayout);
152
153        // Reduced motion: never start a shake. The trigger still
154        // increments freely on the caller side, just no visual play.
155        if ctx.prefers_reduced_motion() {
156            return vec![child_id];
157        }
158
159        let duration = self.duration.unwrap_or(ctx.theme().motion.duration_slow);
160        let progress_for_effect = progress;
161        ctx.effect(&self.trigger, move |_| {
162            // Restart from 0 each time, even if the previous shake
163            // hadn't completed. Uses Linear easing so the per-tick
164            // value is a true elapsed-fraction — the damped sine in
165            // place_children does the visual shape.
166            progress_for_effect.set(0.0);
167            progress_for_effect.animate_to(1.0, duration, Easing::Linear);
168        });
169
170        vec![child_id]
171    }
172
173    fn layout_response(
174        &self,
175        proposal: SizeProposal,
176        ctx: &LayoutContext,
177    ) -> teksilo_core::widget::LayoutResponse {
178        let Some(child_id) = self.child_id else {
179            return (proposal.resolve(0.0, 0.0)).into();
180        };
181        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
182        self.natural_size.set(natural);
183        natural.into()
184    }
185
186    fn place_children(
187        &self,
188        bounds: Rect,
189        _proposal: SizeProposal,
190        children: &mut [WidgetPlacement],
191        _ctx: &LayoutContext,
192    ) {
193        let t = self
194            .progress
195            .as_ref()
196            .map(|s| s.get().clamp(0.0, 1.0))
197            .unwrap_or(1.0);
198        // Damped sine: amplitude tapers linearly to 0 over [0, 1].
199        let dx = if t >= 1.0 {
200            0.0
201        } else {
202            let envelope = 1.0 - t;
203            let phase = t * self.cycles * std::f32::consts::TAU;
204            self.amplitude * envelope * phase.sin()
205        };
206        let natural = self.natural_size.get();
207        for child in children.iter_mut() {
208            child.origin = Point::new(bounds.x + dx, bounds.y);
209            child.size = natural;
210        }
211    }
212
213    fn clips_children(&self) -> bool {
214        // Required: the oscillation pushes the child past the
215        // wrapper's bounds during the shake; clip so it doesn't
216        // overlap siblings.
217        true
218    }
219
220    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
221        // Visual-only feedback wrapper. The child owns its own a11y.
222    }
223
224    fn children(&self) -> Vec<WidgetId> {
225        self.child_id.into_iter().collect()
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::primitives::TextWidget;
233    use teksilo_core::widget_tree::WidgetTree;
234    use teksilo_i18n::lit;
235
236    #[test]
237    fn shake_starts_at_rest() {
238        let trigger = Signal::new(0_u32);
239        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
240        tree.add(Shake::new(trigger).child(TextWidget::new(lit!("oops"))));
241        tree.layout(SizeProposal {
242            width: Some(200.0),
243            height: None,
244        });
245        assert!(
246            !tree.has_active_animations(),
247            "no animation until the trigger is bumped"
248        );
249    }
250
251    #[test]
252    fn bumping_trigger_starts_shake() {
253        let trigger = Signal::new(0_u32);
254        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
255        tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
256        tree.layout(SizeProposal {
257            width: Some(200.0),
258            height: None,
259        });
260
261        trigger.set(1);
262        tree.tick_animations(Duration::from_millis(50));
263        assert!(
264            tree.has_active_animations(),
265            "shake should be in flight after trigger bump"
266        );
267    }
268
269    #[test]
270    fn shake_completes() {
271        let trigger = Signal::new(0_u32);
272        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
273        tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
274        tree.layout(SizeProposal {
275            width: Some(200.0),
276            height: None,
277        });
278        trigger.set(1);
279        // Tick well past the default 300ms duration — animation must
280        // have completed and the scheduler dropped it.
281        tree.tick_animations(Duration::from_millis(600));
282        assert!(
283            !tree.has_active_animations(),
284            "shake should have completed after its duration"
285        );
286    }
287
288    #[test]
289    fn reduced_motion_swallows_trigger() {
290        let trigger = Signal::new(0_u32);
291        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
292        tree.set_accessibility_preferences(false, true, 1.0);
293        tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
294        tree.layout(SizeProposal {
295            width: Some(200.0),
296            height: None,
297        });
298
299        trigger.set(1);
300        assert!(
301            !tree.has_active_animations(),
302            "reduced-motion path must not register animations"
303        );
304    }
305}