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    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
79    ///
80    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
81    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
82    /// adds no arena node, so nothing is laid out, painted, or published to the
83    /// accessibility tree, and a stack applies no spacing around it.
84    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    /// Append children from an iterator.
92    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        // Reduced-motion or trivial case (≤1 child): no timer, sticks
129        // on the first child.
130        if ctx.prefers_reduced_motion() || n <= 1 {
131            return vec![root];
132        }
133
134        // Discrete index advance. Cycle only changes its visible child
135        // once per period, so it subscribes *throttled* rather than
136        // per-frame: the event loop sleeps to the period deadline instead
137        // of rendering ~90 identical frames per boundary at 60 Hz. The
138        // visibility gate is unchanged — a Cycle parked in a non-selected
139        // `Switcher` branch (or an off-screen tab) still ticks zero times,
140        // and resumes when shown again (its `last_advance` clock keeps
141        // running on real time, so it advances on the first wake past the
142        // next boundary).
143        //
144        // Timing is absolute (`Instant::now`) rather than accumulated
145        // frame deltas: at the throttled cadence the tree's per-frame
146        // delta is clamped to 0.1 s (a spike guard) and cannot measure a
147        // multi-second period. Absolute time is also self-correcting when
148        // a wake lands late.
149        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                // First tick after (re)build: start the clock, don't jump.
156                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        // Visual rotator. The active child owns its own a11y; the
197        // wrapper is a11y-transparent.
198    }
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}