use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::config::PomodoroConfig;
use crate::frame::Binding;
use crate::glyphs::{self, BigText};
use crate::panel::{KeyOutcome, Panel, RenderContext};
const MAX_SCALE: u16 = 1;
const FRAME_WIDTH: u16 = 4;
const FRAME_HEIGHT: u16 = 2;
pub const MAX_MINUTES: u64 = 180;
const LABEL_AND_FOOTER: u16 = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Chime {
Bell,
Run(Vec<String>),
}
fn chime_for(config: &PomodoroConfig) -> Option<Chime> {
if !config.chime {
return None;
}
if config.chime_command.is_empty() {
Some(Chime::Bell)
} else {
Some(Chime::Run(config.chime_command.clone()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Focus,
ShortBreak,
LongBreak,
}
impl Phase {
fn label(self) -> &'static str {
match self {
Self::Focus => "focus",
Self::ShortBreak => "short break",
Self::LongBreak => "long break",
}
}
fn is_focus(self) -> bool {
self == Self::Focus
}
}
pub struct PomodoroPanel {
config: PomodoroConfig,
seeded: PomodoroConfig,
phase: Phase,
ends_at: Option<Instant>,
paused_remaining: Duration,
completed: u32,
total_completed: u32,
chime_error: Option<String>,
}
impl PomodoroPanel {
pub fn new(config: PomodoroConfig) -> Self {
let first = Duration::from_secs(config.focus_minutes * 60);
Self {
seeded: config.clone(),
config,
phase: Phase::Focus,
ends_at: None,
paused_remaining: first,
completed: 0,
total_completed: 0,
chime_error: None,
}
}
fn sound_chime(&mut self) {
let Some(chime) = chime_for(&self.config) else {
return;
};
match chime {
Chime::Bell => {
use std::io::Write;
let mut out = std::io::stdout();
self.chime_error = match out.write_all(b"\x07").and_then(|()| out.flush()) {
Ok(()) => None,
Err(e) => Some(format!("bell failed: {e}")),
};
}
Chime::Run(command) => {
let (program, rest) = command.split_first().expect("non-empty by construction");
match std::process::Command::new(program)
.args(rest)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
self.chime_error = None;
std::thread::spawn(move || {
let _ = child.wait();
});
}
Err(e) => {
self.chime_error = Some(format!("{program}: {e}"));
}
}
}
}
}
fn length_of(&self, phase: Phase) -> Duration {
let minutes = match phase {
Phase::Focus => self.config.focus_minutes,
Phase::ShortBreak => self.config.short_break_minutes,
Phase::LongBreak => self.config.long_break_minutes,
};
Duration::from_secs(minutes * 60)
}
fn remaining(&self) -> Duration {
match self.ends_at {
Some(deadline) => deadline.saturating_duration_since(Instant::now()),
None => self.paused_remaining,
}
}
fn is_running(&self) -> bool {
self.ends_at.is_some()
}
fn start(&mut self) {
if self.ends_at.is_none() {
if self.paused_remaining.is_zero() {
self.paused_remaining = self.length_of(self.phase);
}
self.ends_at = Some(Instant::now() + self.paused_remaining);
}
}
fn pause(&mut self) {
if self.ends_at.is_some() {
self.paused_remaining = self.remaining();
self.ends_at = None;
}
}
fn toggle(&mut self) {
if self.is_running() {
self.pause();
} else {
self.start();
}
}
fn reset_phase(&mut self) {
self.paused_remaining = self.length_of(self.phase);
self.ends_at = None;
}
fn next_phase(&self) -> Phase {
if self.phase.is_focus() {
if self.completed > 0
&& self
.completed
.is_multiple_of(self.config.rounds_before_long_break)
{
Phase::LongBreak
} else {
Phase::ShortBreak
}
} else {
Phase::Focus
}
}
fn advance(&mut self) {
if self.phase.is_focus() {
self.completed += 1;
self.total_completed += 1;
} else if self.phase == Phase::LongBreak {
self.completed = 0;
}
self.phase = self.next_phase();
self.paused_remaining = self.length_of(self.phase);
self.ends_at = if self.config.auto_start {
Some(Instant::now() + self.paused_remaining)
} else {
None
};
}
fn adjust(&mut self, delta: i64) {
let minutes = match self.phase {
Phase::Focus => &mut self.config.focus_minutes,
Phase::ShortBreak => &mut self.config.short_break_minutes,
Phase::LongBreak => &mut self.config.long_break_minutes,
};
let before = *minutes;
let after = before.saturating_add_signed(delta).clamp(1, MAX_MINUTES);
*minutes = after;
if after == before {
return;
}
let shift = Duration::from_secs(after.abs_diff(before) * 60);
let grew = after > before;
let remaining = self.remaining();
let adjusted = if grew {
remaining + shift
} else {
remaining.saturating_sub(shift)
};
match self.ends_at {
Some(_) => self.ends_at = Some(Instant::now() + adjusted),
None => self.paused_remaining = adjusted,
}
}
fn elapsed_percent(&self) -> u16 {
let total = self.length_of(self.phase).as_secs();
if total == 0 {
return 0;
}
let left = self.remaining().as_secs().min(total);
let done = total - left;
u16::try_from(done * 100 / total).unwrap_or(100)
}
}
fn clock_text(remaining: Duration) -> String {
let secs = remaining.as_secs();
format!("{:02}:{:02}", secs / 60, secs % 60)
}
const BINDINGS: &[Binding] = &[
Binding::primary("space", "start/pause"),
Binding::primary("n", "next phase"),
Binding::primary("+/-", "length"),
Binding::extra("r", "reset phase"),
];
impl Panel for PomodoroPanel {
fn title(&self) -> String {
"Pomodoro".into()
}
fn counter(&self) -> Option<String> {
Some(if self.is_running() {
self.phase.label().into()
} else if self.remaining() == self.length_of(self.phase) {
"ready".into()
} else {
"paused".into()
})
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
Some(glyphs::width_of("00:00", MAX_SCALE) + FRAME_WIDTH)
}
fn max_height(&self) -> Option<u16> {
Some(1 + 5 * MAX_SCALE + 2 + FRAME_HEIGHT)
}
fn refresh_interval(&self) -> Duration {
Duration::from_secs(1)
}
fn tick(&mut self) {
if self.is_running() && self.remaining().is_zero() {
self.sound_chime();
self.advance();
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
if area.width == 0 || area.height == 0 {
return;
}
let theme = ctx.theme;
let phase_colour = if self.phase.is_focus() {
theme.accent
} else {
theme.success
};
let time = clock_text(self.remaining());
let bottom = area.y + area.height;
let scale = clock_scale(
&time,
area.width,
area.height.saturating_sub(LABEL_AND_FOOTER),
);
let content = LABEL_AND_FOOTER + clock_rows(&time, scale);
let mut cursor = area.y + area.height.saturating_sub(content) / 2;
if cursor < bottom {
let label = glyphs::utility(self.phase.label());
let x = area.x + area.width.saturating_sub(display_width(&label)) / 2;
frame.render_widget(
Paragraph::new(Span::styled(
label.clone(),
Style::default()
.fg(phase_colour)
.add_modifier(Modifier::BOLD),
)),
Rect::new(x, cursor, display_width(&label).min(area.width), 1),
);
cursor += 1;
}
let clock_colour = if self.is_running() {
phase_colour
} else {
theme.muted
};
cursor += draw_clock(frame, area, cursor, bottom, &time, scale, clock_colour);
if cursor < bottom && area.width > 4 {
let cells = meter_line(
self.elapsed_percent(),
area.width,
phase_colour,
theme.track,
);
frame.render_widget(
Paragraph::new(Line::from(cells)),
Rect::new(area.x, cursor, area.width, 1),
);
cursor += 1;
}
if cursor < bottom {
let mut spans = Vec::new();
for index in 0..self.config.rounds_before_long_break {
let done = index < self.completed;
spans.push(Span::styled(
"■",
Style::default().fg(if done { theme.accent } else { theme.track }),
));
spans.push(Span::raw(" "));
}
if let Some(error) = &self.chime_error {
spans.push(Span::styled(
format!(" chime: {error}"),
Style::default().fg(theme.error),
));
} else {
let done = if self.total_completed > 0 {
format!(" · {} done", self.total_completed)
} else {
String::new()
};
spans.push(Span::styled(
format!(
" {}m focus · {}m break{done}",
self.config.focus_minutes, self.config.short_break_minutes
),
Style::default().fg(theme.muted),
));
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect::new(area.x, cursor, area.width, 1),
);
}
}
fn remember(&self, state: &mut crate::state::UiState) {
if self.config.focus_minutes != self.seeded.focus_minutes {
state.pomodoro_focus_minutes = Some(self.config.focus_minutes);
}
if self.config.short_break_minutes != self.seeded.short_break_minutes {
state.pomodoro_short_break_minutes = Some(self.config.short_break_minutes);
}
if self.config.long_break_minutes != self.seeded.long_break_minutes {
state.pomodoro_long_break_minutes = Some(self.config.long_break_minutes);
}
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
return KeyOutcome::Ignored;
}
match key.code {
KeyCode::Char(' ') => self.toggle(),
KeyCode::Char('n') => self.advance(),
KeyCode::Char('r') => self.reset_phase(),
KeyCode::Char('+' | '=') => self.adjust(1),
KeyCode::Char('-' | '_') => self.adjust(-1),
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
}
fn clock_scale(time: &str, width: u16, budget: u16) -> Option<u16> {
glyphs::fitting_scale(time, width, MAX_SCALE)
.filter(|s| BigText::new(time, *s).height <= budget.max(1))
}
fn clock_rows(time: &str, scale: Option<u16>) -> u16 {
scale.map_or(1, |s| BigText::new(time, s).height)
}
fn draw_clock(
frame: &mut Frame,
area: Rect,
top: u16,
bottom: u16,
time: &str,
scale: Option<u16>,
colour: Color,
) -> u16 {
let Some(scale) = scale else {
if top >= bottom {
return 0;
}
let width = display_width(time);
let x = area.x + area.width.saturating_sub(width) / 2;
frame.render_widget(
Paragraph::new(Span::styled(time.to_owned(), Style::default().fg(colour))),
Rect::new(x, top, width.min(area.width), 1),
);
return 1;
};
let big = BigText::new(time, scale);
let x = area.x + area.width.saturating_sub(big.width) / 2;
for (index, row) in big.rows.iter().enumerate() {
let y = top + u16::try_from(index).unwrap_or(0);
if y >= bottom {
break;
}
frame.render_widget(
Paragraph::new(Span::styled(row.clone(), Style::default().fg(colour))),
Rect::new(x, y, big.width.min(area.width), 1),
);
}
big.height
}
fn display_width(text: &str) -> u16 {
u16::try_from(unicode_width::UnicodeWidthStr::width(text)).unwrap_or(u16::MAX)
}
fn meter_line(percent: u16, width: u16, fill: Color, track: Color) -> Vec<Span<'static>> {
let width = usize::from(width);
let filled = usize::from(percent.min(100)) * width / 100;
(0..width)
.map(|i| {
Span::styled(
"■",
Style::default().fg(if i < filled { fill } else { track }),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn panel() -> PomodoroPanel {
PomodoroPanel::new(PomodoroConfig::default())
}
#[test]
fn a_fresh_timer_is_stopped_at_a_full_focus_interval() {
let p = panel();
assert_eq!(p.phase, Phase::Focus);
assert!(!p.is_running());
assert_eq!(p.remaining(), Duration::from_mins(25));
assert_eq!(p.elapsed_percent(), 0);
}
#[test]
fn space_starts_and_pauses_without_losing_the_remainder() {
let mut p = panel();
p.paused_remaining = Duration::from_secs(90);
p.toggle();
assert!(p.is_running());
p.toggle();
assert!(!p.is_running());
let left = p.remaining().as_secs();
assert!((89..=90).contains(&left), "remaining was {left}s");
}
#[test]
fn a_phase_that_runs_out_advances_exactly_one_step() {
let mut p = panel();
p.paused_remaining = Duration::ZERO;
p.ends_at = Some(Instant::now());
p.tick();
assert_eq!(p.phase, Phase::ShortBreak);
assert_eq!(p.completed, 1);
assert!(!p.is_running());
assert_eq!(p.remaining(), Duration::from_mins(5));
p.tick();
assert_eq!(p.phase, Phase::ShortBreak);
}
#[test]
fn the_long_break_falls_on_the_fourth_focus_and_resets_the_set() {
let mut p = panel();
for round in 1..=3 {
p.advance(); assert_eq!(p.phase, Phase::ShortBreak, "round {round}");
p.advance(); assert_eq!(p.phase, Phase::Focus, "round {round}");
}
p.advance(); assert_eq!(p.phase, Phase::LongBreak);
assert_eq!(p.completed, 4);
p.advance(); assert_eq!(p.phase, Phase::Focus);
assert_eq!(p.completed, 0, "a long break starts the set over");
assert_eq!(p.total_completed, 4, "the running total does not reset");
}
#[test]
fn adjusting_moves_the_length_and_the_remainder_together() {
let mut p = panel();
p.paused_remaining = Duration::from_mins(10);
p.adjust(1);
assert_eq!(p.config.focus_minutes, 26);
assert_eq!(
p.remaining(),
Duration::from_mins(11),
"adding a minute must not rewind to the start of the phase"
);
p.adjust(-1);
assert_eq!(p.config.focus_minutes, 25);
assert_eq!(p.remaining(), Duration::from_mins(10));
}
#[test]
fn a_length_cannot_be_driven_below_a_minute_or_past_the_cap() {
let mut p = panel();
for _ in 0..40 {
p.adjust(-1);
}
assert_eq!(p.config.focus_minutes, 1);
assert!(p.remaining() <= Duration::from_mins(1));
for _ in 0..MAX_MINUTES + 10 {
p.adjust(1);
}
assert_eq!(p.config.focus_minutes, MAX_MINUTES);
}
#[test]
fn adjusting_targets_the_phase_you_are_in() {
let mut p = panel();
p.advance(); p.adjust(1);
assert_eq!(p.config.short_break_minutes, 6);
assert_eq!(
p.config.focus_minutes, 25,
"adjusting a break must not touch the focus length"
);
}
#[test]
fn reset_restores_the_phase_without_changing_the_set() {
let mut p = panel();
p.completed = 2;
p.paused_remaining = Duration::from_secs(30);
p.ends_at = Some(Instant::now() + Duration::from_secs(30));
p.reset_phase();
assert!(!p.is_running());
assert_eq!(p.remaining(), Duration::from_mins(25));
assert_eq!(p.completed, 2, "reset is for the phase, not the set");
}
#[test]
fn starting_a_run_down_timer_restarts_it_rather_than_finishing_instantly() {
let mut p = panel();
p.paused_remaining = Duration::ZERO;
p.start();
assert!(p.is_running());
assert!(p.remaining() > Duration::from_mins(24));
}
#[test]
fn auto_start_runs_the_next_phase_without_a_keypress() {
let mut p = panel();
p.config.auto_start = true;
p.advance();
assert!(p.is_running());
assert_eq!(p.phase, Phase::ShortBreak);
}
#[test]
fn the_clock_reads_as_minutes_and_seconds_past_an_hour() {
assert_eq!(clock_text(Duration::from_secs(0)), "00:00");
assert_eq!(clock_text(Duration::from_secs(59)), "00:59");
assert_eq!(clock_text(Duration::from_mins(25)), "25:00");
assert_eq!(
clock_text(Duration::from_mins(90)),
"90:00",
"a long phase must not roll over into an hours field it does not have"
);
}
#[test]
fn the_meter_paints_a_full_width_track_at_every_value() {
for percent in [0, 1, 50, 99, 100, 250] {
let cells = meter_line(percent, 20, Color::Red, Color::Black);
assert_eq!(cells.len(), 20, "meter changed width at {percent}%");
}
}
#[test]
fn elapsed_percent_spans_the_phase() {
let mut p = panel();
assert_eq!(p.elapsed_percent(), 0);
p.paused_remaining = Duration::from_secs(0);
assert_eq!(p.elapsed_percent(), 100);
p.paused_remaining = Duration::from_secs(25 * 60 / 2);
assert_eq!(p.elapsed_percent(), 50);
}
#[test]
fn the_counter_distinguishes_ready_from_paused() {
let mut p = panel();
assert_eq!(p.counter().as_deref(), Some("ready"));
p.paused_remaining = Duration::from_mins(1);
assert_eq!(
p.counter().as_deref(),
Some("paused"),
"a part-spent phase that is not running is paused, not ready"
);
p.start();
assert_eq!(p.counter().as_deref(), Some("focus"));
}
#[test]
fn the_chime_is_off_unless_asked_for() {
let config = PomodoroConfig::default();
assert!(!config.chime, "a dashboard must not make noise by default");
assert_eq!(chime_for(&config), None);
}
#[test]
fn chime_falls_back_to_the_terminal_bell_with_no_command() {
let config = PomodoroConfig {
chime: true,
..PomodoroConfig::default()
};
assert_eq!(chime_for(&config), Some(Chime::Bell));
}
#[test]
fn a_chime_command_replaces_the_bell() {
let config = PomodoroConfig {
chime: true,
chime_command: vec!["afplay".into(), "/System/Library/Sounds/Glass.aiff".into()],
..PomodoroConfig::default()
};
assert_eq!(
chime_for(&config),
Some(Chime::Run(vec![
"afplay".into(),
"/System/Library/Sounds/Glass.aiff".into()
]))
);
}
#[test]
fn a_chime_command_that_does_not_exist_is_reported_rather_than_swallowed() {
let mut p = PomodoroPanel::new(PomodoroConfig {
chime: true,
chime_command: vec!["mirador-no-such-player".into()],
..PomodoroConfig::default()
});
p.sound_chime();
let error = p.chime_error.expect("a missing player must be reported");
assert!(
error.contains("mirador-no-such-player"),
"the message must name the program that failed: {error}"
);
}
#[test]
fn advancing_with_the_chime_off_touches_nothing() {
let mut p = panel();
p.advance();
assert_eq!(p.chime_error, None);
}
#[test]
fn a_phase_that_runs_out_chimes_but_skipping_by_hand_does_not() {
let config = PomodoroConfig {
chime: true,
chime_command: vec!["mirador-no-such-player".into()],
..PomodoroConfig::default()
};
let mut skipped = PomodoroPanel::new(config.clone());
skipped.advance();
assert_eq!(
skipped.chime_error, None,
"pressing `n` is you already knowing; it must not ring"
);
let mut expired = PomodoroPanel::new(config);
expired.ends_at = Some(Instant::now());
expired.tick();
assert!(
expired.chime_error.is_some(),
"a phase that ran out must announce itself"
);
assert_eq!(expired.phase, Phase::ShortBreak, "and still advance");
}
const DOCUMENTED_KEYS: &[(KeyCode, &str)] = &[
(KeyCode::Char(' '), "space"),
(KeyCode::Char('n'), "n"),
(KeyCode::Char('r'), "r"),
(KeyCode::Char('+'), "+/-"),
(KeyCode::Char('='), "+/-"),
(KeyCode::Char('-'), "+/-"),
(KeyCode::Char('_'), "+/-"),
];
#[test]
fn every_documented_key_works_and_every_working_key_is_documented() {
for (code, key) in DOCUMENTED_KEYS {
assert!(
BINDINGS.iter().any(|b| b.key == *key),
"`{key}` is handled but missing from BINDINGS"
);
let mut p = panel();
assert_eq!(
p.handle_key(KeyEvent::new(*code, KeyModifiers::NONE)),
KeyOutcome::Consumed,
"`{key}` is documented but the panel ignores it"
);
}
for binding in BINDINGS {
assert!(
DOCUMENTED_KEYS.iter().any(|(_, key)| *key == binding.key),
"`{}` is in BINDINGS but nothing here proves it works",
binding.key
);
}
}
#[test]
fn the_panel_claims_only_the_space_it_can_actually_use() {
use crate::panel::Panel as _;
let p = panel();
assert_eq!(p.max_width(), Some(42));
assert_eq!(p.max_height(), Some(10));
let time = "88:88";
let scale = clock_scale(time, 42 - FRAME_WIDTH, 10 - FRAME_HEIGHT - LABEL_AND_FOOTER);
assert!(
scale.is_some(),
"the declared width must fit the numerals, or the panel caps itself \
into its own plain-text fallback"
);
assert_eq!(
LABEL_AND_FOOTER + clock_rows(time, scale) + FRAME_HEIGHT,
10,
"the declared height must be exactly what the rows add up to"
);
}
#[test]
fn only_a_duration_you_changed_is_remembered() {
use crate::panel::Panel as _;
let mut p = panel();
let mut state = crate::state::UiState::default();
p.remember(&mut state);
assert_eq!(
state,
crate::state::UiState::default(),
"an untouched timer must write nothing, or the config's own values \
get pinned into the state file and editing the config stops working"
);
p.adjust(1); let mut state = crate::state::UiState::default();
p.remember(&mut state);
assert_eq!(state.pomodoro_focus_minutes, Some(26));
assert_eq!(
state.pomodoro_short_break_minutes, None,
"a break you never touched must not be pinned by adjusting focus"
);
assert_eq!(state.pomodoro_long_break_minutes, None);
}
#[test]
fn global_keys_are_not_swallowed() {
let mut p = panel();
let q = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(p.handle_key(q), KeyOutcome::Ignored);
assert!(
!p.captures_input(),
"the timer has no text entry to protect"
);
}
}