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