Skip to main content

ftui_widgets/
timer.rs

1#![forbid(unsafe_code)]
2
3//! Countdown timer widget.
4//!
5//! Provides a [`Timer`] widget that renders remaining time, and a
6//! [`TimerState`] that counts down from a set duration with start/stop/reset
7//! semantics.
8//!
9//! # Example
10//!
11//! ```rust
12//! use ftui_widgets::timer::{Timer, TimerState};
13//! use std::time::Duration;
14//!
15//! let mut state = TimerState::new(Duration::from_secs(60));
16//! assert_eq!(state.remaining(), Duration::from_secs(60));
17//! assert!(!state.finished());
18//!
19//! state.start();
20//! state.tick(Duration::from_secs(1));
21//! assert_eq!(state.remaining(), Duration::from_secs(59));
22//! ```
23
24use crate::{StatefulWidget, Widget, draw_text_span};
25use ftui_core::geometry::Rect;
26use ftui_render::frame::Frame;
27use ftui_style::Style;
28
29// Re-use format types from stopwatch.
30pub use crate::stopwatch::StopwatchFormat as TimerFormat;
31
32/// State for the countdown timer.
33#[derive(Debug, Clone)]
34pub struct TimerState {
35    duration: std::time::Duration,
36    remaining: std::time::Duration,
37    running: bool,
38}
39
40impl TimerState {
41    /// Creates a new timer with the given countdown duration, initially stopped.
42    pub fn new(duration: std::time::Duration) -> Self {
43        Self {
44            duration,
45            remaining: duration,
46            running: false,
47        }
48    }
49
50    /// Returns the original countdown duration.
51    #[inline]
52    pub fn duration(&self) -> std::time::Duration {
53        self.duration
54    }
55
56    /// Returns the remaining time.
57    #[inline]
58    pub fn remaining(&self) -> std::time::Duration {
59        self.remaining
60    }
61
62    /// Returns whether the timer is currently running.
63    #[inline]
64    pub fn running(&self) -> bool {
65        self.running && !self.finished()
66    }
67
68    /// Returns whether the timer has reached zero.
69    #[inline]
70    pub fn finished(&self) -> bool {
71        self.remaining.is_zero()
72    }
73
74    /// Starts the timer.
75    pub fn start(&mut self) {
76        self.running = true;
77    }
78
79    /// Stops (pauses) the timer.
80    pub fn stop(&mut self) {
81        self.running = false;
82    }
83
84    /// Toggles between running and stopped.
85    pub fn toggle(&mut self) {
86        self.running = !self.running;
87    }
88
89    /// Resets the timer to its original duration. Does not change running state.
90    pub fn reset(&mut self) {
91        self.remaining = self.duration;
92    }
93
94    /// Sets a new countdown duration and resets remaining time.
95    pub fn set_duration(&mut self, duration: std::time::Duration) {
96        self.duration = duration;
97        self.remaining = duration;
98    }
99
100    /// Subtracts delta from remaining time if running.
101    /// Returns `true` if the tick was applied.
102    pub fn tick(&mut self, delta: std::time::Duration) -> bool {
103        if self.running && !self.finished() {
104            self.remaining = self.remaining.saturating_sub(delta);
105            true
106        } else {
107            false
108        }
109    }
110}
111
112/// A widget that displays remaining time from a [`TimerState`].
113#[derive(Debug, Clone, Default)]
114pub struct Timer<'a> {
115    format: TimerFormat,
116    style: Style,
117    running_style: Option<Style>,
118    finished_style: Option<Style>,
119    label: Option<&'a str>,
120}
121
122impl<'a> Timer<'a> {
123    /// Creates a new timer widget with default settings.
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Sets the display format.
129    #[must_use]
130    pub fn format(mut self, format: TimerFormat) -> Self {
131        self.format = format;
132        self
133    }
134
135    /// Sets the base style.
136    #[must_use]
137    pub fn style(mut self, style: Style) -> Self {
138        self.style = style;
139        self
140    }
141
142    /// Sets a style override used while the timer is running.
143    #[must_use]
144    pub fn running_style(mut self, style: Style) -> Self {
145        self.running_style = Some(style);
146        self
147    }
148
149    /// Sets a style override used when the timer has finished.
150    #[must_use]
151    pub fn finished_style(mut self, style: Style) -> Self {
152        self.finished_style = Some(style);
153        self
154    }
155
156    /// Sets an optional label rendered before the time.
157    #[must_use]
158    pub fn label(mut self, label: &'a str) -> Self {
159        self.label = Some(label);
160        self
161    }
162}
163
164impl StatefulWidget for Timer<'_> {
165    type State = TimerState;
166
167    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
168        if area.is_empty() || area.height == 0 {
169            return;
170        }
171
172        let deg = frame.buffer.degradation;
173        if !deg.render_content() {
174            return;
175        }
176
177        let style = if deg.apply_styling() {
178            if state.finished() {
179                self.finished_style.unwrap_or(self.style)
180            } else if state.running() {
181                self.running_style.unwrap_or(self.style)
182            } else {
183                self.style
184            }
185        } else {
186            Style::default()
187        };
188
189        let formatted = crate::stopwatch::format_duration(state.remaining, self.format);
190        let mut x = area.x;
191
192        if let Some(label) = self.label {
193            x = draw_text_span(frame, x, area.y, label, style, area.right());
194            if x < area.right() {
195                x = draw_text_span(frame, x, area.y, " ", style, area.right());
196            }
197        }
198
199        draw_text_span(frame, x, area.y, &formatted, style, area.right());
200    }
201}
202
203impl Widget for Timer<'_> {
204    fn render(&self, area: Rect, frame: &mut Frame) {
205        let mut state = TimerState::new(std::time::Duration::ZERO);
206        StatefulWidget::render(self, area, frame, &mut state);
207    }
208
209    fn is_essential(&self) -> bool {
210        true
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use ftui_render::buffer::Buffer;
218    use ftui_render::grapheme_pool::GraphemePool;
219    use std::time::Duration;
220
221    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
222        buf.get(x, y).and_then(|c| c.content.as_char())
223    }
224
225    fn render_to_string(widget: &Timer, state: &mut TimerState, width: u16) -> String {
226        let mut pool = GraphemePool::new();
227        let mut frame = Frame::new(width, 1, &mut pool);
228        let area = Rect::new(0, 0, width, 1);
229        StatefulWidget::render(widget, area, &mut frame, state);
230        (0..width)
231            .filter_map(|x| cell_char(&frame.buffer, x, 0))
232            .collect::<String>()
233            .trim_end()
234            .to_string()
235    }
236
237    // --- TimerState tests ---
238
239    #[test]
240    fn state_new() {
241        let state = TimerState::new(Duration::from_secs(60));
242        assert_eq!(state.duration(), Duration::from_secs(60));
243        assert_eq!(state.remaining(), Duration::from_secs(60));
244        assert!(!state.running());
245        assert!(!state.finished());
246    }
247
248    #[test]
249    fn state_start_stop() {
250        let mut state = TimerState::new(Duration::from_secs(10));
251        state.start();
252        assert!(state.running());
253        state.stop();
254        assert!(!state.running());
255    }
256
257    #[test]
258    fn state_toggle() {
259        let mut state = TimerState::new(Duration::from_secs(10));
260        state.toggle();
261        assert!(state.running());
262        state.toggle();
263        assert!(!state.running());
264    }
265
266    #[test]
267    fn state_tick_counts_down() {
268        let mut state = TimerState::new(Duration::from_secs(10));
269        state.start();
270        assert!(state.tick(Duration::from_secs(3)));
271        assert_eq!(state.remaining(), Duration::from_secs(7));
272    }
273
274    #[test]
275    fn state_tick_when_stopped_is_noop() {
276        let mut state = TimerState::new(Duration::from_secs(10));
277        assert!(!state.tick(Duration::from_secs(1)));
278        assert_eq!(state.remaining(), Duration::from_secs(10));
279    }
280
281    #[test]
282    fn state_tick_saturates_at_zero() {
283        let mut state = TimerState::new(Duration::from_secs(2));
284        state.start();
285        state.tick(Duration::from_secs(5));
286        assert_eq!(state.remaining(), Duration::ZERO);
287        assert!(state.finished());
288    }
289
290    #[test]
291    fn state_finished_stops_running() {
292        let mut state = TimerState::new(Duration::from_secs(1));
293        state.start();
294        state.tick(Duration::from_secs(1));
295        assert!(state.finished());
296        assert!(!state.running()); // running() returns false when finished
297    }
298
299    #[test]
300    fn state_tick_after_finished_is_noop() {
301        let mut state = TimerState::new(Duration::from_secs(1));
302        state.start();
303        state.tick(Duration::from_secs(1));
304        assert!(!state.tick(Duration::from_secs(1)));
305        assert_eq!(state.remaining(), Duration::ZERO);
306    }
307
308    #[test]
309    fn state_reset() {
310        let mut state = TimerState::new(Duration::from_secs(60));
311        state.start();
312        state.tick(Duration::from_secs(30));
313        state.reset();
314        assert_eq!(state.remaining(), Duration::from_secs(60));
315    }
316
317    #[test]
318    fn state_set_duration() {
319        let mut state = TimerState::new(Duration::from_secs(60));
320        state.start();
321        state.tick(Duration::from_secs(10));
322        state.set_duration(Duration::from_secs(120));
323        assert_eq!(state.duration(), Duration::from_secs(120));
324        assert_eq!(state.remaining(), Duration::from_secs(120));
325    }
326
327    #[test]
328    fn state_zero_duration_is_finished() {
329        let state = TimerState::new(Duration::ZERO);
330        assert!(state.finished());
331        assert!(!state.running());
332    }
333
334    // --- Widget rendering tests ---
335
336    #[test]
337    fn render_zero_area() {
338        let widget = Timer::new();
339        let area = Rect::new(0, 0, 0, 0);
340        let mut pool = GraphemePool::new();
341        let mut frame = Frame::new(1, 1, &mut pool);
342        let mut state = TimerState::new(Duration::from_secs(60));
343        StatefulWidget::render(&widget, area, &mut frame, &mut state);
344    }
345
346    #[test]
347    fn render_remaining_human() {
348        let widget = Timer::new();
349        let mut state = TimerState::new(Duration::from_secs(125));
350        let text = render_to_string(&widget, &mut state, 20);
351        assert_eq!(text, "2m5s");
352    }
353
354    #[test]
355    fn render_digital_format() {
356        let widget = Timer::new().format(TimerFormat::Digital);
357        let mut state = TimerState::new(Duration::from_secs(3665));
358        let text = render_to_string(&widget, &mut state, 20);
359        assert_eq!(text, "01:01:05");
360    }
361
362    #[test]
363    fn render_seconds_format() {
364        let widget = Timer::new().format(TimerFormat::Seconds);
365        let mut state = TimerState::new(Duration::from_secs(90));
366        let text = render_to_string(&widget, &mut state, 20);
367        assert_eq!(text, "90s");
368    }
369
370    #[test]
371    fn render_with_label() {
372        let widget = Timer::new().label("Remaining:");
373        let mut state = TimerState::new(Duration::from_secs(45));
374        let text = render_to_string(&widget, &mut state, 30);
375        assert_eq!(text, "Remaining: 45s");
376    }
377
378    #[test]
379    fn render_finished_shows_zero() {
380        let widget = Timer::new();
381        let mut state = TimerState::new(Duration::from_secs(1));
382        state.start();
383        state.tick(Duration::from_secs(1));
384        let text = render_to_string(&widget, &mut state, 20);
385        assert_eq!(text, "0s");
386    }
387
388    #[test]
389    fn stateless_render_shows_zero() {
390        let widget = Timer::new();
391        let area = Rect::new(0, 0, 10, 1);
392        let mut pool = GraphemePool::new();
393        let mut frame = Frame::new(10, 1, &mut pool);
394        Widget::render(&widget, area, &mut frame);
395        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
396        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
397    }
398
399    #[test]
400    fn is_essential() {
401        let widget = Timer::new();
402        assert!(widget.is_essential());
403    }
404
405    // --- Degradation tests ---
406
407    #[test]
408    fn degradation_skeleton_skips() {
409        use ftui_render::budget::DegradationLevel;
410
411        let widget = Timer::new();
412        let area = Rect::new(0, 0, 20, 1);
413        let mut pool = GraphemePool::new();
414        let mut frame = Frame::new(20, 1, &mut pool);
415        frame.buffer.degradation = DegradationLevel::Skeleton;
416        let mut state = TimerState::new(Duration::from_secs(60));
417        StatefulWidget::render(&widget, area, &mut frame, &mut state);
418        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
419    }
420
421    // --- Countdown progression test ---
422
423    #[test]
424    fn countdown_progression() {
425        let mut state = TimerState::new(Duration::from_secs(5));
426        state.start();
427
428        for expected in (0..=4).rev() {
429            state.tick(Duration::from_secs(1));
430            assert_eq!(state.remaining(), Duration::from_secs(expected));
431        }
432
433        assert!(state.finished());
434    }
435}