Skip to main content

bubbles/
stopwatch.rs

1//! Stopwatch component for tracking elapsed time.
2//!
3//! This module provides a stopwatch that counts up from zero, useful for
4//! measuring elapsed time in TUI applications.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::stopwatch::Stopwatch;
10//! use std::time::Duration;
11//!
12//! let stopwatch = Stopwatch::new();
13//! assert_eq!(stopwatch.elapsed(), Duration::ZERO);
14//! assert!(!stopwatch.running());
15//! ```
16
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::time::Duration;
19
20use bubbletea::{Cmd, Message, Model};
21
22/// Global ID counter for stopwatch instances.
23static NEXT_ID: AtomicU64 = AtomicU64::new(1);
24
25fn next_id() -> u64 {
26    NEXT_ID.fetch_add(1, Ordering::Relaxed)
27}
28
29/// Message sent on every stopwatch tick.
30#[derive(Debug, Clone, Copy)]
31pub struct TickMsg {
32    /// The stopwatch ID.
33    pub id: u64,
34    /// Tag for message ordering.
35    tag: u64,
36}
37
38impl TickMsg {
39    /// Creates a new tick message.
40    #[must_use]
41    pub fn new(id: u64, tag: u64) -> Self {
42        Self { id, tag }
43    }
44}
45
46/// Message to start or stop the stopwatch.
47#[derive(Debug, Clone, Copy)]
48pub struct StartStopMsg {
49    /// The stopwatch ID.
50    pub id: u64,
51    /// Whether to start (true) or stop (false).
52    pub running: bool,
53}
54
55/// Message to reset the stopwatch.
56#[derive(Debug, Clone, Copy)]
57pub struct ResetMsg {
58    /// The stopwatch ID.
59    pub id: u64,
60}
61
62/// Stopwatch model.
63#[derive(Debug, Clone)]
64pub struct Stopwatch {
65    /// Elapsed time.
66    elapsed: Duration,
67    /// Tick interval.
68    interval: Duration,
69    /// Unique ID.
70    id: u64,
71    /// Message tag for ordering.
72    tag: u64,
73    /// Whether the stopwatch is running.
74    running: bool,
75}
76
77impl Default for Stopwatch {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl Stopwatch {
84    /// Creates a new stopwatch with the default 1-second interval.
85    #[must_use]
86    pub fn new() -> Self {
87        Self::with_interval(Duration::from_secs(1))
88    }
89
90    /// Creates a new stopwatch with the given tick interval.
91    #[must_use]
92    pub fn with_interval(interval: Duration) -> Self {
93        Self {
94            elapsed: Duration::ZERO,
95            interval,
96            id: next_id(),
97            tag: 0,
98            running: false,
99        }
100    }
101
102    /// Returns the stopwatch's unique ID.
103    #[must_use]
104    pub fn id(&self) -> u64 {
105        self.id
106    }
107
108    /// Returns whether the stopwatch is currently running.
109    #[must_use]
110    pub fn running(&self) -> bool {
111        self.running
112    }
113
114    /// Returns the elapsed time.
115    #[must_use]
116    pub fn elapsed(&self) -> Duration {
117        self.elapsed
118    }
119
120    /// Returns the tick interval.
121    #[must_use]
122    pub fn interval(&self) -> Duration {
123        self.interval
124    }
125
126    /// Returns a command to initialize and start the stopwatch.
127    #[must_use]
128    pub fn init(&self) -> Option<Cmd> {
129        self.start_cmd()
130    }
131
132    /// Starts the stopwatch.
133    fn start_cmd(&self) -> Option<Cmd> {
134        let id = self.id;
135        let tag = self.tag;
136        let interval = self.interval;
137
138        bubbletea::sequence(vec![
139            Some(Cmd::new(move || {
140                Message::new(StartStopMsg { id, running: true })
141            })),
142            Some(Cmd::new(move || {
143                std::thread::sleep(interval);
144                Message::new(TickMsg { id, tag })
145            })),
146        ])
147    }
148
149    /// Creates a command to start the stopwatch.
150    pub fn start(&self) -> Option<Cmd> {
151        self.start_cmd()
152    }
153
154    /// Creates a command to stop the stopwatch.
155    pub fn stop(&self) -> Option<Cmd> {
156        let id = self.id;
157        Some(Cmd::new(move || {
158            Message::new(StartStopMsg { id, running: false })
159        }))
160    }
161
162    /// Creates a command to toggle the stopwatch.
163    pub fn toggle(&self) -> Option<Cmd> {
164        if self.running() {
165            self.stop()
166        } else {
167            self.start()
168        }
169    }
170
171    /// Creates a command to reset the stopwatch.
172    pub fn reset(&self) -> Option<Cmd> {
173        let id = self.id;
174        Some(Cmd::new(move || Message::new(ResetMsg { id })))
175    }
176
177    /// Creates a tick command.
178    fn tick_cmd(&self) -> Cmd {
179        let id = self.id;
180        let tag = self.tag;
181        let interval = self.interval;
182
183        Cmd::new(move || {
184            std::thread::sleep(interval);
185            Message::new(TickMsg { id, tag })
186        })
187    }
188
189    /// Updates the stopwatch state.
190    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
191        // Handle start/stop
192        if let Some(ss) = msg.downcast_ref::<StartStopMsg>() {
193            if ss.id != self.id {
194                return None;
195            }
196            self.running = ss.running;
197            return None;
198        }
199
200        // Handle reset
201        if let Some(reset) = msg.downcast_ref::<ResetMsg>() {
202            if reset.id != self.id {
203                return None;
204            }
205            self.elapsed = Duration::ZERO;
206            return None;
207        }
208
209        // Handle tick
210        if let Some(tick) = msg.downcast_ref::<TickMsg>() {
211            if !self.running || tick.id != self.id {
212                return None;
213            }
214
215            // Reject old tags
216            if tick.tag > 0 && tick.tag != self.tag {
217                return None;
218            }
219
220            self.elapsed += self.interval;
221            self.tag = self.tag.wrapping_add(1);
222            return Some(self.tick_cmd());
223        }
224
225        None
226    }
227
228    /// Renders the stopwatch display.
229    #[must_use]
230    pub fn view(&self) -> String {
231        format_duration(self.elapsed)
232    }
233}
234
235/// Formats a duration for display, matching Go's time.Duration.String() behavior.
236///
237/// Format rules (matching Go):
238/// - Less than 1 second: show as milliseconds (e.g., "100ms", "1ms")
239/// - 1 second or more: show with decimal precision (e.g., "5.001s", "10.5s")
240/// - 1 minute or more: show minutes and seconds (e.g., "1m30s", "2m5.5s")
241/// - 1 hour or more: show hours, minutes, seconds (e.g., "1h0m0s", "1h30m15.5s")
242fn format_duration(d: Duration) -> String {
243    let total_nanos = d.as_nanos();
244
245    // Zero case
246    if total_nanos == 0 {
247        return "0s".to_string();
248    }
249
250    let total_secs = d.as_secs();
251    let subsec_nanos = d.subsec_nanos();
252
253    // Less than 1 second - show as ms, µs, or ns
254    if total_secs == 0 {
255        let micros = d.as_micros();
256        if micros >= 1000 {
257            // Milliseconds
258            let millis = d.as_millis();
259            let remainder_micros = micros % 1000;
260            if remainder_micros == 0 {
261                return format!("{}ms", millis);
262            }
263            // Show with decimal precision
264            let decimal = format!("{:06}", d.as_nanos() % 1_000_000);
265            let trimmed = decimal.trim_end_matches('0');
266            if trimmed.is_empty() {
267                return format!("{}ms", millis);
268            }
269            return format!("{}.{}ms", millis, trimmed);
270        } else if micros >= 1 {
271            // Microseconds
272            let nanos = d.as_nanos() % 1000;
273            if nanos == 0 {
274                return format!("{}µs", micros);
275            }
276            let decimal = format!("{:03}", nanos);
277            let trimmed = decimal.trim_end_matches('0');
278            return format!("{}.{}µs", micros, trimmed);
279        } else {
280            // Nanoseconds
281            return format!("{}ns", d.as_nanos());
282        }
283    }
284
285    let hours = total_secs / 3600;
286    let minutes = (total_secs % 3600) / 60;
287    let seconds = total_secs % 60;
288
289    // Format sub-second part
290    let subsec_str = if subsec_nanos > 0 {
291        // Convert to string with 9 decimal places, then trim trailing zeros
292        let decimal = format!("{:09}", subsec_nanos);
293        let trimmed = decimal.trim_end_matches('0');
294        if trimmed.is_empty() {
295            String::new()
296        } else {
297            format!(".{}", trimmed)
298        }
299    } else {
300        String::new()
301    };
302
303    if hours > 0 {
304        if subsec_str.is_empty() {
305            format!("{}h{}m{}s", hours, minutes, seconds)
306        } else {
307            format!("{}h{}m{}{}s", hours, minutes, seconds, subsec_str)
308        }
309    } else if minutes > 0 {
310        if subsec_str.is_empty() {
311            format!("{}m{}s", minutes, seconds)
312        } else {
313            format!("{}m{}{}s", minutes, seconds, subsec_str)
314        }
315    } else {
316        format!("{}{}s", seconds, subsec_str)
317    }
318}
319
320/// Implement the Model trait for standalone bubbletea usage.
321impl Model for Stopwatch {
322    fn init(&self) -> Option<Cmd> {
323        Stopwatch::init(self)
324    }
325
326    fn update(&mut self, msg: Message) -> Option<Cmd> {
327        Stopwatch::update(self, msg)
328    }
329
330    fn view(&self) -> String {
331        Stopwatch::view(self)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_stopwatch_new() {
341        let sw = Stopwatch::new();
342        assert_eq!(sw.elapsed(), Duration::ZERO);
343        assert!(!sw.running());
344        assert_eq!(sw.interval(), Duration::from_secs(1));
345    }
346
347    #[test]
348    fn test_stopwatch_unique_ids() {
349        let sw1 = Stopwatch::new();
350        let sw2 = Stopwatch::new();
351        assert_ne!(sw1.id(), sw2.id());
352    }
353
354    #[test]
355    fn test_stopwatch_with_interval() {
356        let sw = Stopwatch::with_interval(Duration::from_millis(100));
357        assert_eq!(sw.interval(), Duration::from_millis(100));
358    }
359
360    #[test]
361    fn test_stopwatch_start_stop() {
362        let mut sw = Stopwatch::new();
363        assert!(!sw.running());
364
365        // Simulate start message
366        let msg = Message::new(StartStopMsg {
367            id: sw.id(),
368            running: true,
369        });
370        sw.update(msg);
371        assert!(sw.running());
372
373        // Simulate stop message
374        let msg = Message::new(StartStopMsg {
375            id: sw.id(),
376            running: false,
377        });
378        sw.update(msg);
379        assert!(!sw.running());
380    }
381
382    #[test]
383    fn test_stopwatch_tick() {
384        let mut sw = Stopwatch::new();
385        sw.running = true;
386
387        let tick = Message::new(TickMsg {
388            id: sw.id(),
389            tag: 0,
390        });
391        sw.update(tick);
392
393        assert_eq!(sw.elapsed(), Duration::from_secs(1));
394    }
395
396    #[test]
397    fn test_stopwatch_reset() {
398        let mut sw = Stopwatch::new();
399        sw.elapsed = Duration::from_secs(100);
400
401        let msg = Message::new(ResetMsg { id: sw.id() });
402        sw.update(msg);
403
404        assert_eq!(sw.elapsed(), Duration::ZERO);
405    }
406
407    #[test]
408    fn test_stopwatch_ignores_other_ids() {
409        let mut sw = Stopwatch::new();
410        sw.running = true;
411
412        let tick = Message::new(TickMsg { id: 9999, tag: 0 });
413        sw.update(tick);
414
415        assert_eq!(sw.elapsed(), Duration::ZERO);
416    }
417
418    #[test]
419    fn test_stopwatch_view() {
420        let mut sw = Stopwatch::new();
421        sw.elapsed = Duration::from_secs(125);
422        assert_eq!(sw.view(), "2m5s");
423
424        sw.elapsed = Duration::from_secs(3665);
425        assert_eq!(sw.view(), "1h1m5s");
426    }
427
428    #[test]
429    fn test_format_duration() {
430        assert_eq!(format_duration(Duration::from_secs(0)), "0s");
431        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
432        assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
433        assert_eq!(format_duration(Duration::from_secs(3600)), "1h0m0s");
434    }
435
436    // Model trait tests
437
438    #[test]
439    fn test_stopwatch_model_init_returns_cmd() {
440        let sw = Stopwatch::new();
441        // init() returns a command to start the stopwatch
442        assert!(sw.init().is_some());
443    }
444
445    #[test]
446    fn test_stopwatch_model_update_start_stop() {
447        let mut sw = Stopwatch::new();
448        assert!(!sw.running());
449
450        // Start via update
451        let msg = Message::new(StartStopMsg {
452            id: sw.id(),
453            running: true,
454        });
455        let result = sw.update(msg);
456        assert!(sw.running());
457        assert!(result.is_none()); // start/stop doesn't return a command
458
459        // Stop via update
460        let msg = Message::new(StartStopMsg {
461            id: sw.id(),
462            running: false,
463        });
464        let result = sw.update(msg);
465        assert!(!sw.running());
466        assert!(result.is_none());
467    }
468
469    #[test]
470    fn test_stopwatch_model_update_tick_returns_cmd() {
471        let mut sw = Stopwatch::new();
472        sw.running = true;
473
474        let tick = Message::new(TickMsg {
475            id: sw.id(),
476            tag: 0,
477        });
478        let result = sw.update(tick);
479
480        // Tick returns a command to schedule the next tick
481        assert!(result.is_some());
482        assert_eq!(sw.elapsed(), Duration::from_secs(1));
483    }
484
485    #[test]
486    fn test_stopwatch_model_update_tick_when_stopped_returns_none() {
487        let mut sw = Stopwatch::new();
488        assert!(!sw.running());
489
490        let tick = Message::new(TickMsg {
491            id: sw.id(),
492            tag: 0,
493        });
494        let result = sw.update(tick);
495
496        // Tick when stopped returns None and doesn't update elapsed
497        assert!(result.is_none());
498        assert_eq!(sw.elapsed(), Duration::ZERO);
499    }
500
501    #[test]
502    fn test_stopwatch_model_update_reset() {
503        let mut sw = Stopwatch::new();
504        sw.elapsed = Duration::from_secs(100);
505        sw.running = true;
506
507        let msg = Message::new(ResetMsg { id: sw.id() });
508        let result = sw.update(msg);
509
510        assert_eq!(sw.elapsed(), Duration::ZERO);
511        assert!(result.is_none());
512    }
513
514    #[test]
515    fn test_stopwatch_model_view_zero_time() {
516        let sw = Stopwatch::new();
517        assert_eq!(sw.view(), "0s");
518    }
519
520    #[test]
521    fn test_stopwatch_model_view_seconds_only() {
522        let mut sw = Stopwatch::new();
523        sw.elapsed = Duration::from_secs(45);
524        assert_eq!(sw.view(), "45s");
525    }
526
527    #[test]
528    fn test_stopwatch_model_view_minutes_seconds() {
529        let mut sw = Stopwatch::new();
530        sw.elapsed = Duration::from_secs(125);
531        assert_eq!(sw.view(), "2m5s");
532    }
533
534    #[test]
535    fn test_stopwatch_model_view_hours_minutes_seconds() {
536        let mut sw = Stopwatch::new();
537        sw.elapsed = Duration::from_secs(3665);
538        assert_eq!(sw.view(), "1h1m5s");
539    }
540
541    #[test]
542    fn test_stopwatch_model_view_with_milliseconds() {
543        let mut sw = Stopwatch::new();
544        // For times under 10 seconds with milliseconds, format shows decimal
545        sw.elapsed = Duration::from_millis(5500);
546        assert_eq!(sw.view(), "5.5s");
547    }
548
549    #[test]
550    fn test_stopwatch_model_very_long_duration() {
551        let mut sw = Stopwatch::new();
552        // Test 100 hours
553        sw.elapsed = Duration::from_secs(100 * 3600 + 30 * 60 + 15);
554        assert_eq!(sw.view(), "100h30m15s");
555    }
556
557    #[test]
558    fn test_stopwatch_model_tick_increments_tag() {
559        let mut sw = Stopwatch::new();
560        sw.running = true;
561        let initial_tag = sw.tag;
562
563        let tick = Message::new(TickMsg {
564            id: sw.id(),
565            tag: initial_tag,
566        });
567        sw.update(tick);
568
569        // Tag should increment after each tick
570        assert_eq!(sw.tag, initial_tag.wrapping_add(1));
571    }
572
573    #[test]
574    fn test_stopwatch_model_old_tag_rejected() {
575        let mut sw = Stopwatch::new();
576        sw.running = true;
577        sw.tag = 5; // Set current tag to 5
578
579        // Old tag (1) should be rejected when current tag is 5
580        let tick = Message::new(TickMsg {
581            id: sw.id(),
582            tag: 1,
583        });
584        let result = sw.update(tick);
585
586        assert!(result.is_none());
587        assert_eq!(sw.elapsed(), Duration::ZERO);
588    }
589
590    // Go parity tests - format_duration should match Go's time.Duration.String()
591
592    #[test]
593    fn test_format_duration_go_parity_sub_second() {
594        // Sub-second durations use ms, µs, or ns units
595        assert_eq!(format_duration(Duration::from_millis(100)), "100ms");
596        assert_eq!(format_duration(Duration::from_millis(1)), "1ms");
597        assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
598        assert_eq!(format_duration(Duration::from_micros(500)), "500µs");
599        assert_eq!(format_duration(Duration::from_nanos(123)), "123ns");
600    }
601
602    #[test]
603    fn test_format_duration_go_parity_seconds_with_decimals() {
604        // Seconds with sub-second precision
605        assert_eq!(format_duration(Duration::from_millis(5050)), "5.05s");
606        assert_eq!(format_duration(Duration::from_millis(5100)), "5.1s");
607        assert_eq!(format_duration(Duration::from_millis(5001)), "5.001s");
608        assert_eq!(format_duration(Duration::from_millis(9999)), "9.999s");
609        assert_eq!(format_duration(Duration::from_millis(10000)), "10s");
610        assert_eq!(format_duration(Duration::from_millis(10001)), "10.001s");
611    }
612
613    #[test]
614    fn test_format_duration_go_parity_minutes() {
615        // Minutes and seconds
616        assert_eq!(format_duration(Duration::from_secs(60)), "1m0s");
617        assert_eq!(format_duration(Duration::from_secs(61)), "1m1s");
618        assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
619        assert_eq!(format_duration(Duration::from_secs(125)), "2m5s");
620        // Minutes with sub-second precision
621        assert_eq!(format_duration(Duration::from_millis(90500)), "1m30.5s");
622    }
623
624    #[test]
625    fn test_format_duration_go_parity_hours() {
626        // Hours, minutes, and seconds
627        assert_eq!(format_duration(Duration::from_secs(3600)), "1h0m0s");
628        assert_eq!(format_duration(Duration::from_secs(3665)), "1h1m5s");
629        assert_eq!(
630            format_duration(Duration::from_secs(100 * 3600 + 30 * 60 + 15)),
631            "100h30m15s"
632        );
633        // Hours with sub-second precision
634        assert_eq!(
635            format_duration(Duration::from_millis(3_600_500)),
636            "1h0m0.5s"
637        );
638    }
639}