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 Widget + 'static) -> Self {
104        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
105        self
106    }
107
108    /// Pre-registered child by `WidgetId`.
109    pub fn child_id(mut self, id: WidgetId) -> Self {
110        self.pending_child = Some(PendingChild::Id(id));
111        self
112    }
113}
114
115impl std::fmt::Debug for Shake {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("Shake")
118            .field("amplitude", &self.amplitude)
119            .field("duration", &self.duration)
120            .field("cycles", &self.cycles)
121            .finish()
122    }
123}
124
125impl Widget for Shake {
126    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
127        if let Some(pending) = self.pending_child.take() {
128            self.child_id = Some(match pending {
129                PendingChild::Id(id) => id,
130                PendingChild::Deferred(w) => ctx.add_boxed(w),
131            });
132        }
133        let Some(child_id) = self.child_id else {
134            return vec![];
135        };
136
137        // 1.0 = at-rest (no shake offset). The shake formula maps
138        // (1-t) * sin(...) so progress=1 → zero offset, progress=0 →
139        // peak amplitude (start of the oscillation).
140        let progress = ctx.animated_signal(1.0);
141        self.progress = Some(progress.clone());
142
143        let id = ctx.self_id();
144        let registry = ctx.binding_registry();
145        progress.bind_to(id, registry, BindingLevel::Relayout);
146
147        // Reduced motion: never start a shake. The trigger still
148        // increments freely on the caller side, just no visual play.
149        if ctx.prefers_reduced_motion() {
150            return vec![child_id];
151        }
152
153        let duration = self.duration.unwrap_or(ctx.theme().motion.duration_slow);
154        let progress_for_effect = progress;
155        ctx.effect(&self.trigger, move |_| {
156            // Restart from 0 each time, even if the previous shake
157            // hadn't completed. Uses Linear easing so the per-tick
158            // value is a true elapsed-fraction — the damped sine in
159            // place_children does the visual shape.
160            progress_for_effect.set(0.0);
161            progress_for_effect.animate_to(1.0, duration, Easing::Linear);
162        });
163
164        vec![child_id]
165    }
166
167    fn layout_response(
168        &self,
169        proposal: SizeProposal,
170        ctx: &LayoutContext,
171    ) -> teksilo_core::widget::LayoutResponse {
172        let Some(child_id) = self.child_id else {
173            return (proposal.resolve(0.0, 0.0)).into();
174        };
175        let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
176        self.natural_size.set(natural);
177        natural.into()
178    }
179
180    fn place_children(
181        &self,
182        bounds: Rect,
183        _proposal: SizeProposal,
184        children: &mut [WidgetPlacement],
185        _ctx: &LayoutContext,
186    ) {
187        let t = self
188            .progress
189            .as_ref()
190            .map(|s| s.get().clamp(0.0, 1.0))
191            .unwrap_or(1.0);
192        // Damped sine: amplitude tapers linearly to 0 over [0, 1].
193        let dx = if t >= 1.0 {
194            0.0
195        } else {
196            let envelope = 1.0 - t;
197            let phase = t * self.cycles * std::f32::consts::TAU;
198            self.amplitude * envelope * phase.sin()
199        };
200        let natural = self.natural_size.get();
201        for child in children.iter_mut() {
202            child.origin = Point::new(bounds.x + dx, bounds.y);
203            child.size = natural;
204        }
205    }
206
207    fn clips_children(&self) -> bool {
208        // Required: the oscillation pushes the child past the
209        // wrapper's bounds during the shake; clip so it doesn't
210        // overlap siblings.
211        true
212    }
213
214    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
215        // Visual-only feedback wrapper. The child owns its own a11y.
216    }
217
218    fn children(&self) -> Vec<WidgetId> {
219        self.child_id.into_iter().collect()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::primitives::TextWidget;
227    use teksilo_core::widget_tree::WidgetTree;
228    use teksilo_i18n::lit;
229
230    #[test]
231    fn shake_starts_at_rest() {
232        let trigger = Signal::new(0_u32);
233        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
234        tree.add(Shake::new(trigger).child(TextWidget::new(lit!("oops"))));
235        tree.layout(SizeProposal {
236            width: Some(200.0),
237            height: None,
238        });
239        assert!(
240            !tree.has_active_animations(),
241            "no animation until the trigger is bumped"
242        );
243    }
244
245    #[test]
246    fn bumping_trigger_starts_shake() {
247        let trigger = Signal::new(0_u32);
248        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
249        tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
250        tree.layout(SizeProposal {
251            width: Some(200.0),
252            height: None,
253        });
254
255        trigger.set(1);
256        tree.tick_animations(Duration::from_millis(50));
257        assert!(
258            tree.has_active_animations(),
259            "shake should be in flight after trigger bump"
260        );
261    }
262
263    #[test]
264    fn shake_completes() {
265        let trigger = Signal::new(0_u32);
266        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
267        tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
268        tree.layout(SizeProposal {
269            width: Some(200.0),
270            height: None,
271        });
272        trigger.set(1);
273        // Tick well past the default 400ms duration — animation must
274        // have completed and the scheduler dropped it.
275        tree.tick_animations(Duration::from_millis(600));
276        assert!(
277            !tree.has_active_animations(),
278            "shake should have completed after its duration"
279        );
280    }
281
282    #[test]
283    fn reduced_motion_swallows_trigger() {
284        let trigger = Signal::new(0_u32);
285        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
286        tree.set_accessibility_preferences(false, true, 1.0);
287        tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
288        tree.layout(SizeProposal {
289            width: Some(200.0),
290            height: None,
291        });
292
293        trigger.set(1);
294        assert!(
295            !tree.has_active_animations(),
296            "reduced-motion path must not register animations"
297        );
298    }
299}