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
79 pub fn child_boxed(mut self, widget: Box<dyn Widget>) -> Self {
81 self.deferred_children.push(widget);
82 self
83 }
84
85 pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
87 for w in iter {
88 self.deferred_children.push(Box::new(w));
89 }
90 self
91 }
92}
93
94impl Default for Cycle {
95 fn default() -> Self {
96 Self::new()
97 }
98}
99
100impl std::fmt::Debug for Cycle {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("Cycle")
103 .field("period", &self.period)
104 .field("num_children", &self.deferred_children.len())
105 .finish()
106 }
107}
108
109impl Widget for Cycle {
110 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
111 let children = std::mem::take(&mut self.deferred_children);
112 let n = children.len();
113 let selected = Signal::new(0_usize);
114
115 let mut switcher = Switcher::new(selected.clone());
116 for child in children {
117 switcher = switcher.child_boxed(child);
118 }
119 let root = ctx.add(switcher);
120 self.root_child_id = Some(root);
121
122 if ctx.prefers_reduced_motion() || n <= 1 {
125 return vec![root];
126 }
127
128 let period = self.period;
144 let last_advance: Rc<Cell<Option<Instant>>> = Rc::new(Cell::new(None));
145 let selected_for_tick = selected;
146 ctx.effect(&ctx.frame_tick(), move |_delta| {
147 let now = Instant::now();
148 match last_advance.get() {
149 None => last_advance.set(Some(now)),
151 Some(prev) if now.duration_since(prev) >= period => {
152 let next = (selected_for_tick.get() + 1) % n;
153 selected_for_tick.set(next);
154 last_advance.set(Some(now));
155 }
156 Some(_) => {}
157 }
158 });
159 self.frame_tick_sub = None;
160 self.frame_tick_sub = Some(ctx.subscribe_frame_tick_throttled(period));
161
162 vec![root]
163 }
164
165 fn layout_response(
166 &self,
167 proposal: SizeProposal,
168 ctx: &LayoutContext,
169 ) -> teksilo_core::widget::LayoutResponse {
170 self.root_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 = bounds.origin();
185 child.size = bounds.size();
186 }
187 }
188
189 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
190 }
193
194 fn children(&self) -> Vec<WidgetId> {
195 self.root_child_id.into_iter().collect()
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::primitives::TextWidget;
203 use teksilo_core::widget_tree::WidgetTree;
204 use teksilo_i18n::lit;
205
206 #[test]
207 fn cycle_builds_with_children() {
208 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
209 let id = tree.add(
210 Cycle::new()
211 .child(TextWidget::new(lit!("A")))
212 .child(TextWidget::new(lit!("B")))
213 .child(TextWidget::new(lit!("C"))),
214 );
215 tree.layout(SizeProposal::exact(200.0, 100.0));
216 let b = tree.bounds(id);
217 assert!(b.width > 0.0);
218 }
219
220 #[test]
221 fn empty_cycle_is_safe() {
222 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
223 tree.add(Cycle::new());
224 tree.layout(SizeProposal::exact(100.0, 50.0));
225 let _ = tree.render();
226 }
227
228 #[test]
229 fn single_child_cycle_does_not_animate() {
230 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
231 tree.add(Cycle::new().child(TextWidget::new(lit!("only"))));
232 tree.layout(SizeProposal::exact(200.0, 100.0));
233 let _ = tree.render();
234 assert!(
235 !tree.has_active_animations(),
236 "single-child cycle should not start a timer"
237 );
238 }
239
240 #[test]
241 fn reduced_motion_pins_first_child() {
242 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
243 tree.set_accessibility_preferences(false, true, 1.0);
244 tree.add(
245 Cycle::new()
246 .child(TextWidget::new(lit!("A")))
247 .child(TextWidget::new(lit!("B"))),
248 );
249 tree.layout(SizeProposal::exact(200.0, 100.0));
250 let _ = tree.render();
251 assert!(
252 !tree.has_active_animations(),
253 "reduced-motion path must not register animations"
254 );
255 }
256}