1#![forbid(unsafe_code)]
2
3use crate::{StatefulWidget, Widget, 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 if !deg.render_content() {
166 return;
167 }
168
169 let style = if deg.apply_styling() {
170 if state.running {
171 self.running_style.unwrap_or(self.style)
172 } else {
173 self.stopped_style.unwrap_or(self.style)
174 }
175 } else {
176 Style::default()
177 };
178
179 let formatted = format_duration(state.elapsed, self.format);
180 let mut x = area.x;
181
182 if let Some(label) = self.label {
183 x = draw_text_span(frame, x, area.y, label, style, area.right());
184 if x < area.right() {
185 x = draw_text_span(frame, x, area.y, " ", style, area.right());
186 }
187 }
188
189 draw_text_span(frame, x, area.y, &formatted, style, area.right());
190 }
191}
192
193impl Widget for Stopwatch<'_> {
194 fn render(&self, area: Rect, frame: &mut Frame) {
195 let mut state = StopwatchState::new();
196 StatefulWidget::render(self, area, frame, &mut state);
197 }
198
199 fn is_essential(&self) -> bool {
200 true
201 }
202}
203
204pub(crate) fn format_duration(d: std::time::Duration, fmt: StopwatchFormat) -> String {
206 match fmt {
207 StopwatchFormat::Human => format_human(d),
208 StopwatchFormat::Digital => format_digital(d),
209 StopwatchFormat::Seconds => format_seconds(d),
210 }
211}
212
213fn format_human(d: std::time::Duration) -> String {
215 let total_nanos = d.as_nanos();
216 if total_nanos == 0 {
217 return "0s".to_string();
218 }
219
220 let total_secs = d.as_secs();
221 let subsec_nanos = d.subsec_nanos();
222
223 if total_secs == 0 {
225 let micros = d.as_micros();
226 if micros >= 1000 {
227 let millis = d.as_millis();
228 let remainder_micros = micros % 1000;
229 if remainder_micros == 0 {
230 return format!("{millis}ms");
231 }
232 let decimal = format!("{:06}", d.as_nanos() % 1_000_000);
233 let trimmed = decimal.trim_end_matches('0');
234 if trimmed.is_empty() {
235 return format!("{millis}ms");
236 }
237 return format!("{millis}.{trimmed}ms");
238 } else if micros >= 1 {
239 let nanos = d.as_nanos() % 1000;
240 if nanos == 0 {
241 return format!("{micros}µs");
242 }
243 let decimal = format!("{:03}", nanos);
244 let trimmed = decimal.trim_end_matches('0');
245 return format!("{micros}.{trimmed}µs");
246 } else {
247 return format!("{}ns", d.as_nanos());
248 }
249 }
250
251 let hours = total_secs / 3600;
252 let minutes = (total_secs % 3600) / 60;
253 let seconds = total_secs % 60;
254
255 let subsec_str = if subsec_nanos > 0 {
256 let decimal = format!("{subsec_nanos:09}");
257 let trimmed = decimal.trim_end_matches('0');
258 if trimmed.is_empty() {
259 String::new()
260 } else {
261 format!(".{trimmed}")
262 }
263 } else {
264 String::new()
265 };
266
267 if hours > 0 {
268 format!("{hours}h{minutes}m{seconds}{subsec_str}s")
269 } else if minutes > 0 {
270 format!("{minutes}m{seconds}{subsec_str}s")
271 } else {
272 format!("{seconds}{subsec_str}s")
273 }
274}
275
276fn format_digital(d: std::time::Duration) -> String {
278 let total_secs = d.as_secs();
279 let hours = total_secs / 3600;
280 let minutes = (total_secs % 3600) / 60;
281 let seconds = total_secs % 60;
282
283 if hours > 0 {
284 format!("{hours:02}:{minutes:02}:{seconds:02}")
285 } else {
286 format!("{minutes:02}:{seconds:02}")
287 }
288}
289
290fn format_seconds(d: std::time::Duration) -> String {
292 format!("{}s", d.as_secs())
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use ftui_render::buffer::Buffer;
299 use ftui_render::grapheme_pool::GraphemePool;
300 use std::time::Duration;
301
302 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
303 buf.get(x, y).and_then(|c| c.content.as_char())
304 }
305
306 fn render_to_string(widget: &Stopwatch, state: &mut StopwatchState, width: u16) -> String {
307 let mut pool = GraphemePool::new();
308 let mut frame = Frame::new(width, 1, &mut pool);
309 let area = Rect::new(0, 0, width, 1);
310 StatefulWidget::render(widget, area, &mut frame, state);
311 (0..width)
312 .filter_map(|x| cell_char(&frame.buffer, x, 0))
313 .collect::<String>()
314 .trim_end()
315 .to_string()
316 }
317
318 #[test]
321 fn state_default_is_zero_and_stopped() {
322 let state = StopwatchState::new();
323 assert_eq!(state.elapsed(), Duration::ZERO);
324 assert!(!state.running());
325 }
326
327 #[test]
328 fn state_start_stop() {
329 let mut state = StopwatchState::new();
330 state.start();
331 assert!(state.running());
332 state.stop();
333 assert!(!state.running());
334 }
335
336 #[test]
337 fn state_toggle() {
338 let mut state = StopwatchState::new();
339 state.toggle();
340 assert!(state.running());
341 state.toggle();
342 assert!(!state.running());
343 }
344
345 #[test]
346 fn state_tick_when_running() {
347 let mut state = StopwatchState::new();
348 state.start();
349 assert!(state.tick(Duration::from_secs(1)));
350 assert_eq!(state.elapsed(), Duration::from_secs(1));
351 assert!(state.tick(Duration::from_secs(2)));
352 assert_eq!(state.elapsed(), Duration::from_secs(3));
353 }
354
355 #[test]
356 fn state_tick_when_stopped_is_noop() {
357 let mut state = StopwatchState::new();
358 assert!(!state.tick(Duration::from_secs(1)));
359 assert_eq!(state.elapsed(), Duration::ZERO);
360 }
361
362 #[test]
363 fn state_reset() {
364 let mut state = StopwatchState::new();
365 state.start();
366 state.tick(Duration::from_secs(100));
367 state.reset();
368 assert_eq!(state.elapsed(), Duration::ZERO);
369 assert!(state.running()); }
371
372 #[test]
375 fn human_zero() {
376 assert_eq!(format_human(Duration::ZERO), "0s");
377 }
378
379 #[test]
380 fn human_seconds() {
381 assert_eq!(format_human(Duration::from_secs(45)), "45s");
382 }
383
384 #[test]
385 fn human_minutes_seconds() {
386 assert_eq!(format_human(Duration::from_secs(125)), "2m5s");
387 }
388
389 #[test]
390 fn human_hours_minutes_seconds() {
391 assert_eq!(format_human(Duration::from_secs(3665)), "1h1m5s");
392 }
393
394 #[test]
395 fn human_with_subseconds() {
396 assert_eq!(format_human(Duration::from_millis(5500)), "5.5s");
397 assert_eq!(format_human(Duration::from_millis(5001)), "5.001s");
398 }
399
400 #[test]
401 fn human_sub_second_ms() {
402 assert_eq!(format_human(Duration::from_millis(100)), "100ms");
403 assert_eq!(format_human(Duration::from_millis(1)), "1ms");
404 }
405
406 #[test]
407 fn human_sub_second_us() {
408 assert_eq!(format_human(Duration::from_micros(500)), "500µs");
409 }
410
411 #[test]
412 fn human_sub_second_ns() {
413 assert_eq!(format_human(Duration::from_nanos(123)), "123ns");
414 }
415
416 #[test]
417 fn human_large_hours() {
418 assert_eq!(
419 format_human(Duration::from_secs(100 * 3600 + 30 * 60 + 15)),
420 "100h30m15s"
421 );
422 }
423
424 #[test]
427 fn digital_zero() {
428 assert_eq!(format_digital(Duration::ZERO), "00:00");
429 }
430
431 #[test]
432 fn digital_seconds() {
433 assert_eq!(format_digital(Duration::from_secs(45)), "00:45");
434 }
435
436 #[test]
437 fn digital_minutes_seconds() {
438 assert_eq!(format_digital(Duration::from_secs(125)), "02:05");
439 }
440
441 #[test]
442 fn digital_hours() {
443 assert_eq!(format_digital(Duration::from_secs(3665)), "01:01:05");
444 }
445
446 #[test]
449 fn seconds_format() {
450 assert_eq!(format_seconds(Duration::ZERO), "0s");
451 assert_eq!(format_seconds(Duration::from_secs(5415)), "5415s");
452 }
453
454 #[test]
457 fn render_zero_area() {
458 let widget = Stopwatch::new();
459 let area = Rect::new(0, 0, 0, 0);
460 let mut pool = GraphemePool::new();
461 let mut frame = Frame::new(1, 1, &mut pool);
462 let mut state = StopwatchState::new();
463 StatefulWidget::render(&widget, area, &mut frame, &mut state);
464 }
466
467 #[test]
468 fn render_default_zero() {
469 let widget = Stopwatch::new();
470 let mut state = StopwatchState::new();
471 let text = render_to_string(&widget, &mut state, 20);
472 assert_eq!(text, "0s");
473 }
474
475 #[test]
476 fn render_elapsed_human() {
477 let widget = Stopwatch::new();
478 let mut state = StopwatchState {
479 elapsed: Duration::from_secs(125),
480 running: false,
481 };
482 let text = render_to_string(&widget, &mut state, 20);
483 assert_eq!(text, "2m5s");
484 }
485
486 #[test]
487 fn render_digital_format() {
488 let widget = Stopwatch::new().format(StopwatchFormat::Digital);
489 let mut state = StopwatchState {
490 elapsed: Duration::from_secs(3665),
491 running: false,
492 };
493 let text = render_to_string(&widget, &mut state, 20);
494 assert_eq!(text, "01:01:05");
495 }
496
497 #[test]
498 fn render_seconds_format() {
499 let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
500 let mut state = StopwatchState {
501 elapsed: Duration::from_secs(90),
502 running: false,
503 };
504 let text = render_to_string(&widget, &mut state, 20);
505 assert_eq!(text, "90s");
506 }
507
508 #[test]
509 fn render_with_label() {
510 let widget = Stopwatch::new().label("Elapsed:");
511 let mut state = StopwatchState {
512 elapsed: Duration::from_secs(45),
513 running: false,
514 };
515 let text = render_to_string(&widget, &mut state, 30);
516 assert_eq!(text, "Elapsed: 45s");
517 }
518
519 #[test]
520 fn render_clips_to_area() {
521 let widget = Stopwatch::new().format(StopwatchFormat::Digital);
522 let mut state = StopwatchState {
523 elapsed: Duration::from_secs(3665),
524 running: false,
525 };
526 let text = render_to_string(&widget, &mut state, 5);
528 assert_eq!(text, "01:01");
529 }
530
531 #[test]
532 fn stateless_render_shows_zero() {
533 let widget = Stopwatch::new();
534 let area = Rect::new(0, 0, 10, 1);
535 let mut pool = GraphemePool::new();
536 let mut frame = Frame::new(10, 1, &mut pool);
537 Widget::render(&widget, area, &mut frame);
538 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
539 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
540 }
541
542 #[test]
543 fn is_essential() {
544 let widget = Stopwatch::new();
545 assert!(widget.is_essential());
546 }
547
548 #[test]
551 fn degradation_skeleton_skips() {
552 use ftui_render::budget::DegradationLevel;
553
554 let widget = Stopwatch::new();
555 let area = Rect::new(0, 0, 20, 1);
556 let mut pool = GraphemePool::new();
557 let mut frame = Frame::new(20, 1, &mut pool);
558 frame.buffer.degradation = DegradationLevel::Skeleton;
559 let mut state = StopwatchState {
560 elapsed: Duration::from_secs(45),
561 running: false,
562 };
563 StatefulWidget::render(&widget, area, &mut frame, &mut state);
564 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
565 }
566
567 #[test]
568 fn degradation_no_styling_uses_default_style() {
569 use ftui_render::budget::DegradationLevel;
570
571 let widget = Stopwatch::new().style(Style::default().bold());
572 let area = Rect::new(0, 0, 20, 1);
573 let mut pool = GraphemePool::new();
574 let mut frame = Frame::new(20, 1, &mut pool);
575 frame.buffer.degradation = DegradationLevel::NoStyling;
576 let mut state = StopwatchState {
577 elapsed: Duration::from_secs(5),
578 running: false,
579 };
580 StatefulWidget::render(&widget, area, &mut frame, &mut state);
581 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('5'));
583 }
584}