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 teksilo_core::IntoTeksiChild) -> Self {
89 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
90 self
91 }
92 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
99 match widget {
100 Some(w) => self.child(w),
101 None => self,
102 }
103 }
104}
105
106impl std::fmt::Debug for Pulse {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("Pulse")
109 .field("min", &self.min)
110 .field("max", &self.max)
111 .field("period", &self.period)
112 .finish()
113 }
114}
115
116impl Widget for Pulse {
117 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
118 if let Some(pending) = self.pending_child.take() {
119 self.child_id = Some(match pending {
120 PendingChild::Id(id) => id,
121 PendingChild::Deferred(w) => ctx.add_boxed(w),
122 });
123 }
124 let Some(child_id) = self.child_id else {
125 return vec![];
126 };
127
128 let mid = (self.min + self.max) * 0.5;
129 let opacity = ctx.signal(mid);
130 let id = ctx.self_id();
131 ctx.set_opacity(id, opacity.clone());
132
133 if ctx.prefers_reduced_motion() {
137 return vec![child_id];
138 }
139
140 let period = self
149 .period
150 .unwrap_or(ctx.theme().motion.duration_indeterminate_sweep);
151 let period_secs = period.as_secs_f32().max(0.001);
152 let amp = (self.max - self.min) * 0.5;
153 let elapsed = Rc::new(Cell::new(0.0_f32));
154 let opacity_for_tick = opacity;
155 ctx.effect(&ctx.frame_tick(), move |&delta| {
156 let t = (elapsed.get() + delta) % period_secs;
157 elapsed.set(t);
158 let phase = (t / period_secs) * std::f32::consts::TAU;
159 let v = mid + amp * phase.sin();
160 opacity_for_tick.set(v);
161 });
162 self.frame_tick_sub = None;
166 self.frame_tick_sub = Some(ctx.subscribe_frame_tick());
167
168 vec![child_id]
169 }
170
171 fn layout_response(
172 &self,
173 proposal: SizeProposal,
174 ctx: &LayoutContext,
175 ) -> teksilo_core::widget::LayoutResponse {
176 self.child_id
177 .and_then(|id| ctx.child_size(id, proposal))
178 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
179 .into()
180 }
181
182 fn place_children(
183 &self,
184 bounds: Rect,
185 _proposal: SizeProposal,
186 children: &mut [WidgetPlacement],
187 _ctx: &LayoutContext,
188 ) {
189 for child in children.iter_mut() {
190 child.origin = Point::new(bounds.x, bounds.y);
191 child.size = bounds.size();
192 }
193 }
194
195 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
196 }
198
199 fn children(&self) -> Vec<WidgetId> {
200 self.child_id.into_iter().collect()
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::primitives::TextWidget;
208 use teksilo_core::widget_tree::WidgetTree;
209 use teksilo_i18n::lit;
210
211 #[test]
212 fn pulse_starts_at_midpoint() {
213 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
216 tree.add(Pulse::opacity(0.2, 1.0).child(TextWidget::new(lit!("●"))));
217 tree.layout(SizeProposal::exact(100.0, 50.0));
218 let frame = tree.render();
219 let ops: Vec<f32> = frame
220 .draw_order
221 .iter()
222 .filter_map(|c| match c {
223 teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
224 _ => None,
225 })
226 .collect();
227 assert_eq!(ops.len(), 1);
228 assert!(
231 (ops[0] - 0.6).abs() < 0.5,
232 "opacity should start near midpoint 0.6, got {}",
233 ops[0]
234 );
235 }
236
237 #[test]
238 fn pulse_pins_to_midpoint_under_reduced_motion() {
239 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
240 tree.set_accessibility_preferences(false, true, 1.0);
241 tree.add(Pulse::opacity(0.0, 1.0).child(TextWidget::new(lit!("●"))));
242 tree.layout(SizeProposal::exact(100.0, 50.0));
243 let frame = tree.render();
244 let ops: Vec<f32> = frame
245 .draw_order
246 .iter()
247 .filter_map(|c| match c {
248 teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
249 _ => None,
250 })
251 .collect();
252 assert_eq!(ops.len(), 1);
253 assert!(
254 (ops[0] - 0.5).abs() < 1e-3,
255 "reduced-motion opacity should be pinned at midpoint 0.5, got {}",
256 ops[0]
257 );
258 assert!(
259 !tree.has_active_animations(),
260 "reduced-motion path must not register animations"
261 );
262 }
263
264 #[test]
265 fn pulse_does_not_change_layout() {
266 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
267 let id = tree.add(Pulse::opacity(0.0, 1.0).child(TextWidget::new(lit!("hello"))));
268 tree.layout(SizeProposal {
269 width: Some(300.0),
270 height: None,
271 });
272 let bounds_initial = tree.bounds(id);
273 tree.layout(SizeProposal {
274 width: Some(300.0),
275 height: None,
276 });
277 let bounds_again = tree.bounds(id);
278 assert_eq!(bounds_initial.size(), bounds_again.size());
279 }
280
281 #[test]
282 fn pulse_clamps_inverted_min_max() {
283 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
285 tree.add(Pulse::opacity(0.9, 0.1).child(TextWidget::new(lit!("●"))));
286 tree.layout(SizeProposal::exact(100.0, 50.0));
287 let _ = tree.render();
288 }
290}