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, 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        if !deg.render_content() {
166            return;
167        }
168
169        let style = if deg.apply_styling() {
170            if state.running {
171                self.running_style.unwrap_or(self.style)
172            } else {
173                self.stopped_style.unwrap_or(self.style)
174            }
175        } else {
176            Style::default()
177        };
178
179        let formatted = format_duration(state.elapsed, self.format);
180        let mut x = area.x;
181
182        if let Some(label) = self.label {
183            x = draw_text_span(frame, x, area.y, label, style, area.right());
184            if x < area.right() {
185                x = draw_text_span(frame, x, area.y, " ", style, area.right());
186            }
187        }
188
189        draw_text_span(frame, x, area.y, &formatted, style, area.right());
190    }
191}
192
193impl Widget for Stopwatch<'_> {
194    fn render(&self, area: Rect, frame: &mut Frame) {
195        let mut state = StopwatchState::new();
196        StatefulWidget::render(self, area, frame, &mut state);
197    }
198
199    fn is_essential(&self) -> bool {
200        true
201    }
202}
203
204/// Formats a duration according to the given format.
205pub(crate) fn format_duration(d: std::time::Duration, fmt: StopwatchFormat) -> String {
206    match fmt {
207        StopwatchFormat::Human => format_human(d),
208        StopwatchFormat::Digital => format_digital(d),
209        StopwatchFormat::Seconds => format_seconds(d),
210    }
211}
212
213/// Human-readable format: "1h30m15s", "45s", "100ms", "0s".
214fn format_human(d: std::time::Duration) -> String {
215    let total_nanos = d.as_nanos();
216    if total_nanos == 0 {
217        return "0s".to_string();
218    }
219
220    let total_secs = d.as_secs();
221    let subsec_nanos = d.subsec_nanos();
222
223    // Sub-second: show ms, µs, or ns
224    if total_secs == 0 {
225        let micros = d.as_micros();
226        if micros >= 1000 {
227            let millis = d.as_millis();
228            let remainder_micros = micros % 1000;
229            if remainder_micros == 0 {
230                return format!("{millis}ms");
231            }
232            let decimal = format!("{:06}", d.as_nanos() % 1_000_000);
233            let trimmed = decimal.trim_end_matches('0');
234            if trimmed.is_empty() {
235                return format!("{millis}ms");
236            }
237            return format!("{millis}.{trimmed}ms");
238        } else if micros >= 1 {
239            let nanos = d.as_nanos() % 1000;
240            if nanos == 0 {
241                return format!("{micros}µs");
242            }
243            let decimal = format!("{:03}", nanos);
244            let trimmed = decimal.trim_end_matches('0');
245            return format!("{micros}.{trimmed}µs");
246        } else {
247            return format!("{}ns", d.as_nanos());
248        }
249    }
250
251    let hours = total_secs / 3600;
252    let minutes = (total_secs % 3600) / 60;
253    let seconds = total_secs % 60;
254
255    let subsec_str = if subsec_nanos > 0 {
256        let decimal = format!("{subsec_nanos:09}");
257        let trimmed = decimal.trim_end_matches('0');
258        if trimmed.is_empty() {
259            String::new()
260        } else {
261            format!(".{trimmed}")
262        }
263    } else {
264        String::new()
265    };
266
267    if hours > 0 {
268        format!("{hours}h{minutes}m{seconds}{subsec_str}s")
269    } else if minutes > 0 {
270        format!("{minutes}m{seconds}{subsec_str}s")
271    } else {
272        format!("{seconds}{subsec_str}s")
273    }
274}
275
276/// Fixed-width digital format: "01:30:15", "00:00:45".
277fn format_digital(d: std::time::Duration) -> String {
278    let total_secs = d.as_secs();
279    let hours = total_secs / 3600;
280    let minutes = (total_secs % 3600) / 60;
281    let seconds = total_secs % 60;
282
283    if hours > 0 {
284        format!("{hours:02}:{minutes:02}:{seconds:02}")
285    } else {
286        format!("{minutes:02}:{seconds:02}")
287    }
288}
289
290/// Compact seconds format: "5415s", "0s".
291fn format_seconds(d: std::time::Duration) -> String {
292    format!("{}s", d.as_secs())
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use ftui_render::buffer::Buffer;
299    use ftui_render::grapheme_pool::GraphemePool;
300    use std::time::Duration;
301
302    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
303        buf.get(x, y).and_then(|c| c.content.as_char())
304    }
305
306    fn render_to_string(widget: &Stopwatch, state: &mut StopwatchState, width: u16) -> String {
307        let mut pool = GraphemePool::new();
308        let mut frame = Frame::new(width, 1, &mut pool);
309        let area = Rect::new(0, 0, width, 1);
310        StatefulWidget::render(widget, area, &mut frame, state);
311        (0..width)
312            .filter_map(|x| cell_char(&frame.buffer, x, 0))
313            .collect::<String>()
314            .trim_end()
315            .to_string()
316    }
317
318    // --- StopwatchState tests ---
319
320    #[test]
321    fn state_default_is_zero_and_stopped() {
322        let state = StopwatchState::new();
323        assert_eq!(state.elapsed(), Duration::ZERO);
324        assert!(!state.running());
325    }
326
327    #[test]
328    fn state_start_stop() {
329        let mut state = StopwatchState::new();
330        state.start();
331        assert!(state.running());
332        state.stop();
333        assert!(!state.running());
334    }
335
336    #[test]
337    fn state_toggle() {
338        let mut state = StopwatchState::new();
339        state.toggle();
340        assert!(state.running());
341        state.toggle();
342        assert!(!state.running());
343    }
344
345    #[test]
346    fn state_tick_when_running() {
347        let mut state = StopwatchState::new();
348        state.start();
349        assert!(state.tick(Duration::from_secs(1)));
350        assert_eq!(state.elapsed(), Duration::from_secs(1));
351        assert!(state.tick(Duration::from_secs(2)));
352        assert_eq!(state.elapsed(), Duration::from_secs(3));
353    }
354
355    #[test]
356    fn state_tick_when_stopped_is_noop() {
357        let mut state = StopwatchState::new();
358        assert!(!state.tick(Duration::from_secs(1)));
359        assert_eq!(state.elapsed(), Duration::ZERO);
360    }
361
362    #[test]
363    fn state_reset() {
364        let mut state = StopwatchState::new();
365        state.start();
366        state.tick(Duration::from_secs(100));
367        state.reset();
368        assert_eq!(state.elapsed(), Duration::ZERO);
369        assert!(state.running()); // reset doesn't change running state
370    }
371
372    // --- format_human tests ---
373
374    #[test]
375    fn human_zero() {
376        assert_eq!(format_human(Duration::ZERO), "0s");
377    }
378
379    #[test]
380    fn human_seconds() {
381        assert_eq!(format_human(Duration::from_secs(45)), "45s");
382    }
383
384    #[test]
385    fn human_minutes_seconds() {
386        assert_eq!(format_human(Duration::from_secs(125)), "2m5s");
387    }
388
389    #[test]
390    fn human_hours_minutes_seconds() {
391        assert_eq!(format_human(Duration::from_secs(3665)), "1h1m5s");
392    }
393
394    #[test]
395    fn human_with_subseconds() {
396        assert_eq!(format_human(Duration::from_millis(5500)), "5.5s");
397        assert_eq!(format_human(Duration::from_millis(5001)), "5.001s");
398    }
399
400    #[test]
401    fn human_sub_second_ms() {
402        assert_eq!(format_human(Duration::from_millis(100)), "100ms");
403        assert_eq!(format_human(Duration::from_millis(1)), "1ms");
404    }
405
406    #[test]
407    fn human_sub_second_us() {
408        assert_eq!(format_human(Duration::from_micros(500)), "500µs");
409    }
410
411    #[test]
412    fn human_sub_second_ns() {
413        assert_eq!(format_human(Duration::from_nanos(123)), "123ns");
414    }
415
416    #[test]
417    fn human_large_hours() {
418        assert_eq!(
419            format_human(Duration::from_secs(100 * 3600 + 30 * 60 + 15)),
420            "100h30m15s"
421        );
422    }
423
424    // --- format_digital tests ---
425
426    #[test]
427    fn digital_zero() {
428        assert_eq!(format_digital(Duration::ZERO), "00:00");
429    }
430
431    #[test]
432    fn digital_seconds() {
433        assert_eq!(format_digital(Duration::from_secs(45)), "00:45");
434    }
435
436    #[test]
437    fn digital_minutes_seconds() {
438        assert_eq!(format_digital(Duration::from_secs(125)), "02:05");
439    }
440
441    #[test]
442    fn digital_hours() {
443        assert_eq!(format_digital(Duration::from_secs(3665)), "01:01:05");
444    }
445
446    // --- format_seconds tests ---
447
448    #[test]
449    fn seconds_format() {
450        assert_eq!(format_seconds(Duration::ZERO), "0s");
451        assert_eq!(format_seconds(Duration::from_secs(5415)), "5415s");
452    }
453
454    // --- Widget rendering tests ---
455
456    #[test]
457    fn render_zero_area() {
458        let widget = Stopwatch::new();
459        let area = Rect::new(0, 0, 0, 0);
460        let mut pool = GraphemePool::new();
461        let mut frame = Frame::new(1, 1, &mut pool);
462        let mut state = StopwatchState::new();
463        StatefulWidget::render(&widget, area, &mut frame, &mut state);
464        // Should not panic
465    }
466
467    #[test]
468    fn render_default_zero() {
469        let widget = Stopwatch::new();
470        let mut state = StopwatchState::new();
471        let text = render_to_string(&widget, &mut state, 20);
472        assert_eq!(text, "0s");
473    }
474
475    #[test]
476    fn render_elapsed_human() {
477        let widget = Stopwatch::new();
478        let mut state = StopwatchState {
479            elapsed: Duration::from_secs(125),
480            running: false,
481        };
482        let text = render_to_string(&widget, &mut state, 20);
483        assert_eq!(text, "2m5s");
484    }
485
486    #[test]
487    fn render_digital_format() {
488        let widget = Stopwatch::new().format(StopwatchFormat::Digital);
489        let mut state = StopwatchState {
490            elapsed: Duration::from_secs(3665),
491            running: false,
492        };
493        let text = render_to_string(&widget, &mut state, 20);
494        assert_eq!(text, "01:01:05");
495    }
496
497    #[test]
498    fn render_seconds_format() {
499        let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
500        let mut state = StopwatchState {
501            elapsed: Duration::from_secs(90),
502            running: false,
503        };
504        let text = render_to_string(&widget, &mut state, 20);
505        assert_eq!(text, "90s");
506    }
507
508    #[test]
509    fn render_with_label() {
510        let widget = Stopwatch::new().label("Elapsed:");
511        let mut state = StopwatchState {
512            elapsed: Duration::from_secs(45),
513            running: false,
514        };
515        let text = render_to_string(&widget, &mut state, 30);
516        assert_eq!(text, "Elapsed: 45s");
517    }
518
519    #[test]
520    fn render_clips_to_area() {
521        let widget = Stopwatch::new().format(StopwatchFormat::Digital);
522        let mut state = StopwatchState {
523            elapsed: Duration::from_secs(3665),
524            running: false,
525        };
526        // Area of width 5 should clip "01:01:05"
527        let text = render_to_string(&widget, &mut state, 5);
528        assert_eq!(text, "01:01");
529    }
530
531    #[test]
532    fn stateless_render_shows_zero() {
533        let widget = Stopwatch::new();
534        let area = Rect::new(0, 0, 10, 1);
535        let mut pool = GraphemePool::new();
536        let mut frame = Frame::new(10, 1, &mut pool);
537        Widget::render(&widget, area, &mut frame);
538        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
539        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
540    }
541
542    #[test]
543    fn is_essential() {
544        let widget = Stopwatch::new();
545        assert!(widget.is_essential());
546    }
547
548    // --- Degradation tests ---
549
550    #[test]
551    fn degradation_skeleton_skips() {
552        use ftui_render::budget::DegradationLevel;
553
554        let widget = Stopwatch::new();
555        let area = Rect::new(0, 0, 20, 1);
556        let mut pool = GraphemePool::new();
557        let mut frame = Frame::new(20, 1, &mut pool);
558        frame.buffer.degradation = DegradationLevel::Skeleton;
559        let mut state = StopwatchState {
560            elapsed: Duration::from_secs(45),
561            running: false,
562        };
563        StatefulWidget::render(&widget, area, &mut frame, &mut state);
564        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
565    }
566
567    #[test]
568    fn degradation_no_styling_uses_default_style() {
569        use ftui_render::budget::DegradationLevel;
570
571        let widget = Stopwatch::new().style(Style::default().bold());
572        let area = Rect::new(0, 0, 20, 1);
573        let mut pool = GraphemePool::new();
574        let mut frame = Frame::new(20, 1, &mut pool);
575        frame.buffer.degradation = DegradationLevel::NoStyling;
576        let mut state = StopwatchState {
577            elapsed: Duration::from_secs(5),
578            running: false,
579        };
580        StatefulWidget::render(&widget, area, &mut frame, &mut state);
581        // Content should still render, just without custom style
582        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('5'));
583    }
584}