1#![forbid(unsafe_code)]
2
3use crate::{StatefulWidget, Widget, clear_text_row, draw_text_span};
24use ftui_core::geometry::Rect;
25use ftui_render::frame::Frame;
26use ftui_style::Style;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum StopwatchFormat {
31 #[default]
33 Human,
34 Digital,
36 Seconds,
38}
39
40#[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 pub fn new() -> Self {
56 Self {
57 elapsed: std::time::Duration::ZERO,
58 running: false,
59 }
60 }
61
62 pub fn elapsed(&self) -> std::time::Duration {
64 self.elapsed
65 }
66
67 pub fn running(&self) -> bool {
69 self.running
70 }
71
72 pub fn start(&mut self) {
74 self.running = true;
75 }
76
77 pub fn stop(&mut self) {
79 self.running = false;
80 }
81
82 pub fn toggle(&mut self) {
84 self.running = !self.running;
85 }
86
87 pub fn reset(&mut self) {
89 self.elapsed = std::time::Duration::ZERO;
90 }
91
92 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#[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 pub fn new() -> Self {
117 Self::default()
118 }
119
120 #[must_use]
122 pub fn format(mut self, format: StopwatchFormat) -> Self {
123 self.format = format;
124 self
125 }
126
127 #[must_use]
129 pub fn style(mut self, style: Style) -> Self {
130 self.style = style;
131 self
132 }
133
134 #[must_use]
136 pub fn running_style(mut self, style: Style) -> Self {
137 self.running_style = Some(style);
138 self
139 }
140
141 #[must_use]
143 pub fn stopped_style(mut self, style: Style) -> Self {
144 self.stopped_style = Some(style);
145 self
146 }
147
148 #[must_use]
150 pub fn label(mut self, label: &'a str) -> Self {
151 self.label = Some(label);
152 self
153 }
154}
155
156impl StatefulWidget for Stopwatch<'_> {
157 type State = StopwatchState;
158
159 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
160 if area.is_empty() || area.height == 0 {
161 return;
162 }
163
164 let deg = frame.buffer.degradation;
165 let style = if deg.apply_styling() {
169 if state.running {
170 self.running_style.unwrap_or(self.style)
171 } else {
172 self.stopped_style.unwrap_or(self.style)
173 }
174 } else {
175 Style::default()
176 };
177
178 clear_text_row(frame, area, style);
179
180 let formatted = format_duration(state.elapsed, self.format);
181 let mut x = area.x;
182
183 if let Some(label) = self.label {
184 x = draw_text_span(frame, x, area.y, label, style, area.right());
185 if x < area.right() {
186 x = draw_text_span(frame, x, area.y, " ", style, area.right());
187 }
188 }
189
190 draw_text_span(frame, x, area.y, &formatted, style, area.right());
191 }
192}
193
194impl Widget for Stopwatch<'_> {
195 fn render(&self, area: Rect, frame: &mut Frame) {
196 let mut state = StopwatchState::new();
197 StatefulWidget::render(self, area, frame, &mut state);
198 }
199
200 fn is_essential(&self) -> bool {
201 true
202 }
203}
204
205pub(crate) fn format_duration(d: std::time::Duration, fmt: StopwatchFormat) -> String {
207 match fmt {
208 StopwatchFormat::Human => format_human(d),
209 StopwatchFormat::Digital => format_digital(d),
210 StopwatchFormat::Seconds => format_seconds(d),
211 }
212}
213
214fn format_human(d: std::time::Duration) -> String {
216 let total_nanos = d.as_nanos();
217 if total_nanos == 0 {
218 return "0s".to_string();
219 }
220
221 let total_secs = d.as_secs();
222 let subsec_nanos = d.subsec_nanos();
223
224 if total_secs == 0 {
226 let micros = d.as_micros();
227 if micros >= 1000 {
228 let millis = d.as_millis();
229 let remainder_micros = micros % 1000;
230 if remainder_micros == 0 {
231 return format!("{millis}ms");
232 }
233 let decimal = format!("{:06}", d.as_nanos() % 1_000_000);
234 let trimmed = decimal.trim_end_matches('0');
235 if trimmed.is_empty() {
236 return format!("{millis}ms");
237 }
238 return format!("{millis}.{trimmed}ms");
239 } else if micros >= 1 {
240 let nanos = d.as_nanos() % 1000;
241 if nanos == 0 {
242 return format!("{micros}µs");
243 }
244 let decimal = format!("{:03}", nanos);
245 let trimmed = decimal.trim_end_matches('0');
246 return format!("{micros}.{trimmed}µs");
247 } else {
248 return format!("{}ns", d.as_nanos());
249 }
250 }
251
252 let hours = total_secs / 3600;
253 let minutes = (total_secs % 3600) / 60;
254 let seconds = total_secs % 60;
255
256 let subsec_str = if subsec_nanos > 0 {
257 let decimal = format!("{subsec_nanos:09}");
258 let trimmed = decimal.trim_end_matches('0');
259 if trimmed.is_empty() {
260 String::new()
261 } else {
262 format!(".{trimmed}")
263 }
264 } else {
265 String::new()
266 };
267
268 if hours > 0 {
269 format!("{hours}h{minutes}m{seconds}{subsec_str}s")
270 } else if minutes > 0 {
271 format!("{minutes}m{seconds}{subsec_str}s")
272 } else {
273 format!("{seconds}{subsec_str}s")
274 }
275}
276
277fn format_digital(d: std::time::Duration) -> String {
279 let total_secs = d.as_secs();
280 let hours = total_secs / 3600;
281 let minutes = (total_secs % 3600) / 60;
282 let seconds = total_secs % 60;
283
284 if hours > 0 {
285 format!("{hours:02}:{minutes:02}:{seconds:02}")
286 } else {
287 format!("{minutes:02}:{seconds:02}")
288 }
289}
290
291fn format_seconds(d: std::time::Duration) -> String {
293 format!("{}s", d.as_secs())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use ftui_render::buffer::Buffer;
300 use ftui_render::cell::Cell;
301 use ftui_render::grapheme_pool::GraphemePool;
302 use std::time::Duration;
303
304 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
305 buf.get(x, y).and_then(|c| c.content.as_char())
306 }
307
308 fn render_to_string(widget: &Stopwatch, state: &mut StopwatchState, width: u16) -> String {
309 let mut pool = GraphemePool::new();
310 let mut frame = Frame::new(width, 1, &mut pool);
311 let area = Rect::new(0, 0, width, 1);
312 StatefulWidget::render(widget, area, &mut frame, state);
313 (0..width)
314 .filter_map(|x| cell_char(&frame.buffer, x, 0))
315 .collect::<String>()
316 .trim_end()
317 .to_string()
318 }
319
320 #[test]
323 fn state_default_is_zero_and_stopped() {
324 let state = StopwatchState::new();
325 assert_eq!(state.elapsed(), Duration::ZERO);
326 assert!(!state.running());
327 }
328
329 #[test]
330 fn state_start_stop() {
331 let mut state = StopwatchState::new();
332 state.start();
333 assert!(state.running());
334 state.stop();
335 assert!(!state.running());
336 }
337
338 #[test]
339 fn state_toggle() {
340 let mut state = StopwatchState::new();
341 state.toggle();
342 assert!(state.running());
343 state.toggle();
344 assert!(!state.running());
345 }
346
347 #[test]
348 fn state_tick_when_running() {
349 let mut state = StopwatchState::new();
350 state.start();
351 assert!(state.tick(Duration::from_secs(1)));
352 assert_eq!(state.elapsed(), Duration::from_secs(1));
353 assert!(state.tick(Duration::from_secs(2)));
354 assert_eq!(state.elapsed(), Duration::from_secs(3));
355 }
356
357 #[test]
358 fn state_tick_when_stopped_is_noop() {
359 let mut state = StopwatchState::new();
360 assert!(!state.tick(Duration::from_secs(1)));
361 assert_eq!(state.elapsed(), Duration::ZERO);
362 }
363
364 #[test]
365 fn state_reset() {
366 let mut state = StopwatchState::new();
367 state.start();
368 state.tick(Duration::from_secs(100));
369 state.reset();
370 assert_eq!(state.elapsed(), Duration::ZERO);
371 assert!(state.running()); }
373
374 #[test]
377 fn human_zero() {
378 assert_eq!(format_human(Duration::ZERO), "0s");
379 }
380
381 #[test]
382 fn human_seconds() {
383 assert_eq!(format_human(Duration::from_secs(45)), "45s");
384 }
385
386 #[test]
387 fn human_minutes_seconds() {
388 assert_eq!(format_human(Duration::from_secs(125)), "2m5s");
389 }
390
391 #[test]
392 fn human_hours_minutes_seconds() {
393 assert_eq!(format_human(Duration::from_secs(3665)), "1h1m5s");
394 }
395
396 #[test]
397 fn human_with_subseconds() {
398 assert_eq!(format_human(Duration::from_millis(5500)), "5.5s");
399 assert_eq!(format_human(Duration::from_millis(5001)), "5.001s");
400 }
401
402 #[test]
403 fn human_sub_second_ms() {
404 assert_eq!(format_human(Duration::from_millis(100)), "100ms");
405 assert_eq!(format_human(Duration::from_millis(1)), "1ms");
406 }
407
408 #[test]
409 fn human_sub_second_us() {
410 assert_eq!(format_human(Duration::from_micros(500)), "500µs");
411 }
412
413 #[test]
414 fn human_sub_second_ns() {
415 assert_eq!(format_human(Duration::from_nanos(123)), "123ns");
416 }
417
418 #[test]
419 fn human_large_hours() {
420 assert_eq!(
421 format_human(Duration::from_secs(100 * 3600 + 30 * 60 + 15)),
422 "100h30m15s"
423 );
424 }
425
426 #[test]
429 fn digital_zero() {
430 assert_eq!(format_digital(Duration::ZERO), "00:00");
431 }
432
433 #[test]
434 fn digital_seconds() {
435 assert_eq!(format_digital(Duration::from_secs(45)), "00:45");
436 }
437
438 #[test]
439 fn digital_minutes_seconds() {
440 assert_eq!(format_digital(Duration::from_secs(125)), "02:05");
441 }
442
443 #[test]
444 fn digital_hours() {
445 assert_eq!(format_digital(Duration::from_secs(3665)), "01:01:05");
446 }
447
448 #[test]
451 fn seconds_format() {
452 assert_eq!(format_seconds(Duration::ZERO), "0s");
453 assert_eq!(format_seconds(Duration::from_secs(5415)), "5415s");
454 }
455
456 #[test]
459 fn render_zero_area() {
460 let widget = Stopwatch::new();
461 let area = Rect::new(0, 0, 0, 0);
462 let mut pool = GraphemePool::new();
463 let mut frame = Frame::new(1, 1, &mut pool);
464 let mut state = StopwatchState::new();
465 StatefulWidget::render(&widget, area, &mut frame, &mut state);
466 }
468
469 #[test]
470 fn render_default_zero() {
471 let widget = Stopwatch::new();
472 let mut state = StopwatchState::new();
473 let text = render_to_string(&widget, &mut state, 20);
474 assert_eq!(text, "0s");
475 }
476
477 #[test]
478 fn render_elapsed_human() {
479 let widget = Stopwatch::new();
480 let mut state = StopwatchState {
481 elapsed: Duration::from_secs(125),
482 running: false,
483 };
484 let text = render_to_string(&widget, &mut state, 20);
485 assert_eq!(text, "2m5s");
486 }
487
488 #[test]
489 fn render_digital_format() {
490 let widget = Stopwatch::new().format(StopwatchFormat::Digital);
491 let mut state = StopwatchState {
492 elapsed: Duration::from_secs(3665),
493 running: false,
494 };
495 let text = render_to_string(&widget, &mut state, 20);
496 assert_eq!(text, "01:01:05");
497 }
498
499 #[test]
500 fn render_seconds_format() {
501 let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
502 let mut state = StopwatchState {
503 elapsed: Duration::from_secs(90),
504 running: false,
505 };
506 let text = render_to_string(&widget, &mut state, 20);
507 assert_eq!(text, "90s");
508 }
509
510 #[test]
511 fn render_with_label() {
512 let widget = Stopwatch::new().label("Elapsed:");
513 let mut state = StopwatchState {
514 elapsed: Duration::from_secs(45),
515 running: false,
516 };
517 let text = render_to_string(&widget, &mut state, 30);
518 assert_eq!(text, "Elapsed: 45s");
519 }
520
521 #[test]
522 fn render_clips_to_area() {
523 let widget = Stopwatch::new().format(StopwatchFormat::Digital);
524 let mut state = StopwatchState {
525 elapsed: Duration::from_secs(3665),
526 running: false,
527 };
528 let text = render_to_string(&widget, &mut state, 5);
530 assert_eq!(text, "01:01");
531 }
532
533 #[test]
534 fn stateless_render_shows_zero() {
535 let widget = Stopwatch::new();
536 let area = Rect::new(0, 0, 10, 1);
537 let mut pool = GraphemePool::new();
538 let mut frame = Frame::new(10, 1, &mut pool);
539 Widget::render(&widget, area, &mut frame);
540 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
541 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
542 }
543
544 #[test]
545 fn render_clears_stale_suffix_cells() {
546 let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
547 let area = Rect::new(0, 0, 6, 1);
548 let mut pool = GraphemePool::new();
549 let mut frame = Frame::new(6, 1, &mut pool);
550 frame.buffer.set_fast(4, 0, Cell::from_char('X'));
551 let mut state = StopwatchState {
552 elapsed: Duration::from_secs(90),
553 running: false,
554 };
555
556 StatefulWidget::render(&widget, area, &mut frame, &mut state);
557
558 assert_eq!(cell_char(&frame.buffer, 4, 0), Some(' '));
559 }
560
561 #[test]
562 fn is_essential() {
563 let widget = Stopwatch::new();
564 assert!(widget.is_essential());
565 }
566
567 #[test]
570 fn degradation_skeleton_renders_essential_text() {
571 use ftui_render::budget::DegradationLevel;
572
573 let widget = Stopwatch::new();
574 let area = Rect::new(0, 0, 20, 1);
575 let mut pool = GraphemePool::new();
576 let mut frame = Frame::new(20, 1, &mut pool);
577 frame.buffer.degradation = DegradationLevel::Skeleton;
578 let mut state = StopwatchState {
579 elapsed: Duration::from_secs(45),
580 running: false,
581 };
582 StatefulWidget::render(&widget, area, &mut frame, &mut state);
583 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('4'));
584 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('5'));
585 }
586
587 #[test]
588 fn skeleton_zero_stopwatch_clears_stale_row() {
589 use ftui_render::budget::DegradationLevel;
590
591 let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
592 let area = Rect::new(0, 0, 6, 1);
593 let mut pool = GraphemePool::new();
594 let mut frame = Frame::new(6, 1, &mut pool);
595 let mut populated = StopwatchState {
596 elapsed: Duration::from_secs(90),
597 running: false,
598 };
599 let mut empty = StopwatchState::default();
600
601 StatefulWidget::render(&widget, area, &mut frame, &mut populated);
602 frame.buffer.degradation = DegradationLevel::Skeleton;
603 StatefulWidget::render(&widget, area, &mut frame, &mut empty);
604
605 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
606 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
607 assert_eq!(cell_char(&frame.buffer, 2, 0), Some(' '));
608 }
609
610 #[test]
611 fn degradation_no_styling_uses_default_style() {
612 use ftui_render::budget::DegradationLevel;
613
614 let widget = Stopwatch::new().style(Style::default().bold());
615 let area = Rect::new(0, 0, 20, 1);
616 let mut pool = GraphemePool::new();
617 let mut frame = Frame::new(20, 1, &mut pool);
618 frame.buffer.degradation = DegradationLevel::NoStyling;
619 let mut state = StopwatchState {
620 elapsed: Duration::from_secs(5),
621 running: false,
622 };
623 StatefulWidget::render(&widget, area, &mut frame, &mut state);
624 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('5'));
626 }
627}