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 Widget + 'static) -> Self {
104 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
105 self
106 }
107
108 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 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 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 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 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 true
212 }
213
214 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
215 }
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 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}