Skip to main content

teksilo_widgets/animations/
pulse.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Pulse` — a wrapper widget that pulses its child's opacity between
5//! a `min` and `max` value on a fixed period, sine-shaped.
6//!
7//! The classic "blinking red light" / recording-indicator / attention
8//! beacon pattern. The wrapped subtree pulses smoothly (sine
9//! interpolation), giving a breathing-light feel rather than a hard
10//! on/off blink.
11//!
12//! ```ignore
13//! ctx.add(
14//!     Pulse::opacity(0.3, 1.0)
15//!         .period(Duration::from_millis(1200))
16//!         .child(RectWidget::new().background(Color::RED)),
17//! );
18//! ```
19//!
20//! ## Layout semantics
21//!
22//! Layout-transparent — the child reports its full natural size at
23//! all opacity values. Identical layout footprint to `Fade`.
24//!
25//! ## Reduced motion
26//!
27//! Honours `prefers-reduced-motion`: skips the per-frame driver and
28//! pins opacity at the midpoint `(min + max) / 2`. The subtree stays
29//! visible at a steady, non-distracting brightness so the indicator
30//! still communicates "active" without animating.
31
32use 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
43/// Wraps a child and pulses its opacity smoothly between `min` and
44/// `max` on a fixed period. Useful for recording indicators,
45/// notification beacons, and attention-grabbing status icons.
46pub struct Pulse {
47    min: f32,
48    max: f32,
49    /// `None` → fall back to `MotionTokens::duration_indeterminate_sweep`
50    /// at build time so theme-driven motion changes flow through.
51    period: Option<Duration>,
52    pending_child: Option<PendingChild>,
53    child_id: Option<WidgetId>,
54    /// RAII guard for the per-frame-effect subscription. Rebuilds
55    /// replace it (the old guard's `Drop` removes the previous entry
56    /// before the new one is registered); widget destruction drops it
57    /// transparently.
58    frame_tick_sub: Option<FrameTickSubscription>,
59}
60
61impl Pulse {
62    /// Wrap a subtree in an opacity pulse between `min` and `max`
63    /// (both clamped to `0..=1`). Uses a sine wave so the transitions
64    /// at both extremes are smooth, not abrupt.
65    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    /// Override the pulse period (full cycle min → max → min).
79    /// Default: `MotionTokens::duration_indeterminate_sweep` (~900 ms),
80    /// the same continuous-loop budget the indeterminate progress bar
81    /// and spinner use — so a re-themed motion stack stays consistent.
82    pub fn period(mut self, period: Duration) -> Self {
83        self.period = Some(period);
84        self
85    }
86
87    /// Inline child widget (deferred insertion).
88    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    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
93    ///
94    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
95    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
96    /// adds no arena node, so nothing is laid out, painted, or published to the
97    /// accessibility tree, and a stack applies no spacing around it.
98    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        // Reduced motion: pin at the midpoint and don't install the
134        // per-frame driver. The indicator remains visible (informative)
135        // but doesn't animate.
136        if ctx.prefers_reduced_motion() {
137            return vec![child_id];
138        }
139
140        // Sine-driven pulse via the frame tick. Each tick computes
141        // phase = (elapsed / period) * 2π, opacity = mid + amp*sin(phase).
142        // The framework auto-arms the frame chain after every render
143        // in which `self_id` was painted (see
144        // `BuildContext::subscribe_frame_tick`), so the chain dies
145        // automatically when this Pulse sits in a non-selected
146        // `Switcher` branch and resumes when it becomes visible
147        // again — no manual `frame_request.set(true)` re-arm needed.
148        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        // Replace any prior subscription (rebuild path) with a fresh
163        // one. Drop order matters: the old guard's `Drop` removes its
164        // entry before the new subscription is recorded.
165        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        // Visual-modulation wrapper. The child owns its own a11y.
197    }
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        // First layout pass, before any frame tick: opacity should be
214        // the midpoint between min and max.
215        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        // Midpoint = (0.2 + 1.0) / 2 = 0.6. Allow a small tolerance
229        // for any tick that happened during render's first frame.
230        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        // Pulse::opacity(0.9, 0.1) should still produce a valid range.
284        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        // Just confirm it didn't panic and produced a frame.
289    }
290}