teksilo_widgets/animations/
cycle.rs1use std::cell::Cell;
29use std::rc::Rc;
30use std::time::{Duration, Instant};
31
32use teksilo_canvas::{Rect, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::build_context::BuildContext;
35use teksilo_core::frame_tick_scheduler::FrameTickSubscription;
36use teksilo_core::signal::Signal;
37use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
38use teksilo_core::widget_id::WidgetId;
39
40use crate::primitives::Switcher;
41
42const DEFAULT_PERIOD: Duration = Duration::from_secs(3);
43
44pub struct Cycle {
46 period: Duration,
47 deferred_children: Vec<Box<dyn Widget>>,
48 root_child_id: Option<WidgetId>,
49 frame_tick_sub: Option<FrameTickSubscription>,
53}
54
55impl Cycle {
56 pub fn new() -> Self {
58 Self {
59 period: DEFAULT_PERIOD,
60 deferred_children: Vec::new(),
61 root_child_id: None,
62 frame_tick_sub: None,
63 }
64 }
65
66 pub fn period(mut self, period: Duration) -> Self {
69 self.period = period;
70 self
71 }
72
73 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
75 self.deferred_children.push(Box::new(widget));
76 self
77 }
78 pub fn child_opt(self, widget: Option<impl Widget + 'static>) -> Self {
85 match widget {
86 Some(w) => self.child(w),
87 None => self,
88 }
89 }
90
91 pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
93 for w in iter {
94 self.deferred_children.push(Box::new(w));
95 }
96 self
97 }
98}
99
100impl Default for Cycle {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl std::fmt::Debug for Cycle {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("Cycle")
109 .field("period", &self.period)
110 .field("num_children", &self.deferred_children.len())
111 .finish()
112 }
113}
114
115impl Widget for Cycle {
116 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
117 let children = std::mem::take(&mut self.deferred_children);
118 let n = children.len();
119 let selected = Signal::new(0_usize);
120
121 let mut switcher = Switcher::new(selected.clone());
122 for child in children {
123 switcher = switcher.child(child);
124 }
125 let root = ctx.add(switcher);
126 self.root_child_id = Some(root);
127
128 if ctx.prefers_reduced_motion() || n <= 1 {
131 return vec![root];
132 }
133
134 let period = self.period;
150 let last_advance: Rc<Cell<Option<Instant>>> = Rc::new(Cell::new(None));
151 let selected_for_tick = selected;
152 ctx.effect(&ctx.frame_tick(), move |_delta| {
153 let now = Instant::now();
154 match last_advance.get() {
155 None => last_advance.set(Some(now)),
157 Some(prev) if now.duration_since(prev) >= period => {
158 let next = (selected_for_tick.get() + 1) % n;
159 selected_for_tick.set(next);
160 last_advance.set(Some(now));
161 }
162 Some(_) => {}
163 }
164 });
165 self.frame_tick_sub = None;
166 self.frame_tick_sub = Some(ctx.subscribe_frame_tick_throttled(period));
167
168 vec![root]
169 }
170
171 fn layout_response(
172 &self,
173 proposal: SizeProposal,
174 ctx: &LayoutContext,
175 ) -> teksilo_core::widget::LayoutResponse {
176 self.root_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 = bounds.origin();
191 child.size = bounds.size();
192 }
193 }
194
195 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
196 }
199
200 fn children(&self) -> Vec<WidgetId> {
201 self.root_child_id.into_iter().collect()
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::primitives::TextWidget;
209 use teksilo_core::widget_tree::WidgetTree;
210 use teksilo_i18n::lit;
211
212 #[test]
213 fn cycle_builds_with_children() {
214 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
215 let id = tree.add(
216 Cycle::new()
217 .child(TextWidget::new(lit!("A")))
218 .child(TextWidget::new(lit!("B")))
219 .child(TextWidget::new(lit!("C"))),
220 );
221 tree.layout(SizeProposal::exact(200.0, 100.0));
222 let b = tree.bounds(id);
223 assert!(b.width > 0.0);
224 }
225
226 #[test]
227 fn empty_cycle_is_safe() {
228 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
229 tree.add(Cycle::new());
230 tree.layout(SizeProposal::exact(100.0, 50.0));
231 let _ = tree.render();
232 }
233
234 #[test]
235 fn single_child_cycle_does_not_animate() {
236 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
237 tree.add(Cycle::new().child(TextWidget::new(lit!("only"))));
238 tree.layout(SizeProposal::exact(200.0, 100.0));
239 let _ = tree.render();
240 assert!(
241 !tree.has_active_animations(),
242 "single-child cycle should not start a timer"
243 );
244 }
245
246 #[test]
247 fn reduced_motion_pins_first_child() {
248 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
249 tree.set_accessibility_preferences(false, true, 1.0);
250 tree.add(
251 Cycle::new()
252 .child(TextWidget::new(lit!("A")))
253 .child(TextWidget::new(lit!("B"))),
254 );
255 tree.layout(SizeProposal::exact(200.0, 100.0));
256 let _ = tree.render();
257 assert!(
258 !tree.has_active_animations(),
259 "reduced-motion path must not register animations"
260 );
261 }
262}