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, clear_text_row, 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        // Timers are essential operational state, so they still render plain
174        // text in Skeleton mode instead of leaving stale or missing content.
175
176        let style = if deg.apply_styling() {
177            if state.finished() {
178                self.finished_style.unwrap_or(self.style)
179            } else if state.running() {
180                self.running_style.unwrap_or(self.style)
181            } else {
182                self.style
183            }
184        } else {
185            Style::default()
186        };
187
188        clear_text_row(frame, area, style);
189
190        let formatted = crate::stopwatch::format_duration(state.remaining, self.format);
191        let mut x = area.x;
192
193        if let Some(label) = self.label {
194            x = draw_text_span(frame, x, area.y, label, style, area.right());
195            if x < area.right() {
196                x = draw_text_span(frame, x, area.y, " ", style, area.right());
197            }
198        }
199
200        draw_text_span(frame, x, area.y, &formatted, style, area.right());
201    }
202}
203
204impl Widget for Timer<'_> {
205    fn render(&self, area: Rect, frame: &mut Frame) {
206        let mut state = TimerState::new(std::time::Duration::ZERO);
207        StatefulWidget::render(self, area, frame, &mut state);
208    }
209
210    fn is_essential(&self) -> bool {
211        true
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use ftui_render::buffer::Buffer;
219    use ftui_render::cell::Cell;
220    use ftui_render::grapheme_pool::GraphemePool;
221    use std::time::Duration;
222
223    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
224        buf.get(x, y).and_then(|c| c.content.as_char())
225    }
226
227    fn render_to_string(widget: &Timer, state: &mut TimerState, width: u16) -> String {
228        let mut pool = GraphemePool::new();
229        let mut frame = Frame::new(width, 1, &mut pool);
230        let area = Rect::new(0, 0, width, 1);
231        StatefulWidget::render(widget, area, &mut frame, state);
232        (0..width)
233            .filter_map(|x| cell_char(&frame.buffer, x, 0))
234            .collect::<String>()
235            .trim_end()
236            .to_string()
237    }
238
239    // --- TimerState tests ---
240
241    #[test]
242    fn state_new() {
243        let state = TimerState::new(Duration::from_secs(60));
244        assert_eq!(state.duration(), Duration::from_secs(60));
245        assert_eq!(state.remaining(), Duration::from_secs(60));
246        assert!(!state.running());
247        assert!(!state.finished());
248    }
249
250    #[test]
251    fn state_start_stop() {
252        let mut state = TimerState::new(Duration::from_secs(10));
253        state.start();
254        assert!(state.running());
255        state.stop();
256        assert!(!state.running());
257    }
258
259    #[test]
260    fn state_toggle() {
261        let mut state = TimerState::new(Duration::from_secs(10));
262        state.toggle();
263        assert!(state.running());
264        state.toggle();
265        assert!(!state.running());
266    }
267
268    #[test]
269    fn state_tick_counts_down() {
270        let mut state = TimerState::new(Duration::from_secs(10));
271        state.start();
272        assert!(state.tick(Duration::from_secs(3)));
273        assert_eq!(state.remaining(), Duration::from_secs(7));
274    }
275
276    #[test]
277    fn state_tick_when_stopped_is_noop() {
278        let mut state = TimerState::new(Duration::from_secs(10));
279        assert!(!state.tick(Duration::from_secs(1)));
280        assert_eq!(state.remaining(), Duration::from_secs(10));
281    }
282
283    #[test]
284    fn state_tick_saturates_at_zero() {
285        let mut state = TimerState::new(Duration::from_secs(2));
286        state.start();
287        state.tick(Duration::from_secs(5));
288        assert_eq!(state.remaining(), Duration::ZERO);
289        assert!(state.finished());
290    }
291
292    #[test]
293    fn state_finished_stops_running() {
294        let mut state = TimerState::new(Duration::from_secs(1));
295        state.start();
296        state.tick(Duration::from_secs(1));
297        assert!(state.finished());
298        assert!(!state.running()); // running() returns false when finished
299    }
300
301    #[test]
302    fn state_tick_after_finished_is_noop() {
303        let mut state = TimerState::new(Duration::from_secs(1));
304        state.start();
305        state.tick(Duration::from_secs(1));
306        assert!(!state.tick(Duration::from_secs(1)));
307        assert_eq!(state.remaining(), Duration::ZERO);
308    }
309
310    #[test]
311    fn state_reset() {
312        let mut state = TimerState::new(Duration::from_secs(60));
313        state.start();
314        state.tick(Duration::from_secs(30));
315        state.reset();
316        assert_eq!(state.remaining(), Duration::from_secs(60));
317    }
318
319    #[test]
320    fn state_set_duration() {
321        let mut state = TimerState::new(Duration::from_secs(60));
322        state.start();
323        state.tick(Duration::from_secs(10));
324        state.set_duration(Duration::from_secs(120));
325        assert_eq!(state.duration(), Duration::from_secs(120));
326        assert_eq!(state.remaining(), Duration::from_secs(120));
327    }
328
329    #[test]
330    fn state_zero_duration_is_finished() {
331        let state = TimerState::new(Duration::ZERO);
332        assert!(state.finished());
333        assert!(!state.running());
334    }
335
336    // --- Widget rendering tests ---
337
338    #[test]
339    fn render_zero_area() {
340        let widget = Timer::new();
341        let area = Rect::new(0, 0, 0, 0);
342        let mut pool = GraphemePool::new();
343        let mut frame = Frame::new(1, 1, &mut pool);
344        let mut state = TimerState::new(Duration::from_secs(60));
345        StatefulWidget::render(&widget, area, &mut frame, &mut state);
346    }
347
348    #[test]
349    fn render_remaining_human() {
350        let widget = Timer::new();
351        let mut state = TimerState::new(Duration::from_secs(125));
352        let text = render_to_string(&widget, &mut state, 20);
353        assert_eq!(text, "2m5s");
354    }
355
356    #[test]
357    fn render_digital_format() {
358        let widget = Timer::new().format(TimerFormat::Digital);
359        let mut state = TimerState::new(Duration::from_secs(3665));
360        let text = render_to_string(&widget, &mut state, 20);
361        assert_eq!(text, "01:01:05");
362    }
363
364    #[test]
365    fn render_seconds_format() {
366        let widget = Timer::new().format(TimerFormat::Seconds);
367        let mut state = TimerState::new(Duration::from_secs(90));
368        let text = render_to_string(&widget, &mut state, 20);
369        assert_eq!(text, "90s");
370    }
371
372    #[test]
373    fn render_with_label() {
374        let widget = Timer::new().label("Remaining:");
375        let mut state = TimerState::new(Duration::from_secs(45));
376        let text = render_to_string(&widget, &mut state, 30);
377        assert_eq!(text, "Remaining: 45s");
378    }
379
380    #[test]
381    fn render_finished_shows_zero() {
382        let widget = Timer::new();
383        let mut state = TimerState::new(Duration::from_secs(1));
384        state.start();
385        state.tick(Duration::from_secs(1));
386        let text = render_to_string(&widget, &mut state, 20);
387        assert_eq!(text, "0s");
388    }
389
390    #[test]
391    fn stateless_render_shows_zero() {
392        let widget = Timer::new();
393        let area = Rect::new(0, 0, 10, 1);
394        let mut pool = GraphemePool::new();
395        let mut frame = Frame::new(10, 1, &mut pool);
396        Widget::render(&widget, area, &mut frame);
397        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
398        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
399    }
400
401    #[test]
402    fn render_clears_stale_suffix_cells() {
403        let widget = Timer::new().format(TimerFormat::Seconds);
404        let area = Rect::new(0, 0, 6, 1);
405        let mut pool = GraphemePool::new();
406        let mut frame = Frame::new(6, 1, &mut pool);
407        frame.buffer.set_fast(4, 0, Cell::from_char('X'));
408        let mut state = TimerState::new(Duration::from_secs(90));
409
410        StatefulWidget::render(&widget, area, &mut frame, &mut state);
411
412        assert_eq!(cell_char(&frame.buffer, 4, 0), Some(' '));
413    }
414
415    #[test]
416    fn is_essential() {
417        let widget = Timer::new();
418        assert!(widget.is_essential());
419    }
420
421    // --- Degradation tests ---
422
423    #[test]
424    fn degradation_skeleton_renders_essential_text() {
425        use ftui_render::budget::DegradationLevel;
426
427        let widget = Timer::new();
428        let area = Rect::new(0, 0, 20, 1);
429        let mut pool = GraphemePool::new();
430        let mut frame = Frame::new(20, 1, &mut pool);
431        frame.buffer.degradation = DegradationLevel::Skeleton;
432        let mut state = TimerState::new(Duration::from_secs(60));
433        StatefulWidget::render(&widget, area, &mut frame, &mut state);
434        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('1'));
435        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('m'));
436    }
437
438    #[test]
439    fn skeleton_empty_timer_clears_stale_row() {
440        use ftui_render::budget::DegradationLevel;
441
442        let widget = Timer::new().format(TimerFormat::Seconds);
443        let area = Rect::new(0, 0, 6, 1);
444        let mut pool = GraphemePool::new();
445        let mut frame = Frame::new(6, 1, &mut pool);
446        let mut populated = TimerState::new(Duration::from_secs(90));
447        let mut empty = TimerState::new(Duration::ZERO);
448
449        StatefulWidget::render(&widget, area, &mut frame, &mut populated);
450        frame.buffer.degradation = DegradationLevel::Skeleton;
451        StatefulWidget::render(&widget, area, &mut frame, &mut empty);
452
453        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
454        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
455        assert_eq!(cell_char(&frame.buffer, 2, 0), Some(' '));
456    }
457
458    // --- Countdown progression test ---
459
460    #[test]
461    fn countdown_progression() {
462        let mut state = TimerState::new(Duration::from_secs(5));
463        state.start();
464
465        for expected in (0..=4).rev() {
466            state.tick(Duration::from_secs(1));
467            assert_eq!(state.remaining(), Duration::from_secs(expected));
468        }
469
470        assert!(state.finished());
471    }
472}