Skip to main content

bubbles/
timer.rs

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