teksilo_widgets/animations/
pulse.rs1use std::cell::Cell;
33use std::rc::Rc;
34use std::time::Duration;
35
36use teksilo_canvas::{Point, Rect, SizeProposal};
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::frame_tick_scheduler::FrameTickSubscription;
40use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
41use teksilo_core::widget_id::WidgetId;
42
43pub struct Pulse {
47 min: f32,
48 max: f32,
49 period: Option<Duration>,
52 pending_child: Option<PendingChild>,
53 child_id: Option<WidgetId>,
54 frame_tick_sub: Option<FrameTickSubscription>,
59}
60
61impl Pulse {
62 pub fn opacity(min: f32, max: f32) -> Self {
66 let lo = min.clamp(0.0, 1.0).min(max.clamp(0.0, 1.0));
67 let hi = min.clamp(0.0, 1.0).max(max.clamp(0.0, 1.0));
68 Self {
69 min: lo,
70 max: hi,
71 period: None,
72 pending_child: None,
73 child_id: None,
74 frame_tick_sub: None,
75 }
76 }
77
78 pub fn period(mut self, period: Duration) -> Self {
83 self.period = Some(period);
84 self
85 }
86
87 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
89 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
90 self
91 }
92
93 pub fn child_id(mut self, id: WidgetId) -> Self {
95 self.pending_child = Some(PendingChild::Id(id));
96 self
97 }
98}
99
100impl std::fmt::Debug for Pulse {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("Pulse")
103 .field("min", &self.min)
104 .field("max", &self.max)
105 .field("period", &self.period)
106 .finish()
107 }
108}
109
110impl Widget for Pulse {
111 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
112 if let Some(pending) = self.pending_child.take() {
113 self.child_id = Some(match pending {
114 PendingChild::Id(id) => id,
115 PendingChild::Deferred(w) => ctx.add_boxed(w),
116 });
117 }
118 let Some(child_id) = self.child_id else {
119 return vec![];
120 };
121
122 let mid = (self.min + self.max) * 0.5;
123 let opacity = ctx.signal(mid);
124 let id = ctx.self_id();
125 ctx.set_opacity(id, opacity.clone());
126
127 if ctx.prefers_reduced_motion() {
131 return vec![child_id];
132 }
133
134 let period = self
143 .period
144 .unwrap_or(ctx.theme().motion.duration_indeterminate_sweep);
145 let period_secs = period.as_secs_f32().max(0.001);
146 let amp = (self.max - self.min) * 0.5;
147 let elapsed = Rc::new(Cell::new(0.0_f32));
148 let opacity_for_tick = opacity;
149 ctx.effect(&ctx.frame_tick(), move |&delta| {
150 let t = (elapsed.get() + delta) % period_secs;
151 elapsed.set(t);
152 let phase = (t / period_secs) * std::f32::consts::TAU;
153 let v = mid + amp * phase.sin();
154 opacity_for_tick.set(v);
155 });
156 self.frame_tick_sub = None;
160 self.frame_tick_sub = Some(ctx.subscribe_frame_tick());
161
162 vec![child_id]
163 }
164
165 fn layout_response(
166 &self,
167 proposal: SizeProposal,
168 ctx: &LayoutContext,
169 ) -> teksilo_core::widget::LayoutResponse {
170 self.child_id
171 .and_then(|id| ctx.child_size(id, proposal))
172 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
173 .into()
174 }
175
176 fn place_children(
177 &self,
178 bounds: Rect,
179 _proposal: SizeProposal,
180 children: &mut [WidgetPlacement],
181 _ctx: &LayoutContext,
182 ) {
183 for child in children.iter_mut() {
184 child.origin = Point::new(bounds.x, bounds.y);
185 child.size = bounds.size();
186 }
187 }
188
189 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
190 }
192
193 fn children(&self) -> Vec<WidgetId> {
194 self.child_id.into_iter().collect()
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::primitives::TextWidget;
202 use teksilo_core::widget_tree::WidgetTree;
203 use teksilo_i18n::lit;
204
205 #[test]
206 fn pulse_starts_at_midpoint() {
207 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
210 tree.add(Pulse::opacity(0.2, 1.0).child(TextWidget::new(lit!("●"))));
211 tree.layout(SizeProposal::exact(100.0, 50.0));
212 let frame = tree.render();
213 let ops: Vec<f32> = frame
214 .draw_order
215 .iter()
216 .filter_map(|c| match c {
217 teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
218 _ => None,
219 })
220 .collect();
221 assert_eq!(ops.len(), 1);
222 assert!(
225 (ops[0] - 0.6).abs() < 0.5,
226 "opacity should start near midpoint 0.6, got {}",
227 ops[0]
228 );
229 }
230
231 #[test]
232 fn pulse_pins_to_midpoint_under_reduced_motion() {
233 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
234 tree.set_accessibility_preferences(false, true, 1.0);
235 tree.add(Pulse::opacity(0.0, 1.0).child(TextWidget::new(lit!("●"))));
236 tree.layout(SizeProposal::exact(100.0, 50.0));
237 let frame = tree.render();
238 let ops: Vec<f32> = frame
239 .draw_order
240 .iter()
241 .filter_map(|c| match c {
242 teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
243 _ => None,
244 })
245 .collect();
246 assert_eq!(ops.len(), 1);
247 assert!(
248 (ops[0] - 0.5).abs() < 1e-3,
249 "reduced-motion opacity should be pinned at midpoint 0.5, got {}",
250 ops[0]
251 );
252 assert!(
253 !tree.has_active_animations(),
254 "reduced-motion path must not register animations"
255 );
256 }
257
258 #[test]
259 fn pulse_does_not_change_layout() {
260 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
261 let id = tree.add(Pulse::opacity(0.0, 1.0).child(TextWidget::new(lit!("hello"))));
262 tree.layout(SizeProposal {
263 width: Some(300.0),
264 height: None,
265 });
266 let bounds_initial = tree.bounds(id);
267 tree.layout(SizeProposal {
268 width: Some(300.0),
269 height: None,
270 });
271 let bounds_again = tree.bounds(id);
272 assert_eq!(bounds_initial.size(), bounds_again.size());
273 }
274
275 #[test]
276 fn pulse_clamps_inverted_min_max() {
277 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
279 tree.add(Pulse::opacity(0.9, 0.1).child(TextWidget::new(lit!("●"))));
280 tree.layout(SizeProposal::exact(100.0, 50.0));
281 let _ = tree.render();
282 }
284}