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