Skip to main content

ftui_widgets/
stopwatch.rs

1#![forbid(unsafe_code)]
2
3//! Stopwatch widget for displaying elapsed time.
4//!
5//! Provides a [`Stopwatch`] widget that renders a formatted duration, and a
6//! [`StopwatchState`] that tracks elapsed time with start/stop/reset semantics.
7//!
8//! # Example
9//!
10//! ```rust
11//! use ftui_widgets::stopwatch::{Stopwatch, StopwatchState};
12//! use std::time::Duration;
13//!
14//! let mut state = StopwatchState::new();
15//! assert_eq!(state.elapsed(), Duration::ZERO);
16//! assert!(!state.running());
17//!
18//! state.start();
19//! state.tick(Duration::from_secs(1));
20//! assert_eq!(state.elapsed(), Duration::from_secs(1));
21//! ```
22
23use crate::{StatefulWidget, Widget, clear_text_row, draw_text_span};
24use ftui_core::geometry::Rect;
25use ftui_render::frame::Frame;
26use ftui_style::Style;
27
28/// Display format for the stopwatch.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum StopwatchFormat {
31    /// Human-readable with units: "1h30m15s", "45s", "100ms".
32    #[default]
33    Human,
34    /// Fixed-width digital clock: "01:30:15", "00:00:45".
35    Digital,
36    /// Compact seconds only: "5415s", "45s".
37    Seconds,
38}
39
40/// State for the stopwatch, tracking elapsed time and running status.
41#[derive(Debug, Clone)]
42pub struct StopwatchState {
43    elapsed: std::time::Duration,
44    running: bool,
45}
46
47impl Default for StopwatchState {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl StopwatchState {
54    /// Creates a new stopped stopwatch at zero elapsed time.
55    pub fn new() -> Self {
56        Self {
57            elapsed: std::time::Duration::ZERO,
58            running: false,
59        }
60    }
61
62    /// Returns the elapsed time.
63    pub fn elapsed(&self) -> std::time::Duration {
64        self.elapsed
65    }
66
67    /// Returns whether the stopwatch is currently running.
68    pub fn running(&self) -> bool {
69        self.running
70    }
71
72    /// Starts the stopwatch.
73    pub fn start(&mut self) {
74        self.running = true;
75    }
76
77    /// Stops (pauses) the stopwatch.
78    pub fn stop(&mut self) {
79        self.running = false;
80    }
81
82    /// Toggles between running and stopped.
83    pub fn toggle(&mut self) {
84        self.running = !self.running;
85    }
86
87    /// Resets elapsed time to zero. Does not change running state.
88    pub fn reset(&mut self) {
89        self.elapsed = std::time::Duration::ZERO;
90    }
91
92    /// Advances the stopwatch by the given delta if running.
93    /// Returns `true` if the tick was applied.
94    pub fn tick(&mut self, delta: std::time::Duration) -> bool {
95        if self.running {
96            self.elapsed += delta;
97            true
98        } else {
99            false
100        }
101    }
102}
103
104/// A widget that displays elapsed time from a [`StopwatchState`].
105#[derive(Debug, Clone, Default)]
106pub struct Stopwatch<'a> {
107    format: StopwatchFormat,
108    style: Style,
109    running_style: Option<Style>,
110    stopped_style: Option<Style>,
111    label: Option<&'a str>,
112}
113
114impl<'a> Stopwatch<'a> {
115    /// Creates a new stopwatch widget with default settings.
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Sets the display format.
121    #[must_use]
122    pub fn format(mut self, format: StopwatchFormat) -> Self {
123        self.format = format;
124        self
125    }
126
127    /// Sets the base style.
128    #[must_use]
129    pub fn style(mut self, style: Style) -> Self {
130        self.style = style;
131        self
132    }
133
134    /// Sets a style override used when the stopwatch is running.
135    #[must_use]
136    pub fn running_style(mut self, style: Style) -> Self {
137        self.running_style = Some(style);
138        self
139    }
140
141    /// Sets a style override used when the stopwatch is stopped.
142    #[must_use]
143    pub fn stopped_style(mut self, style: Style) -> Self {
144        self.stopped_style = Some(style);
145        self
146    }
147
148    /// Sets an optional label rendered before the time.
149    #[must_use]
150    pub fn label(mut self, label: &'a str) -> Self {
151        self.label = Some(label);
152        self
153    }
154}
155
156impl StatefulWidget for Stopwatch<'_> {
157    type State = StopwatchState;
158
159    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
160        if area.is_empty() || area.height == 0 {
161            return;
162        }
163
164        let deg = frame.buffer.degradation;
165        // Stopwatch output is essential user-facing state, so it still renders
166        // plain text in Skeleton mode instead of disappearing.
167
168        let style = if deg.apply_styling() {
169            if state.running {
170                self.running_style.unwrap_or(self.style)
171            } else {
172                self.stopped_style.unwrap_or(self.style)
173            }
174        } else {
175            Style::default()
176        };
177
178        clear_text_row(frame, area, style);
179
180        let formatted = format_duration(state.elapsed, 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 Stopwatch<'_> {
195    fn render(&self, area: Rect, frame: &mut Frame) {
196        let mut state = StopwatchState::new();
197        StatefulWidget::render(self, area, frame, &mut state);
198    }
199
200    fn is_essential(&self) -> bool {
201        true
202    }
203}
204
205/// Formats a duration according to the given format.
206pub(crate) fn format_duration(d: std::time::Duration, fmt: StopwatchFormat) -> String {
207    match fmt {
208        StopwatchFormat::Human => format_human(d),
209        StopwatchFormat::Digital => format_digital(d),
210        StopwatchFormat::Seconds => format_seconds(d),
211    }
212}
213
214/// Human-readable format: "1h30m15s", "45s", "100ms", "0s".
215fn format_human(d: std::time::Duration) -> String {
216    let total_nanos = d.as_nanos();
217    if total_nanos == 0 {
218        return "0s".to_string();
219    }
220
221    let total_secs = d.as_secs();
222    let subsec_nanos = d.subsec_nanos();
223
224    // Sub-second: show ms, µs, or ns
225    if total_secs == 0 {
226        let micros = d.as_micros();
227        if micros >= 1000 {
228            let millis = d.as_millis();
229            let remainder_micros = micros % 1000;
230            if remainder_micros == 0 {
231                return format!("{millis}ms");
232            }
233            let decimal = format!("{:06}", d.as_nanos() % 1_000_000);
234            let trimmed = decimal.trim_end_matches('0');
235            if trimmed.is_empty() {
236                return format!("{millis}ms");
237            }
238            return format!("{millis}.{trimmed}ms");
239        } else if micros >= 1 {
240            let nanos = d.as_nanos() % 1000;
241            if nanos == 0 {
242                return format!("{micros}µs");
243            }
244            let decimal = format!("{:03}", nanos);
245            let trimmed = decimal.trim_end_matches('0');
246            return format!("{micros}.{trimmed}µs");
247        } else {
248            return format!("{}ns", d.as_nanos());
249        }
250    }
251
252    let hours = total_secs / 3600;
253    let minutes = (total_secs % 3600) / 60;
254    let seconds = total_secs % 60;
255
256    let subsec_str = if subsec_nanos > 0 {
257        let decimal = format!("{subsec_nanos:09}");
258        let trimmed = decimal.trim_end_matches('0');
259        if trimmed.is_empty() {
260            String::new()
261        } else {
262            format!(".{trimmed}")
263        }
264    } else {
265        String::new()
266    };
267
268    if hours > 0 {
269        format!("{hours}h{minutes}m{seconds}{subsec_str}s")
270    } else if minutes > 0 {
271        format!("{minutes}m{seconds}{subsec_str}s")
272    } else {
273        format!("{seconds}{subsec_str}s")
274    }
275}
276
277/// Fixed-width digital format: "01:30:15", "00:00:45".
278fn format_digital(d: std::time::Duration) -> String {
279    let total_secs = d.as_secs();
280    let hours = total_secs / 3600;
281    let minutes = (total_secs % 3600) / 60;
282    let seconds = total_secs % 60;
283
284    if hours > 0 {
285        format!("{hours:02}:{minutes:02}:{seconds:02}")
286    } else {
287        format!("{minutes:02}:{seconds:02}")
288    }
289}
290
291/// Compact seconds format: "5415s", "0s".
292fn format_seconds(d: std::time::Duration) -> String {
293    format!("{}s", d.as_secs())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use ftui_render::buffer::Buffer;
300    use ftui_render::cell::Cell;
301    use ftui_render::grapheme_pool::GraphemePool;
302    use std::time::Duration;
303
304    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
305        buf.get(x, y).and_then(|c| c.content.as_char())
306    }
307
308    fn render_to_string(widget: &Stopwatch, state: &mut StopwatchState, width: u16) -> String {
309        let mut pool = GraphemePool::new();
310        let mut frame = Frame::new(width, 1, &mut pool);
311        let area = Rect::new(0, 0, width, 1);
312        StatefulWidget::render(widget, area, &mut frame, state);
313        (0..width)
314            .filter_map(|x| cell_char(&frame.buffer, x, 0))
315            .collect::<String>()
316            .trim_end()
317            .to_string()
318    }
319
320    // --- StopwatchState tests ---
321
322    #[test]
323    fn state_default_is_zero_and_stopped() {
324        let state = StopwatchState::new();
325        assert_eq!(state.elapsed(), Duration::ZERO);
326        assert!(!state.running());
327    }
328
329    #[test]
330    fn state_start_stop() {
331        let mut state = StopwatchState::new();
332        state.start();
333        assert!(state.running());
334        state.stop();
335        assert!(!state.running());
336    }
337
338    #[test]
339    fn state_toggle() {
340        let mut state = StopwatchState::new();
341        state.toggle();
342        assert!(state.running());
343        state.toggle();
344        assert!(!state.running());
345    }
346
347    #[test]
348    fn state_tick_when_running() {
349        let mut state = StopwatchState::new();
350        state.start();
351        assert!(state.tick(Duration::from_secs(1)));
352        assert_eq!(state.elapsed(), Duration::from_secs(1));
353        assert!(state.tick(Duration::from_secs(2)));
354        assert_eq!(state.elapsed(), Duration::from_secs(3));
355    }
356
357    #[test]
358    fn state_tick_when_stopped_is_noop() {
359        let mut state = StopwatchState::new();
360        assert!(!state.tick(Duration::from_secs(1)));
361        assert_eq!(state.elapsed(), Duration::ZERO);
362    }
363
364    #[test]
365    fn state_reset() {
366        let mut state = StopwatchState::new();
367        state.start();
368        state.tick(Duration::from_secs(100));
369        state.reset();
370        assert_eq!(state.elapsed(), Duration::ZERO);
371        assert!(state.running()); // reset doesn't change running state
372    }
373
374    // --- format_human tests ---
375
376    #[test]
377    fn human_zero() {
378        assert_eq!(format_human(Duration::ZERO), "0s");
379    }
380
381    #[test]
382    fn human_seconds() {
383        assert_eq!(format_human(Duration::from_secs(45)), "45s");
384    }
385
386    #[test]
387    fn human_minutes_seconds() {
388        assert_eq!(format_human(Duration::from_secs(125)), "2m5s");
389    }
390
391    #[test]
392    fn human_hours_minutes_seconds() {
393        assert_eq!(format_human(Duration::from_secs(3665)), "1h1m5s");
394    }
395
396    #[test]
397    fn human_with_subseconds() {
398        assert_eq!(format_human(Duration::from_millis(5500)), "5.5s");
399        assert_eq!(format_human(Duration::from_millis(5001)), "5.001s");
400    }
401
402    #[test]
403    fn human_sub_second_ms() {
404        assert_eq!(format_human(Duration::from_millis(100)), "100ms");
405        assert_eq!(format_human(Duration::from_millis(1)), "1ms");
406    }
407
408    #[test]
409    fn human_sub_second_us() {
410        assert_eq!(format_human(Duration::from_micros(500)), "500µs");
411    }
412
413    #[test]
414    fn human_sub_second_ns() {
415        assert_eq!(format_human(Duration::from_nanos(123)), "123ns");
416    }
417
418    #[test]
419    fn human_large_hours() {
420        assert_eq!(
421            format_human(Duration::from_secs(100 * 3600 + 30 * 60 + 15)),
422            "100h30m15s"
423        );
424    }
425
426    // --- format_digital tests ---
427
428    #[test]
429    fn digital_zero() {
430        assert_eq!(format_digital(Duration::ZERO), "00:00");
431    }
432
433    #[test]
434    fn digital_seconds() {
435        assert_eq!(format_digital(Duration::from_secs(45)), "00:45");
436    }
437
438    #[test]
439    fn digital_minutes_seconds() {
440        assert_eq!(format_digital(Duration::from_secs(125)), "02:05");
441    }
442
443    #[test]
444    fn digital_hours() {
445        assert_eq!(format_digital(Duration::from_secs(3665)), "01:01:05");
446    }
447
448    // --- format_seconds tests ---
449
450    #[test]
451    fn seconds_format() {
452        assert_eq!(format_seconds(Duration::ZERO), "0s");
453        assert_eq!(format_seconds(Duration::from_secs(5415)), "5415s");
454    }
455
456    // --- Widget rendering tests ---
457
458    #[test]
459    fn render_zero_area() {
460        let widget = Stopwatch::new();
461        let area = Rect::new(0, 0, 0, 0);
462        let mut pool = GraphemePool::new();
463        let mut frame = Frame::new(1, 1, &mut pool);
464        let mut state = StopwatchState::new();
465        StatefulWidget::render(&widget, area, &mut frame, &mut state);
466        // Should not panic
467    }
468
469    #[test]
470    fn render_default_zero() {
471        let widget = Stopwatch::new();
472        let mut state = StopwatchState::new();
473        let text = render_to_string(&widget, &mut state, 20);
474        assert_eq!(text, "0s");
475    }
476
477    #[test]
478    fn render_elapsed_human() {
479        let widget = Stopwatch::new();
480        let mut state = StopwatchState {
481            elapsed: Duration::from_secs(125),
482            running: false,
483        };
484        let text = render_to_string(&widget, &mut state, 20);
485        assert_eq!(text, "2m5s");
486    }
487
488    #[test]
489    fn render_digital_format() {
490        let widget = Stopwatch::new().format(StopwatchFormat::Digital);
491        let mut state = StopwatchState {
492            elapsed: Duration::from_secs(3665),
493            running: false,
494        };
495        let text = render_to_string(&widget, &mut state, 20);
496        assert_eq!(text, "01:01:05");
497    }
498
499    #[test]
500    fn render_seconds_format() {
501        let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
502        let mut state = StopwatchState {
503            elapsed: Duration::from_secs(90),
504            running: false,
505        };
506        let text = render_to_string(&widget, &mut state, 20);
507        assert_eq!(text, "90s");
508    }
509
510    #[test]
511    fn render_with_label() {
512        let widget = Stopwatch::new().label("Elapsed:");
513        let mut state = StopwatchState {
514            elapsed: Duration::from_secs(45),
515            running: false,
516        };
517        let text = render_to_string(&widget, &mut state, 30);
518        assert_eq!(text, "Elapsed: 45s");
519    }
520
521    #[test]
522    fn render_clips_to_area() {
523        let widget = Stopwatch::new().format(StopwatchFormat::Digital);
524        let mut state = StopwatchState {
525            elapsed: Duration::from_secs(3665),
526            running: false,
527        };
528        // Area of width 5 should clip "01:01:05"
529        let text = render_to_string(&widget, &mut state, 5);
530        assert_eq!(text, "01:01");
531    }
532
533    #[test]
534    fn stateless_render_shows_zero() {
535        let widget = Stopwatch::new();
536        let area = Rect::new(0, 0, 10, 1);
537        let mut pool = GraphemePool::new();
538        let mut frame = Frame::new(10, 1, &mut pool);
539        Widget::render(&widget, area, &mut frame);
540        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
541        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
542    }
543
544    #[test]
545    fn render_clears_stale_suffix_cells() {
546        let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
547        let area = Rect::new(0, 0, 6, 1);
548        let mut pool = GraphemePool::new();
549        let mut frame = Frame::new(6, 1, &mut pool);
550        frame.buffer.set_fast(4, 0, Cell::from_char('X'));
551        let mut state = StopwatchState {
552            elapsed: Duration::from_secs(90),
553            running: false,
554        };
555
556        StatefulWidget::render(&widget, area, &mut frame, &mut state);
557
558        assert_eq!(cell_char(&frame.buffer, 4, 0), Some(' '));
559    }
560
561    #[test]
562    fn is_essential() {
563        let widget = Stopwatch::new();
564        assert!(widget.is_essential());
565    }
566
567    // --- Degradation tests ---
568
569    #[test]
570    fn degradation_skeleton_renders_essential_text() {
571        use ftui_render::budget::DegradationLevel;
572
573        let widget = Stopwatch::new();
574        let area = Rect::new(0, 0, 20, 1);
575        let mut pool = GraphemePool::new();
576        let mut frame = Frame::new(20, 1, &mut pool);
577        frame.buffer.degradation = DegradationLevel::Skeleton;
578        let mut state = StopwatchState {
579            elapsed: Duration::from_secs(45),
580            running: false,
581        };
582        StatefulWidget::render(&widget, area, &mut frame, &mut state);
583        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('4'));
584        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('5'));
585    }
586
587    #[test]
588    fn skeleton_zero_stopwatch_clears_stale_row() {
589        use ftui_render::budget::DegradationLevel;
590
591        let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
592        let area = Rect::new(0, 0, 6, 1);
593        let mut pool = GraphemePool::new();
594        let mut frame = Frame::new(6, 1, &mut pool);
595        let mut populated = StopwatchState {
596            elapsed: Duration::from_secs(90),
597            running: false,
598        };
599        let mut empty = StopwatchState::default();
600
601        StatefulWidget::render(&widget, area, &mut frame, &mut populated);
602        frame.buffer.degradation = DegradationLevel::Skeleton;
603        StatefulWidget::render(&widget, area, &mut frame, &mut empty);
604
605        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
606        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
607        assert_eq!(cell_char(&frame.buffer, 2, 0), Some(' '));
608    }
609
610    #[test]
611    fn degradation_no_styling_uses_default_style() {
612        use ftui_render::budget::DegradationLevel;
613
614        let widget = Stopwatch::new().style(Style::default().bold());
615        let area = Rect::new(0, 0, 20, 1);
616        let mut pool = GraphemePool::new();
617        let mut frame = Frame::new(20, 1, &mut pool);
618        frame.buffer.degradation = DegradationLevel::NoStyling;
619        let mut state = StopwatchState {
620            elapsed: Duration::from_secs(5),
621            running: false,
622        };
623        StatefulWidget::render(&widget, area, &mut frame, &mut state);
624        // Content should still render, just without custom style
625        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('5'));
626    }
627}