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 TickMsg {
32 pub id: u64,
34 tag: u64,
36}
37
38impl TickMsg {
39 #[must_use]
41 pub fn new(id: u64, tag: u64) -> Self {
42 Self { id, tag }
43 }
44}
45
46#[derive(Debug, Clone, Copy)]
48pub struct StartStopMsg {
49 pub id: u64,
51 pub running: bool,
53}
54
55#[derive(Debug, Clone, Copy)]
57pub struct ResetMsg {
58 pub id: u64,
60}
61
62#[derive(Debug, Clone)]
64pub struct Stopwatch {
65 elapsed: Duration,
67 interval: Duration,
69 id: u64,
71 tag: u64,
73 running: bool,
75}
76
77impl Default for Stopwatch {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl Stopwatch {
84 #[must_use]
86 pub fn new() -> Self {
87 Self::with_interval(Duration::from_secs(1))
88 }
89
90 #[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 #[must_use]
104 pub fn id(&self) -> u64 {
105 self.id
106 }
107
108 #[must_use]
110 pub fn running(&self) -> bool {
111 self.running
112 }
113
114 #[must_use]
116 pub fn elapsed(&self) -> Duration {
117 self.elapsed
118 }
119
120 #[must_use]
122 pub fn interval(&self) -> Duration {
123 self.interval
124 }
125
126 #[must_use]
128 pub fn init(&self) -> Option<Cmd> {
129 self.start_cmd()
130 }
131
132 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 pub fn start(&self) -> Option<Cmd> {
151 self.start_cmd()
152 }
153
154 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 pub fn toggle(&self) -> Option<Cmd> {
164 if self.running() {
165 self.stop()
166 } else {
167 self.start()
168 }
169 }
170
171 pub fn reset(&self) -> Option<Cmd> {
173 let id = self.id;
174 Some(Cmd::new(move || Message::new(ResetMsg { id })))
175 }
176
177 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 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
191 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 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 if let Some(tick) = msg.downcast_ref::<TickMsg>() {
211 if !self.running || tick.id != self.id {
212 return None;
213 }
214
215 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 #[must_use]
230 pub fn view(&self) -> String {
231 format_duration(self.elapsed)
232 }
233}
234
235fn format_duration(d: Duration) -> String {
243 let total_nanos = d.as_nanos();
244
245 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 if total_secs == 0 {
255 let micros = d.as_micros();
256 if micros >= 1000 {
257 let millis = d.as_millis();
259 let remainder_micros = micros % 1000;
260 if remainder_micros == 0 {
261 return format!("{}ms", millis);
262 }
263 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 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 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 let subsec_str = if subsec_nanos > 0 {
291 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
320impl 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 let msg = Message::new(StartStopMsg {
367 id: sw.id(),
368 running: true,
369 });
370 sw.update(msg);
371 assert!(sw.running());
372
373 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 #[test]
439 fn test_stopwatch_model_init_returns_cmd() {
440 let sw = Stopwatch::new();
441 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 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()); 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 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 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 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 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 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; 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 #[test]
593 fn test_format_duration_go_parity_sub_second() {
594 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 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 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 assert_eq!(format_duration(Duration::from_millis(90500)), "1m30.5s");
622 }
623
624 #[test]
625 fn test_format_duration_go_parity_hours() {
626 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 assert_eq!(
635 format_duration(Duration::from_millis(3_600_500)),
636 "1h0m0.5s"
637 );
638 }
639}