1use std::sync::atomic::{AtomicU64, Ordering};
18use std::time::Duration;
19
20use bubbletea::{Cmd, Message, Model};
21
22static NEXT_ID: AtomicU64 = AtomicU64::new(1);
24
25fn next_id() -> u64 {
26 NEXT_ID.fetch_add(1, Ordering::Relaxed)
27}
28
29#[derive(Debug, Clone, Copy)]
31pub struct StartStopMsg {
32 pub id: u64,
34 pub running: bool,
36}
37
38#[derive(Debug, Clone, Copy)]
40pub struct TickMsg {
41 pub id: u64,
43 pub timeout: bool,
45 tag: u64,
47}
48
49impl TickMsg {
50 #[must_use]
52 pub fn new(id: u64, timeout: bool, tag: u64) -> Self {
53 Self { id, timeout, tag }
54 }
55}
56
57#[derive(Debug, Clone, Copy)]
59pub struct TimeoutMsg {
60 pub id: u64,
62}
63
64#[derive(Debug, Clone)]
66pub struct Timer {
67 timeout: Duration,
69 interval: Duration,
71 id: u64,
73 tag: u64,
75 running: bool,
77}
78
79impl Timer {
80 #[must_use]
82 pub fn new(timeout: Duration) -> Self {
83 Self::with_interval(timeout, Duration::from_secs(1))
84 }
85
86 #[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 #[must_use]
100 pub fn id(&self) -> u64 {
101 self.id
102 }
103
104 #[must_use]
106 pub fn running(&self) -> bool {
107 if self.timed_out() {
108 return false;
109 }
110 self.running
111 }
112
113 #[must_use]
115 pub fn timed_out(&self) -> bool {
116 self.timeout.is_zero()
117 }
118
119 #[must_use]
121 pub fn remaining(&self) -> Duration {
122 self.timeout
123 }
124
125 #[must_use]
127 pub fn interval(&self) -> Duration {
128 self.interval
129 }
130
131 #[must_use]
133 pub fn init(&self) -> Option<Cmd> {
134 Some(self.tick_cmd())
135 }
136
137 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 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 pub fn toggle(&mut self) -> Option<Cmd> {
155 if self.running() {
156 self.stop()
157 } else {
158 self.start()
159 }
160 }
161
162 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 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
181 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 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 if tick.tag > 0 && tick.tag != self.tag {
198 return None;
199 }
200
201 self.timeout = self.timeout.saturating_sub(self.interval);
203 self.tag = self.tag.wrapping_add(1);
204
205 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 #[must_use]
223 pub fn view(&self) -> String {
224 format_duration(self.timeout)
225 }
226}
227
228impl 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 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 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 if tick.tag > 0 && tick.tag != self.tag {
252 return None;
253 }
254
255 self.timeout = self.timeout.saturating_sub(self.interval);
257 self.tag = self.tag.wrapping_add(1);
258
259 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
280fn format_duration(d: Duration) -> String {
288 let total_nanos = d.as_nanos();
289
290 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 if total_secs == 0 {
300 let micros = d.as_micros();
301 if micros >= 1000 {
302 let millis = d.as_millis();
304 let remainder_micros = micros % 1000;
305 if remainder_micros == 0 {
306 return format!("{}ms", millis);
307 }
308 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 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 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 let subsec_str = if subsec_nanos > 0 {
336 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 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 #[test]
455 fn test_model_trait_init_returns_cmd() {
456 let timer = Timer::new(Duration::from_secs(30));
457 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 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 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 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 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 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 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 #[test]
518 fn test_format_duration_go_parity_sub_second() {
519 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 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 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 assert_eq!(format_duration(Duration::from_millis(90500)), "1m30.5s");
547 }
548
549 #[test]
550 fn test_format_duration_go_parity_hours() {
551 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 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 let mut timer = Timer::with_interval(Duration::from_secs(5), Duration::from_secs(1));
569
570 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 let timer = Timer::new(Duration::ZERO);
591 assert!(timer.timed_out());
592 assert!(!timer.running());
593 assert_eq!(timer.view(), "0s");
594 }
595}