use std::cell::Cell;
use std::time::Duration;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::Easing;
const DEFAULT_AMPLITUDE: f32 = 8.0;
const DEFAULT_CYCLES: f32 = 4.0;
pub struct Shake {
trigger: Signal<u32>,
amplitude: f32,
duration: Option<Duration>,
cycles: f32,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
progress: Option<Signal<f32>>,
natural_size: Cell<Size>,
}
impl Shake {
pub fn new(trigger: Signal<u32>) -> Self {
Self {
trigger,
amplitude: DEFAULT_AMPLITUDE,
duration: None,
cycles: DEFAULT_CYCLES,
pending_child: None,
child_id: None,
progress: None,
natural_size: Cell::new(Size::ZERO),
}
}
pub fn amplitude(mut self, px: f32) -> Self {
self.amplitude = px.max(0.0);
self
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
pub fn cycles(mut self, cycles: f32) -> Self {
self.cycles = cycles.max(0.5);
self
}
pub fn child(mut self, widget: impl Widget + 'static) -> Self {
self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
self
}
pub fn child_id(mut self, id: WidgetId) -> Self {
self.pending_child = Some(PendingChild::Id(id));
self
}
}
impl std::fmt::Debug for Shake {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Shake")
.field("amplitude", &self.amplitude)
.field("duration", &self.duration)
.field("cycles", &self.cycles)
.finish()
}
}
impl Widget for Shake {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Some(pending) = self.pending_child.take() {
self.child_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
let Some(child_id) = self.child_id else {
return vec![];
};
let progress = ctx.animated_signal(1.0);
self.progress = Some(progress.clone());
let id = ctx.self_id();
let registry = ctx.binding_registry();
progress.bind_to(id, registry, BindingLevel::Relayout);
if ctx.prefers_reduced_motion() {
return vec![child_id];
}
let duration = self.duration.unwrap_or(ctx.theme().motion.duration_slow);
let progress_for_effect = progress;
ctx.effect(&self.trigger, move |_| {
progress_for_effect.set(0.0);
progress_for_effect.animate_to(1.0, duration, Easing::Linear);
});
vec![child_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let Some(child_id) = self.child_id else {
return (proposal.resolve(0.0, 0.0)).into();
};
let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
self.natural_size.set(natural);
natural.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
let t = self
.progress
.as_ref()
.map(|s| s.get().clamp(0.0, 1.0))
.unwrap_or(1.0);
let dx = if t >= 1.0 {
0.0
} else {
let envelope = 1.0 - t;
let phase = t * self.cycles * std::f32::consts::TAU;
self.amplitude * envelope * phase.sin()
};
let natural = self.natural_size.get();
for child in children.iter_mut() {
child.origin = Point::new(bounds.x + dx, bounds.y);
child.size = natural;
}
}
fn clips_children(&self) -> bool {
true
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
fn children(&self) -> Vec<WidgetId> {
self.child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::TextWidget;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn shake_starts_at_rest() {
let trigger = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Shake::new(trigger).child(TextWidget::new(lit!("oops"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
assert!(
!tree.has_active_animations(),
"no animation until the trigger is bumped"
);
}
#[test]
fn bumping_trigger_starts_shake() {
let trigger = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
trigger.set(1);
tree.tick_animations(Duration::from_millis(50));
assert!(
tree.has_active_animations(),
"shake should be in flight after trigger bump"
);
}
#[test]
fn shake_completes() {
let trigger = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
trigger.set(1);
tree.tick_animations(Duration::from_millis(600));
assert!(
!tree.has_active_animations(),
"shake should have completed after its duration"
);
}
#[test]
fn reduced_motion_swallows_trigger() {
let trigger = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_accessibility_preferences(false, true, 1.0);
tree.add(Shake::new(trigger.clone()).child(TextWidget::new(lit!("oops"))));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
trigger.set(1);
assert!(
!tree.has_active_animations(),
"reduced-motion path must not register animations"
);
}
}