Files
termato/src/main.rs
T

454 lines
12 KiB
Rust
Raw Normal View History

use std::{
2026-07-27 15:00:07 -05:00
io::{self, stdout}, sync::{Arc, Mutex, mpsc}, thread, time::Duration,
};
use crossterm::{
ExecutableCommand,
event::{self, Event, KeyCode, KeyModifiers},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use figlet_rs::Toilet;
use notify_rust::Notification;
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Style},
widgets::{Block, Borders, Clear, Paragraph},
};
2026-07-22 22:46:14 -05:00
const SECONDS_PER_MIN: u32 = 60;
2026-07-22 22:46:14 -05:00
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TimerMode {
Work,
Break,
}
fn send_desktop_notification(title: &str, body: &str) {
let title = title.to_string();
let body = body.to_string();
std::thread::spawn(move || {
let _ = Notification::new()
.summary(&title)
.body(&body)
.appname("termato")
.show();
});
}
struct Termato {
2026-07-22 22:46:14 -05:00
work_mins: u32,
break_mins: u32,
mode: TimerMode,
is_running: bool,
2026-07-22 22:46:14 -05:00
duration_in_secs: u32,
time_remaining_in_sec: u32,
2026-07-22 22:24:48 -05:00
show_help: bool,
enable_notifications: bool,
2026-07-27 15:00:07 -05:00
enable_visualizer: bool,
}
impl Termato {
2026-07-22 22:46:14 -05:00
fn new(work_mins: u32, break_mins: u32) -> Self {
Termato {
work_mins,
break_mins,
2026-07-22 22:46:14 -05:00
mode: TimerMode::Work,
is_running: false,
duration_in_secs: work_mins * SECONDS_PER_MIN,
time_remaining_in_sec: work_mins * SECONDS_PER_MIN,
2026-07-22 22:24:48 -05:00
show_help: false,
enable_notifications: false,
2026-07-27 15:00:07 -05:00
enable_visualizer: false,
}
}
fn with_notifications(mut self, enable: bool) -> Self {
self.enable_notifications = enable;
self
}
2026-07-27 15:00:07 -05:00
fn with_visualizer(mut self, enable: bool) -> Self {
self.enable_visualizer = enable;
self
}
fn toggle_running(&mut self) {
2026-07-22 22:46:14 -05:00
self.is_running = !self.is_running;
}
fn toggle_mode(&mut self) {
2026-07-22 22:46:14 -05:00
self.mode = match self.mode {
TimerMode::Work => TimerMode::Break,
TimerMode::Break => TimerMode::Work,
};
self.duration_in_secs = match self.mode {
TimerMode::Work => self.work_mins * SECONDS_PER_MIN,
TimerMode::Break => self.break_mins * SECONDS_PER_MIN,
};
self.time_remaining_in_sec = self.duration_in_secs;
}
fn tick(&mut self) {
if self.is_running && self.time_remaining_in_sec > 0 {
self.time_remaining_in_sec -= 1;
if self.time_remaining_in_sec == 0 {
if self.enable_notifications {
match self.mode {
TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."),
TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."),
}
}
self.toggle_mode();
}
}
}
fn reset(&mut self) {
self.is_running = false;
2026-07-22 22:46:14 -05:00
self.mode = TimerMode::Work;
self.duration_in_secs = self.work_mins * SECONDS_PER_MIN;
self.time_remaining_in_sec = self.duration_in_secs;
}
2026-07-22 22:24:48 -05:00
fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
}
struct TerminalGuard;
impl TerminalGuard {
fn new() -> Result<Self, io::Error> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
Ok(TerminalGuard)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
}
}
2026-07-22 23:09:36 -05:00
// TODO: Refactor to an Elm like architecture
fn main() -> Result<(), io::Error> {
2026-07-22 22:48:27 -05:00
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
println!("Usage: termato [options] [work_minutes] [break_minutes]");
println!();
println!("Options:");
println!(" -n, --notify Enable desktop notifications");
println!(" -h, --help Print this help message");
println!(" -v, --version Print the version number");
2026-07-27 15:00:07 -05:00
println!(" -z, --visualizer Display visualizer of playing audio");
println!();
2026-07-22 22:48:27 -05:00
println!("Defaults: work_minutes = 25, break_minutes = 5");
return Ok(());
}
if args.iter().any(|arg| arg == "-v" || arg == "--version") {
println!("termato version {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let _guard = TerminalGuard::new()?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let font = Toilet::smblock().unwrap();
2026-07-22 22:48:27 -05:00
2026-07-27 15:00:07 -05:00
let enable_visualizer = args.iter().any(|arg| arg == "-z" || arg == "--visualizer");
let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify");
2026-07-27 15:00:07 -05:00
let positional_args: Vec<&String> = args.iter()
.skip(1)
2026-07-27 15:00:07 -05:00
.filter(|arg| {
*arg != "-n" && *arg != "--notify" && *arg != "-vx" && *arg != "--visualizer"
})
.collect();
2026-07-27 15:00:07 -05:00
let work_mins = positional_args.first().and_then(|s| s.parse().ok()).unwrap_or(25);
let break_mins = positional_args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5);
2026-07-27 15:00:07 -05:00
let mut termato = Termato::new(work_mins, break_mins)
.with_notifications(enable_notifications)
.with_visualizer(enable_visualizer);
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
loop {
thread::sleep(Duration::from_secs(1));
if tx.send(()).is_err() {
break;
}
}
});
loop {
terminal.draw(|f| {
let size = f.area();
2026-07-22 22:24:48 -05:00
let minutes = termato.time_remaining_in_sec / SECONDS_PER_MIN;
let seconds = termato.time_remaining_in_sec % SECONDS_PER_MIN;
let time_str = format!("{:02}:{:02}", minutes, seconds);
2026-07-22 22:24:48 -05:00
2026-07-22 22:46:14 -05:00
let time_color = if !termato.is_running {
Color::Yellow
} else {
match termato.mode {
TimerMode::Work => Color::Red,
TimerMode::Break => Color::Green,
}
};
let big_time_text = if let Some(fig) = font.convert(&time_str) {
fig.to_string()
} else {
time_str
};
let text_height = big_time_text.lines().count() as u16;
let vertical_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(text_height),
Constraint::Min(0),
])
.split(size);
let timer_paragraph = Paragraph::new(big_time_text)
.style(Style::default().fg(time_color))
.alignment(Alignment::Center);
f.render_widget(timer_paragraph, vertical_layout[1]);
2026-07-22 22:24:48 -05:00
if termato.show_help {
let popup_width = 44;
let popup_height = 7;
if size.width >= popup_width && size.height >= popup_height {
let popup_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((size.height.saturating_sub(popup_height)) / 2),
Constraint::Length(popup_height),
Constraint::Min(1),
])
.split(size);
let popup_horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((size.width.saturating_sub(popup_width)) / 2),
Constraint::Length(popup_width),
Constraint::Min(1),
])
.split(popup_vertical[1]);
let popup_area = popup_horizontal[1];
let help_block = Block::default()
.title(" Controls ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let help_content = Paragraph::new(
"[Space] Pause / Resume\n\
[S] Switch Mode\n\
[R] Reset Timer\n\
[?] Toggle Help\n\
[Q] Quit",
)
.block(help_block)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Left);
f.render_widget(Clear, popup_area);
f.render_widget(help_content, popup_area);
}
}
})?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == event::KeyEventKind::Press
{
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
KeyCode::Char(' ') => termato.toggle_running(),
KeyCode::Char('s') | KeyCode::Char('S') => termato.toggle_mode(),
KeyCode::Char('r') | KeyCode::Char('R') => termato.reset(),
2026-07-22 22:24:48 -05:00
KeyCode::Char('?') => termato.toggle_help(),
_ => {}
}
}
if rx.try_recv().is_ok() {
termato.tick();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
2026-07-27 15:00:07 -05:00
fn termato_new_when_called_it_should_return_expected_starting_state() {
let result = Termato::new(25, 5);
assert_eq!(result.work_mins, 25);
assert_eq!(result.break_mins, 5);
2026-07-22 22:46:14 -05:00
assert_eq!(result.mode, TimerMode::Work);
assert!(!result.is_running);
assert_eq!(result.duration_in_secs, 1500);
assert_eq!(result.time_remaining_in_sec, 1500);
2026-07-27 15:00:07 -05:00
assert!(!result.show_help);
assert!(!result.enable_notifications);
assert!(!result.enable_visualizer);
}
#[test]
fn termato_tick_when_called_and_not_running_it_should_do_nothing() {
let mut termato = Termato::new(1, 1);
termato.tick();
assert_eq!(termato.time_remaining_in_sec, 60);
}
#[test]
fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.time_remaining_in_sec = 0;
termato.tick();
assert_eq!(termato.time_remaining_in_sec, 0);
}
#[test]
fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.tick();
assert_eq!(termato.time_remaining_in_sec, 59);
}
#[test]
fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() {
let mut termato = Termato::new(1, 2);
termato.is_running = true;
termato.time_remaining_in_sec = 1;
termato.tick();
2026-07-22 22:46:14 -05:00
assert_eq!(termato.mode, TimerMode::Break);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() {
let mut termato = Termato::new(2, 1);
2026-07-22 22:46:14 -05:00
termato.mode = TimerMode::Break;
termato.is_running = true;
termato.time_remaining_in_sec = 1;
termato.tick();
2026-07-22 22:46:14 -05:00
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
2026-07-22 22:46:14 -05:00
fn termato_toggle_mode_when_called_it_should_toggle_the_mode() {
let mut termato = Termato::new(2, 1);
termato.toggle_mode();
2026-07-22 22:46:14 -05:00
assert_eq!(termato.mode, TimerMode::Break);
assert_eq!(termato.duration_in_secs, 60);
assert_eq!(termato.time_remaining_in_sec, 60);
2026-07-22 22:46:14 -05:00
termato.toggle_mode();
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_toggle_running_when_called_and_already_running_it_should_pause() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.toggle_running();
assert!(!termato.is_running)
}
#[test]
fn termato_toggle_running_when_called_and_already_paused_it_should_run() {
let mut termato = Termato::new(1, 1);
termato.toggle_running();
assert!(termato.is_running)
}
#[test]
fn termato_rest_when_called_it_should_reset_the_apps_state() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
2026-07-22 22:46:14 -05:00
termato.mode = TimerMode::Break;
termato.duration_in_secs = 1;
termato.time_remaining_in_sec = 1;
termato.reset();
assert!(!termato.is_running);
2026-07-22 22:46:14 -05:00
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 60);
assert_eq!(termato.time_remaining_in_sec, 60);
}
2026-07-22 22:24:48 -05:00
#[test]
fn termato_toggle_help_when_called_it_should_toggle_help() {
let mut termato = Termato::new(1, 1);
termato.toggle_help();
assert!(termato.show_help);
termato.toggle_help();
assert!(!termato.show_help);
}
#[test]
fn termato_notifications_when_called_it_should_verify_opt_in_behavior() {
let result = Termato::new(25, 5);
assert!(!result.enable_notifications);
let result = result.with_notifications(true);
assert!(result.enable_notifications);
}
2026-07-22 18:25:02 -05:00
}