#![forbid(unsafe_code)]
use crate::{StatefulWidget, Widget, clear_text_row, draw_text_span};
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_style::Style;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StopwatchFormat {
#[default]
Human,
Digital,
Seconds,
}
#[derive(Debug, Clone)]
pub struct StopwatchState {
elapsed: std::time::Duration,
running: bool,
}
impl Default for StopwatchState {
fn default() -> Self {
Self::new()
}
}
impl StopwatchState {
pub fn new() -> Self {
Self {
elapsed: std::time::Duration::ZERO,
running: false,
}
}
pub fn elapsed(&self) -> std::time::Duration {
self.elapsed
}
pub fn running(&self) -> bool {
self.running
}
pub fn start(&mut self) {
self.running = true;
}
pub fn stop(&mut self) {
self.running = false;
}
pub fn toggle(&mut self) {
self.running = !self.running;
}
pub fn reset(&mut self) {
self.elapsed = std::time::Duration::ZERO;
}
pub fn tick(&mut self, delta: std::time::Duration) -> bool {
if self.running {
self.elapsed += delta;
true
} else {
false
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Stopwatch<'a> {
format: StopwatchFormat,
style: Style,
running_style: Option<Style>,
stopped_style: Option<Style>,
label: Option<&'a str>,
}
impl<'a> Stopwatch<'a> {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn format(mut self, format: StopwatchFormat) -> Self {
self.format = format;
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn running_style(mut self, style: Style) -> Self {
self.running_style = Some(style);
self
}
#[must_use]
pub fn stopped_style(mut self, style: Style) -> Self {
self.stopped_style = Some(style);
self
}
#[must_use]
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
}
impl StatefulWidget for Stopwatch<'_> {
type State = StopwatchState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
if area.is_empty() || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
let style = if deg.apply_styling() {
if state.running {
self.running_style.unwrap_or(self.style)
} else {
self.stopped_style.unwrap_or(self.style)
}
} else {
Style::default()
};
clear_text_row(frame, area, style);
let formatted = format_duration(state.elapsed, self.format);
let mut x = area.x;
if let Some(label) = self.label {
x = draw_text_span(frame, x, area.y, label, style, area.right());
if x < area.right() {
x = draw_text_span(frame, x, area.y, " ", style, area.right());
}
}
draw_text_span(frame, x, area.y, &formatted, style, area.right());
}
}
impl Widget for Stopwatch<'_> {
fn render(&self, area: Rect, frame: &mut Frame) {
let mut state = StopwatchState::new();
StatefulWidget::render(self, area, frame, &mut state);
}
fn is_essential(&self) -> bool {
true
}
}
pub(crate) fn format_duration(d: std::time::Duration, fmt: StopwatchFormat) -> String {
match fmt {
StopwatchFormat::Human => format_human(d),
StopwatchFormat::Digital => format_digital(d),
StopwatchFormat::Seconds => format_seconds(d),
}
}
fn format_human(d: std::time::Duration) -> String {
let total_nanos = d.as_nanos();
if total_nanos == 0 {
return "0s".to_string();
}
let total_secs = d.as_secs();
let subsec_nanos = d.subsec_nanos();
if total_secs == 0 {
let micros = d.as_micros();
if micros >= 1000 {
let millis = d.as_millis();
let remainder_micros = micros % 1000;
if remainder_micros == 0 {
return format!("{millis}ms");
}
let decimal = format!("{:06}", d.as_nanos() % 1_000_000);
let trimmed = decimal.trim_end_matches('0');
if trimmed.is_empty() {
return format!("{millis}ms");
}
return format!("{millis}.{trimmed}ms");
} else if micros >= 1 {
let nanos = d.as_nanos() % 1000;
if nanos == 0 {
return format!("{micros}µs");
}
let decimal = format!("{:03}", nanos);
let trimmed = decimal.trim_end_matches('0');
return format!("{micros}.{trimmed}µs");
} else {
return format!("{}ns", d.as_nanos());
}
}
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
let subsec_str = if subsec_nanos > 0 {
let decimal = format!("{subsec_nanos:09}");
let trimmed = decimal.trim_end_matches('0');
if trimmed.is_empty() {
String::new()
} else {
format!(".{trimmed}")
}
} else {
String::new()
};
if hours > 0 {
format!("{hours}h{minutes}m{seconds}{subsec_str}s")
} else if minutes > 0 {
format!("{minutes}m{seconds}{subsec_str}s")
} else {
format!("{seconds}{subsec_str}s")
}
}
fn format_digital(d: std::time::Duration) -> String {
let total_secs = d.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{hours:02}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes:02}:{seconds:02}")
}
}
fn format_seconds(d: std::time::Duration) -> String {
format!("{}s", d.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::buffer::Buffer;
use ftui_render::cell::Cell;
use ftui_render::grapheme_pool::GraphemePool;
use std::time::Duration;
fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
buf.get(x, y).and_then(|c| c.content.as_char())
}
fn render_to_string(widget: &Stopwatch, state: &mut StopwatchState, width: u16) -> String {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(width, 1, &mut pool);
let area = Rect::new(0, 0, width, 1);
StatefulWidget::render(widget, area, &mut frame, state);
(0..width)
.filter_map(|x| cell_char(&frame.buffer, x, 0))
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn state_default_is_zero_and_stopped() {
let state = StopwatchState::new();
assert_eq!(state.elapsed(), Duration::ZERO);
assert!(!state.running());
}
#[test]
fn state_start_stop() {
let mut state = StopwatchState::new();
state.start();
assert!(state.running());
state.stop();
assert!(!state.running());
}
#[test]
fn state_toggle() {
let mut state = StopwatchState::new();
state.toggle();
assert!(state.running());
state.toggle();
assert!(!state.running());
}
#[test]
fn state_tick_when_running() {
let mut state = StopwatchState::new();
state.start();
assert!(state.tick(Duration::from_secs(1)));
assert_eq!(state.elapsed(), Duration::from_secs(1));
assert!(state.tick(Duration::from_secs(2)));
assert_eq!(state.elapsed(), Duration::from_secs(3));
}
#[test]
fn state_tick_when_stopped_is_noop() {
let mut state = StopwatchState::new();
assert!(!state.tick(Duration::from_secs(1)));
assert_eq!(state.elapsed(), Duration::ZERO);
}
#[test]
fn state_reset() {
let mut state = StopwatchState::new();
state.start();
state.tick(Duration::from_secs(100));
state.reset();
assert_eq!(state.elapsed(), Duration::ZERO);
assert!(state.running()); }
#[test]
fn human_zero() {
assert_eq!(format_human(Duration::ZERO), "0s");
}
#[test]
fn human_seconds() {
assert_eq!(format_human(Duration::from_secs(45)), "45s");
}
#[test]
fn human_minutes_seconds() {
assert_eq!(format_human(Duration::from_secs(125)), "2m5s");
}
#[test]
fn human_hours_minutes_seconds() {
assert_eq!(format_human(Duration::from_secs(3665)), "1h1m5s");
}
#[test]
fn human_with_subseconds() {
assert_eq!(format_human(Duration::from_millis(5500)), "5.5s");
assert_eq!(format_human(Duration::from_millis(5001)), "5.001s");
}
#[test]
fn human_sub_second_ms() {
assert_eq!(format_human(Duration::from_millis(100)), "100ms");
assert_eq!(format_human(Duration::from_millis(1)), "1ms");
}
#[test]
fn human_sub_second_us() {
assert_eq!(format_human(Duration::from_micros(500)), "500µs");
}
#[test]
fn human_sub_second_ns() {
assert_eq!(format_human(Duration::from_nanos(123)), "123ns");
}
#[test]
fn human_large_hours() {
assert_eq!(
format_human(Duration::from_secs(100 * 3600 + 30 * 60 + 15)),
"100h30m15s"
);
}
#[test]
fn digital_zero() {
assert_eq!(format_digital(Duration::ZERO), "00:00");
}
#[test]
fn digital_seconds() {
assert_eq!(format_digital(Duration::from_secs(45)), "00:45");
}
#[test]
fn digital_minutes_seconds() {
assert_eq!(format_digital(Duration::from_secs(125)), "02:05");
}
#[test]
fn digital_hours() {
assert_eq!(format_digital(Duration::from_secs(3665)), "01:01:05");
}
#[test]
fn seconds_format() {
assert_eq!(format_seconds(Duration::ZERO), "0s");
assert_eq!(format_seconds(Duration::from_secs(5415)), "5415s");
}
#[test]
fn render_zero_area() {
let widget = Stopwatch::new();
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
let mut state = StopwatchState::new();
StatefulWidget::render(&widget, area, &mut frame, &mut state);
}
#[test]
fn render_default_zero() {
let widget = Stopwatch::new();
let mut state = StopwatchState::new();
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "0s");
}
#[test]
fn render_elapsed_human() {
let widget = Stopwatch::new();
let mut state = StopwatchState {
elapsed: Duration::from_secs(125),
running: false,
};
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "2m5s");
}
#[test]
fn render_digital_format() {
let widget = Stopwatch::new().format(StopwatchFormat::Digital);
let mut state = StopwatchState {
elapsed: Duration::from_secs(3665),
running: false,
};
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "01:01:05");
}
#[test]
fn render_seconds_format() {
let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
let mut state = StopwatchState {
elapsed: Duration::from_secs(90),
running: false,
};
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "90s");
}
#[test]
fn render_with_label() {
let widget = Stopwatch::new().label("Elapsed:");
let mut state = StopwatchState {
elapsed: Duration::from_secs(45),
running: false,
};
let text = render_to_string(&widget, &mut state, 30);
assert_eq!(text, "Elapsed: 45s");
}
#[test]
fn render_clips_to_area() {
let widget = Stopwatch::new().format(StopwatchFormat::Digital);
let mut state = StopwatchState {
elapsed: Duration::from_secs(3665),
running: false,
};
let text = render_to_string(&widget, &mut state, 5);
assert_eq!(text, "01:01");
}
#[test]
fn stateless_render_shows_zero() {
let widget = Stopwatch::new();
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
Widget::render(&widget, area, &mut frame);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
}
#[test]
fn render_clears_stale_suffix_cells() {
let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
let area = Rect::new(0, 0, 6, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
frame.buffer.set_fast(4, 0, Cell::from_char('X'));
let mut state = StopwatchState {
elapsed: Duration::from_secs(90),
running: false,
};
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 4, 0), Some(' '));
}
#[test]
fn is_essential() {
let widget = Stopwatch::new();
assert!(widget.is_essential());
}
#[test]
fn degradation_skeleton_renders_essential_text() {
use ftui_render::budget::DegradationLevel;
let widget = Stopwatch::new();
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::Skeleton;
let mut state = StopwatchState {
elapsed: Duration::from_secs(45),
running: false,
};
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('4'));
assert_eq!(cell_char(&frame.buffer, 1, 0), Some('5'));
}
#[test]
fn skeleton_zero_stopwatch_clears_stale_row() {
use ftui_render::budget::DegradationLevel;
let widget = Stopwatch::new().format(StopwatchFormat::Seconds);
let area = Rect::new(0, 0, 6, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
let mut populated = StopwatchState {
elapsed: Duration::from_secs(90),
running: false,
};
let mut empty = StopwatchState::default();
StatefulWidget::render(&widget, area, &mut frame, &mut populated);
frame.buffer.degradation = DegradationLevel::Skeleton;
StatefulWidget::render(&widget, area, &mut frame, &mut empty);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
assert_eq!(cell_char(&frame.buffer, 2, 0), Some(' '));
}
#[test]
fn degradation_no_styling_uses_default_style() {
use ftui_render::budget::DegradationLevel;
let widget = Stopwatch::new().style(Style::default().bold());
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
let mut state = StopwatchState {
elapsed: Duration::from_secs(5),
running: false,
};
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('5'));
}
}