Skip to main content

teksilo_widgets/animations/
cycle.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Cycle` — show one of N children at a time, advancing on a fixed
5//! period. The "rotating loading tip" / status display pattern.
6//!
7//! ```ignore
8//! ctx.add(
9//!     Cycle::new()
10//!         .period(Duration::from_secs(3))
11//!         .child(TextWidget::new(lit!("Tip: press Cmd-K to search")))
12//!         .child(TextWidget::new(lit!("Tip: hold Shift to multi-select")))
13//!         .child(TextWidget::new(lit!("Tip: drag the divider to resize"))),
14//! );
15//! ```
16//!
17//! Internally a [`Switcher`] whose
18//! `Signal<usize>` index is incremented by a per-frame effect.
19//! Children share a `ZStack` slot — at any given moment only the
20//! selected child is visible (others are dormant).
21//!
22//! ## Reduced motion
23//!
24//! Honours `prefers-reduced-motion`: pins on the first child and
25//! does not install the timer driver. Subsequent children are still
26//! built (so widget construction is identical) but are never shown.
27
28use 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
44/// A wrapper that cycles through its children on a fixed period.
45pub struct Cycle {
46    period: Duration,
47    deferred_children: Vec<Box<dyn Widget>>,
48    root_child_id: Option<WidgetId>,
49    /// RAII guard for the per-frame-effect subscription. See
50    /// [`Pulse::frame_tick_sub`](super::pulse::Pulse) for the same
51    /// pattern.
52    frame_tick_sub: Option<FrameTickSubscription>,
53}
54
55impl Cycle {
56    /// New cycle with default 3 s period.
57    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    /// Step interval — how long each child is visible before
67    /// advancing to the next. Default 3 s.
68    pub fn period(mut self, period: Duration) -> Self {
69        self.period = period;
70        self
71    }
72
73    /// Append a child to the rotation.
74    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
75        self.deferred_children.push(Box::new(widget));
76        self
77    }
78
79    /// Append a pre-boxed child to the rotation.
80    pub fn child_boxed(mut self, widget: Box<dyn Widget>) -> Self {
81        self.deferred_children.push(widget);
82        self
83    }
84
85    /// Append children from an iterator.
86    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        // Reduced-motion or trivial case (≤1 child): no timer, sticks
123        // on the first child.
124        if ctx.prefers_reduced_motion() || n <= 1 {
125            return vec![root];
126        }
127
128        // Discrete index advance. Cycle only changes its visible child
129        // once per period, so it subscribes *throttled* rather than
130        // per-frame: the event loop sleeps to the period deadline instead
131        // of rendering ~90 identical frames per boundary at 60 Hz. The
132        // visibility gate is unchanged — a Cycle parked in a non-selected
133        // `Switcher` branch (or an off-screen tab) still ticks zero times,
134        // and resumes when shown again (its `last_advance` clock keeps
135        // running on real time, so it advances on the first wake past the
136        // next boundary).
137        //
138        // Timing is absolute (`Instant::now`) rather than accumulated
139        // frame deltas: at the throttled cadence the tree's per-frame
140        // delta is clamped to 0.1 s (a spike guard) and cannot measure a
141        // multi-second period. Absolute time is also self-correcting when
142        // a wake lands late.
143        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                // First tick after (re)build: start the clock, don't jump.
150                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        // Visual rotator. The active child owns its own a11y; the
191        // wrapper is a11y-transparent.
192    }
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}