teksilo_widgets/animations/
shake.rs1use 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
47pub struct Shake {
50 trigger: Signal<u32>,
51 amplitude: f32,
52 duration: Option<Duration>,
55 cycles: f32,
56 pending_child: Option<PendingChild>,
57 child_id: Option<WidgetId>,
58 progress: Option<Signal<f32>>,
62 natural_size: Cell<Size>,
63}
64
65impl Shake {
66 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 pub fn amplitude(mut self, px: f32) -> Self {
83 self.amplitude = px.max(0.0);
84 self
85 }
86
87 pub fn duration(mut self, duration: Duration) -> Self {
91 self.duration = Some(duration);
92 self
93 }
94
95 pub fn cycles(mut self, cycles: f32) -> Self {
98 self.cycles = cycles.max(0.5);
99 self
100 }
101
102 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 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 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 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 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 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 true
218 }
219
220 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
221 }
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 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}