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