#![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;
pub use crate::stopwatch::StopwatchFormat as TimerFormat;
#[derive(Debug, Clone)]
pub struct TimerState {
duration: std::time::Duration,
remaining: std::time::Duration,
running: bool,
}
impl TimerState {
pub fn new(duration: std::time::Duration) -> Self {
Self {
duration,
remaining: duration,
running: false,
}
}
#[inline]
pub fn duration(&self) -> std::time::Duration {
self.duration
}
#[inline]
pub fn remaining(&self) -> std::time::Duration {
self.remaining
}
#[inline]
pub fn running(&self) -> bool {
self.running && !self.finished()
}
#[inline]
pub fn finished(&self) -> bool {
self.remaining.is_zero()
}
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.remaining = self.duration;
}
pub fn set_duration(&mut self, duration: std::time::Duration) {
self.duration = duration;
self.remaining = duration;
}
pub fn tick(&mut self, delta: std::time::Duration) -> bool {
if self.running && !self.finished() {
self.remaining = self.remaining.saturating_sub(delta);
true
} else {
false
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Timer<'a> {
format: TimerFormat,
style: Style,
running_style: Option<Style>,
finished_style: Option<Style>,
label: Option<&'a str>,
}
impl<'a> Timer<'a> {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn format(mut self, format: TimerFormat) -> 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 finished_style(mut self, style: Style) -> Self {
self.finished_style = Some(style);
self
}
#[must_use]
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
}
impl StatefulWidget for Timer<'_> {
type State = TimerState;
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.finished() {
self.finished_style.unwrap_or(self.style)
} else if state.running() {
self.running_style.unwrap_or(self.style)
} else {
self.style
}
} else {
Style::default()
};
clear_text_row(frame, area, style);
let formatted = crate::stopwatch::format_duration(state.remaining, 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 Timer<'_> {
fn render(&self, area: Rect, frame: &mut Frame) {
let mut state = TimerState::new(std::time::Duration::ZERO);
StatefulWidget::render(self, area, frame, &mut state);
}
fn is_essential(&self) -> bool {
true
}
}
#[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: &Timer, state: &mut TimerState, 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_new() {
let state = TimerState::new(Duration::from_secs(60));
assert_eq!(state.duration(), Duration::from_secs(60));
assert_eq!(state.remaining(), Duration::from_secs(60));
assert!(!state.running());
assert!(!state.finished());
}
#[test]
fn state_start_stop() {
let mut state = TimerState::new(Duration::from_secs(10));
state.start();
assert!(state.running());
state.stop();
assert!(!state.running());
}
#[test]
fn state_toggle() {
let mut state = TimerState::new(Duration::from_secs(10));
state.toggle();
assert!(state.running());
state.toggle();
assert!(!state.running());
}
#[test]
fn state_tick_counts_down() {
let mut state = TimerState::new(Duration::from_secs(10));
state.start();
assert!(state.tick(Duration::from_secs(3)));
assert_eq!(state.remaining(), Duration::from_secs(7));
}
#[test]
fn state_tick_when_stopped_is_noop() {
let mut state = TimerState::new(Duration::from_secs(10));
assert!(!state.tick(Duration::from_secs(1)));
assert_eq!(state.remaining(), Duration::from_secs(10));
}
#[test]
fn state_tick_saturates_at_zero() {
let mut state = TimerState::new(Duration::from_secs(2));
state.start();
state.tick(Duration::from_secs(5));
assert_eq!(state.remaining(), Duration::ZERO);
assert!(state.finished());
}
#[test]
fn state_finished_stops_running() {
let mut state = TimerState::new(Duration::from_secs(1));
state.start();
state.tick(Duration::from_secs(1));
assert!(state.finished());
assert!(!state.running()); }
#[test]
fn state_tick_after_finished_is_noop() {
let mut state = TimerState::new(Duration::from_secs(1));
state.start();
state.tick(Duration::from_secs(1));
assert!(!state.tick(Duration::from_secs(1)));
assert_eq!(state.remaining(), Duration::ZERO);
}
#[test]
fn state_reset() {
let mut state = TimerState::new(Duration::from_secs(60));
state.start();
state.tick(Duration::from_secs(30));
state.reset();
assert_eq!(state.remaining(), Duration::from_secs(60));
}
#[test]
fn state_set_duration() {
let mut state = TimerState::new(Duration::from_secs(60));
state.start();
state.tick(Duration::from_secs(10));
state.set_duration(Duration::from_secs(120));
assert_eq!(state.duration(), Duration::from_secs(120));
assert_eq!(state.remaining(), Duration::from_secs(120));
}
#[test]
fn state_zero_duration_is_finished() {
let state = TimerState::new(Duration::ZERO);
assert!(state.finished());
assert!(!state.running());
}
#[test]
fn render_zero_area() {
let widget = Timer::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 = TimerState::new(Duration::from_secs(60));
StatefulWidget::render(&widget, area, &mut frame, &mut state);
}
#[test]
fn render_remaining_human() {
let widget = Timer::new();
let mut state = TimerState::new(Duration::from_secs(125));
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "2m5s");
}
#[test]
fn render_digital_format() {
let widget = Timer::new().format(TimerFormat::Digital);
let mut state = TimerState::new(Duration::from_secs(3665));
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "01:01:05");
}
#[test]
fn render_seconds_format() {
let widget = Timer::new().format(TimerFormat::Seconds);
let mut state = TimerState::new(Duration::from_secs(90));
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "90s");
}
#[test]
fn render_with_label() {
let widget = Timer::new().label("Remaining:");
let mut state = TimerState::new(Duration::from_secs(45));
let text = render_to_string(&widget, &mut state, 30);
assert_eq!(text, "Remaining: 45s");
}
#[test]
fn render_finished_shows_zero() {
let widget = Timer::new();
let mut state = TimerState::new(Duration::from_secs(1));
state.start();
state.tick(Duration::from_secs(1));
let text = render_to_string(&widget, &mut state, 20);
assert_eq!(text, "0s");
}
#[test]
fn stateless_render_shows_zero() {
let widget = Timer::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 = Timer::new().format(TimerFormat::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 = TimerState::new(Duration::from_secs(90));
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 4, 0), Some(' '));
}
#[test]
fn is_essential() {
let widget = Timer::new();
assert!(widget.is_essential());
}
#[test]
fn degradation_skeleton_renders_essential_text() {
use ftui_render::budget::DegradationLevel;
let widget = Timer::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 = TimerState::new(Duration::from_secs(60));
StatefulWidget::render(&widget, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('1'));
assert_eq!(cell_char(&frame.buffer, 1, 0), Some('m'));
}
#[test]
fn skeleton_empty_timer_clears_stale_row() {
use ftui_render::budget::DegradationLevel;
let widget = Timer::new().format(TimerFormat::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 = TimerState::new(Duration::from_secs(90));
let mut empty = TimerState::new(Duration::ZERO);
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 countdown_progression() {
let mut state = TimerState::new(Duration::from_secs(5));
state.start();
for expected in (0..=4).rev() {
state.tick(Duration::from_secs(1));
assert_eq!(state.remaining(), Duration::from_secs(expected));
}
assert!(state.finished());
}
}