From 6acc79a5d14d76893bd032288beae010c32e6510 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:31:58 -0500 Subject: [PATCH 01/10] refactor: extract message enum --- src/msg.rs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/msg.rs diff --git a/src/msg.rs b/src/msg.rs new file mode 100644 index 0000000..b45c432 --- /dev/null +++ b/src/msg.rs @@ -0,0 +1,9 @@ +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Message { + Tick, + ToggleRunning, + ToggleMode, + Reset, + ToggleHelp, + Quit, +} -- 2.54.0 From 19a3d0927e44489fcc3e8c89ca4741cbff122199 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:32:16 -0500 Subject: [PATCH 02/10] refactor: extract audio visualizer --- src/audio.rs | 298 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 src/audio.rs diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..74761d3 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,298 @@ +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, +}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use realfft::{RealFftPlanner, num_complex}; + +pub struct AudioVisualizer { + pub bar_data: Arc>>, + pub stop_flag: Arc, +} + +// This is a implementation largely lifted from these +// open source implementation. I just wrote it in Rust here: +// 1. CAVA (C): https://github.com/karlstav/cava +// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer +impl AudioVisualizer { + pub fn new(num_bars: usize) -> Self { + let bar_data = Arc::new(Mutex::new(vec![0; num_bars])); + let bar_data_clone = Arc::clone(&bar_data); + let stop_flag = Arc::new(AtomicBool::new(false)); + let stop_clone = Arc::clone(&stop_flag); + + thread::spawn(move || { + if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars, stop_clone) { + eprintln!("Audio capture error: {:?}", e); + } + }); + + Self { + bar_data, + stop_flag, + } + } + + // Doing best effort to support cross-platform functionality + fn get_audio_device( + host: &cpal::Host, + ) -> Result<(cpal::Device, cpal::StreamConfig), Box> { + // Try windows WASAPI loopback on default output device + #[cfg(target_os = "windows")] + { + if let Some(device) = host.default_output_device() + && let Ok(config) = device.default_output_config() + { + return Ok((device, config.into())); + } + } + + // Try Linux PipeWire/PulseAudio output monitor device + #[cfg(target_os = "linux")] + { + if let Ok(devices) = host.devices() { + for dev in devices { + if let Ok(desc) = dev.description() { + let name = desc.to_string(); + + // Monitor devices mirror system output under PulseAudio/PipeWire + if name.contains("monitor") { + if let Ok(config) = dev.default_input_config() { + return Ok((dev, config.into())); + } + } + } + } + } + } + + // Fallback to default input device + let device = host + .default_input_device() + .or_else(|| host.default_output_device()) + .ok_or("No audio input or output device found")?; + + let config = device + .default_input_config() + .or_else(|_| device.default_output_config())? + .into(); + + Ok((device, config)) + } + + // we here in doubling octaves but fft outputs linearly spaced bins. + // so we bundle bins on a log scale from 20 hz to 12 kHz + // this allows bass, mids, and trembls to have equal visual + // proportions during display + // + // i.e. + // bins like this [0-500Hz] [500-1k] [1k-1.5k] [1.5k-2k] [2k-2.5k] [2.5k-3k] [3k-12kHz] + // vs + // bins like this [20-60Hz] [60-250Hz] [250-500Hz] [500-2kHz] [2k-4kHz] [4k-8kHz] [8k-12kHz] + fn build_log_bins(num_bars: usize, sample_rate: f32, chunk_size: usize) -> Vec<(usize, usize)> { + let nyquist = sample_rate / 2.0; + let max_hz = 12000.0f32; + + (0..num_bars) + .map(|i| { + let low_hz = 20.0 * (max_hz / 20.0).powf(i as f32 / num_bars as f32); + let high_hz = 20.0 * (max_hz / 20.0).powf((i + 1) as f32 / num_bars as f32); + + let low = ((low_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize; + let high = ((high_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize; + + (low.max(1), high.max(low + 1)) + }) + .collect() + } + + // without we get sharp edges which gives noise in FFT processing + fn apply_hann_window(samples: &[f32], input_buffer: &mut [f32]) { + let chunk_size = samples.len(); + + for (i, sample) in samples.iter().enumerate() { + let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / chunk_size as f32).cos()); + input_buffer[i] = sample * window; + } + } + + fn process_fft_magnitudes( + spectrum: &[num_complex::Complex32], + log_bins: &[(usize, usize)], + freq_boost: &[f32], + prev_heights: &[f32], + autosens: f32, + smoothing: f32, + falloff: f32, + ) -> Vec { + let num_bars = log_bins.len(); + let mut current_bars = vec![0.0f32; num_bars]; + + // determine magnitude + for i in 0..num_bars { + let (start, stop) = log_bins[i]; + let bin_slice = &spectrum[start..stop.min(spectrum.len())]; + let magnitude_sum: f32 = bin_slice.iter().map(|c| c.norm()).sum(); + let avg_mag = if !bin_slice.is_empty() { + magnitude_sum / bin_slice.len() as f32 + } else { + 0.0 + }; + + // ignore quiet static noise below specific amplitude + let raw_val = if avg_mag < 0.02 { + 0.0 + } else { + (avg_mag * freq_boost[i] * autosens + 1.0).log10() * 3.5 + }; + + // rise smoothly from previous height + let target = (raw_val * (1.0 - smoothing)) + (prev_heights[i] * smoothing); + + // prevent sudden drops + if target < prev_heights[i] { + current_bars[i] = (prev_heights[i] - falloff).max(0.0); + } else { + current_bars[i] = target; + } + } + + current_bars + } + + // blend the heights between neighboring freq bins + // so instead of sharp spikes we get more of waves + fn apply_monstercat_smoothing(bars: &[f32]) -> Vec { + let num_bars = bars.len(); + let mut smoothed = bars.to_vec(); + + for i in 1..(num_bars - 1) { + smoothed[i] = (bars[i - 1] * 0.25) + (bars[i] * 0.50) + (bars[i + 1] * 0.25); + } + + smoothed + } + + // songs can be quiet and loud so we try to adjust + // sensitivity to avoid bars becoming flattened or + // clipped + fn adjust_autosens(autosens: &mut f32, bars: &[f32]) { + let max_val = bars.iter().copied().fold(0.0f32, f32::max); + + if max_val > 8.0 { + *autosens *= 0.98; + } else if max_val < 3.0 && *autosens < 3.0 { + *autosens *= 1.01; + } + } + + // captures output and runs through pipeline + // system audio -> audio buffer -> windowing -> fft process -> binning -> floor/autosens -> smoothing + fn run_audio_loop( + bar_data: Arc>>, + num_bars: usize, + stop: Arc, + ) -> Result<(), Box> { + let host = cpal::default_host(); + let (device, config) = Self::get_audio_device(&host)?; + + let sample_rate = config.sample_rate as f32; + let channels = config.channels as usize; + + let chunk_size = 2048; + let mut planner = RealFftPlanner::::new(); + let fft = planner.plan_fft_forward(chunk_size); + let mut windowed_buffer = fft.make_input_vec(); + let mut spectrum = fft.make_output_vec(); + + let mut prev_heights = vec![0.0f32; num_bars]; + let smoothing = 0.70f32; + let falloff = 0.08f32; + let mut autosens = 1.0f32; + + let log_bins = Self::build_log_bins(num_bars, sample_rate, chunk_size); + + let freq_boost: Vec = (0..num_bars) + .map(|i| 1.0 + (3.5 * (i as f32 / num_bars as f32).powf(1.2))) + .collect(); + + let audio_buffer = Arc::new(Mutex::new(Vec::::with_capacity(chunk_size * 2))); + let buffer_clone = Arc::clone(&audio_buffer); + + let stream = device.build_input_stream( + config, + move |data: &[f32], _| { + if let Ok(mut buf) = buffer_clone.lock() { + for chunk in data.chunks(channels) { + let mono: f32 = chunk.iter().sum::() / channels as f32; + buf.push(mono); + } + + if buf.len() > chunk_size * 2 { + let drain_amt = buf.len() - chunk_size; + buf.drain(0..drain_amt); + } + } + }, + |err| eprintln!("Stream error: {}", err), + None, + )?; + + stream.play()?; + + while !stop.load(Ordering::Relaxed) { + thread::sleep(std::time::Duration::from_millis(16)); + + let samples = { + let buf = match audio_buffer.lock() { + Ok(b) => b, + Err(_) => continue, + }; + if buf.len() < chunk_size { + continue; + } + buf[buf.len() - chunk_size..].to_vec() + }; + + Self::apply_hann_window(&samples, &mut windowed_buffer); + + if let Err(e) = fft.process(&mut windowed_buffer, &mut spectrum) { + eprintln!("FFT error: {:?}", e); + continue; + } + + let raw_bars = Self::process_fft_magnitudes( + &spectrum, + &log_bins, + &freq_boost, + &prev_heights, + autosens, + smoothing, + falloff, + ); + + let smoothed_bars = Self::apply_monstercat_smoothing(&raw_bars); + prev_heights = smoothed_bars.clone(); + + Self::adjust_autosens(&mut autosens, &smoothed_bars); + + if let Ok(mut bars) = bar_data.lock() { + for (i, val) in smoothed_bars.iter().enumerate() { + bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64; + } + } + } + + Ok(()) + } +} + +impl Drop for AudioVisualizer { + fn drop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + } +} -- 2.54.0 From dc1b08095c48a8e954b4ff2b62cc96df0f0705e9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:32:31 -0500 Subject: [PATCH 03/10] refactor: extract notification sending --- src/notification.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/notification.rs diff --git a/src/notification.rs b/src/notification.rs new file mode 100644 index 0000000..64286f1 --- /dev/null +++ b/src/notification.rs @@ -0,0 +1,14 @@ +use notify_rust::Notification; + +pub 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(); + }); +} -- 2.54.0 From 06d8a6fa8a8f0411a6a6cdd711f2facc673b4f7f Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:32:43 -0500 Subject: [PATCH 04/10] refactor: extract terminal guard --- src/terminal.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/terminal.rs diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..f173fa4 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,22 @@ +use crossterm::{ + ExecutableCommand, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use std::io::{self, stdout}; + +pub struct TerminalGuard; + +impl TerminalGuard { + pub fn new() -> Result { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + Ok(TerminalGuard) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + } +} -- 2.54.0 From ec614702564ab83330b5f563099ce5153fd4ce2a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:33:03 -0500 Subject: [PATCH 05/10] refactor: implement event handling --- src/events.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/events.rs diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..57af44f --- /dev/null +++ b/src/events.rs @@ -0,0 +1,38 @@ +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crate::msg::Message; + +pub fn handle_event(event: Event) -> Option { + if let Event::Key(key) = event + && key.kind == event::KeyEventKind::Press + { + match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') => Some(Message::Quit), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Message::Quit), + KeyCode::Char(' ') => Some(Message::ToggleRunning), + KeyCode::Char('s') | KeyCode::Char('S') => Some(Message::ToggleMode), + KeyCode::Char('r') | KeyCode::Char('R') => Some(Message::Reset), + KeyCode::Char('?') => Some(Message::ToggleHelp), + _ => None, + } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + + #[test] + fn handle_event_when_called_maps_key_presses_to_messages() { + let key_event = Event::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert_eq!(handle_event(key_event), Some(Message::ToggleRunning)); + + let key_event_q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + assert_eq!(handle_event(key_event_q), Some(Message::Quit)); + + let key_event_unknown = Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + assert_eq!(handle_event(key_event_unknown), None); + } +} -- 2.54.0 From a2b3b23cc1d3f435631cceaf27de3ed6eed95cf1 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:33:23 -0500 Subject: [PATCH 06/10] refactor: extract termato struct and implementations --- src/app.rs | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 src/app.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..498cfe5 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,285 @@ +use crate::msg::Message; +use crate::notification::send_desktop_notification; + +const SECONDS_PER_MIN: u32 = 60; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum TimerMode { + Work, + Break, +} + +pub struct Termato { + pub work_mins: u32, + pub break_mins: u32, + pub mode: TimerMode, + pub is_running: bool, + pub duration_in_secs: u32, + pub time_remaining_in_sec: u32, + pub show_help: bool, + pub enable_notifications: bool, + pub enable_visualizer: bool, + pub should_quit: bool, +} + +impl Termato { + pub fn new(work_mins: u32, break_mins: u32) -> Self { + Termato { + work_mins, + break_mins, + mode: TimerMode::Work, + is_running: false, + duration_in_secs: work_mins * SECONDS_PER_MIN, + time_remaining_in_sec: work_mins * SECONDS_PER_MIN, + show_help: false, + enable_notifications: false, + enable_visualizer: false, + should_quit: false, + } + } + + pub fn with_notifications(mut self, enable: bool) -> Self { + self.enable_notifications = enable; + self + } + + pub fn with_visualizer(mut self, enable: bool) -> Self { + self.enable_visualizer = enable; + self + } + + pub fn update(&mut self, msg: Message) { + match msg { + Message::Tick => self.tick(), + Message::ToggleRunning => self.toggle_running(), + Message::ToggleMode => self.toggle_mode(), + Message::Reset => self.reset(), + Message::ToggleHelp => self.toggle_help(), + Message::Quit => self.should_quit = true, + } + } + + pub fn toggle_running(&mut self) { + self.is_running = !self.is_running; + } + + pub fn toggle_mode(&mut self) { + 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; + } + + pub 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(); + } + } + } + + pub fn reset(&mut self) { + self.is_running = false; + self.mode = TimerMode::Work; + self.duration_in_secs = self.work_mins * SECONDS_PER_MIN; + self.time_remaining_in_sec = self.duration_in_secs; + } + + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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); + 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); + assert!(!result.show_help); + assert!(!result.enable_notifications); + assert!(!result.enable_visualizer); + assert!(!result.should_quit); + } + + #[test] + fn termato_update_when_called_with_messages_it_should_update_state() { + let mut termato = Termato::new(25, 5); + + termato.update(Message::ToggleRunning); + assert!(termato.is_running); + + termato.update(Message::ToggleHelp); + assert!(termato.show_help); + + termato.update(Message::ToggleMode); + assert_eq!(termato.mode, TimerMode::Break); + + termato.update(Message::Reset); + assert!(!termato.is_running); + assert_eq!(termato.mode, TimerMode::Work); + + termato.update(Message::Quit); + assert!(termato.should_quit); + } + + #[test] + fn termato_tick_when_called_and_not_running_it_should_do_nothing() { + let mut termato = Termato::new(1, 1); + + termato.update(Message::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.update(Message::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.update(Message::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.update(Message::Tick); + + 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); + termato.mode = TimerMode::Break; + termato.is_running = true; + termato.time_remaining_in_sec = 1; + + termato.update(Message::Tick); + + 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_mode_when_called_it_should_toggle_the_mode() { + let mut termato = Termato::new(2, 1); + + termato.toggle_mode(); + assert_eq!(termato.mode, TimerMode::Break); + assert_eq!(termato.duration_in_secs, 60); + assert_eq!(termato.time_remaining_in_sec, 60); + + 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; + termato.mode = TimerMode::Break; + termato.duration_in_secs = 1; + termato.time_remaining_in_sec = 1; + + termato.reset(); + + assert!(!termato.is_running); + assert_eq!(termato.mode, TimerMode::Work); + assert_eq!(termato.duration_in_secs, 60); + assert_eq!(termato.time_remaining_in_sec, 60); + } + + #[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); + } + + #[test] + fn termato_visualizer_when_called_it_should_verify_opt_in_behavior() { + let result = Termato::new(25, 5); + assert!(!result.enable_visualizer); + + let result = result.with_visualizer(true); + assert!(result.enable_visualizer); + } +} -- 2.54.0 From c8448c364ed0ca52a28d21bd78c4de8124a7ffbc Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:33:31 -0500 Subject: [PATCH 07/10] refactor: isolate rendering --- src/ui.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/ui.rs diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..875cb80 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,166 @@ +use figlet_rs::Toilet; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::app::{Termato, TimerMode}; +use crate::audio::AudioVisualizer; + +// use unicode blocks for rendering +// Empty -> " " +// 1/8th height -> "▂" +// 2/8th height -> "▃" +// 3/8th height -> "▄" +// 4/8th height -> "▅" +// 5/8th height -> "▆" +// 6/8th height -> "▇" +// Full Height -> "█" +pub fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) { + const BLOCKS: [&str; 8] = ["▂", "▃", "▄", "▅", "▆", "▇", "█", "█"]; + + let max_bars = area.width as usize; + let line: String = bar_values + .iter() + .take(max_bars) + .map(|&val| { + if val == 0 { + " " + } else { + let idx = ((val as f32 / 100.0) * (BLOCKS.len() - 1) as f32) + .clamp(0.0, (BLOCKS.len() - 1) as f32) as usize; + + BLOCKS[idx] + } + }) + .collect(); + + let viz_paragraph = Paragraph::new(line) + .style(Style::default().fg(Color::Rgb(200, 184, 224))) + .alignment(Alignment::Center); + + f.render_widget(viz_paragraph, area); +} + +pub fn render_app( + app: &Termato, + f: &mut Frame, + font: &Toilet, + visualizer: Option<&AudioVisualizer>, +) { + let size = f.area(); + + let minutes = app.time_remaining_in_sec / 60; + let seconds = app.time_remaining_in_sec % 60; + let time_str = format!("{:02}:{:02}", minutes, seconds); + + let time_color = if !app.is_running { + Color::Rgb(212, 200, 122) + } else { + match app.mode { + TimerMode::Work => Color::Rgb(212, 115, 115), + TimerMode::Break => Color::Rgb(126, 200, 192), + } + }; + + let raw_time_text = if let Some(fig) = font.convert(&time_str) { + fig.to_string() + } else { + time_str + }; + + let trimmed_time_text = raw_time_text.trim_matches('\n'); + let text_lines: Vec<&str> = trimmed_time_text.lines().collect(); + let text_height = text_lines.len() as u16; + + let viz_height = if visualizer.is_some() { 1 } else { 0 }; + let viz_width = 64; + + let content_height = text_height + viz_height; + + let outer_vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(content_height), + Constraint::Min(0), + ]) + .split(size); + + let inner_vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(text_height), + Constraint::Length(viz_height), + ]) + .split(outer_vertical[1]); + + let viz_horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(viz_width.min(size.width)), + Constraint::Min(0), + ]) + .split(inner_vertical[1]); + + let timer_paragraph = Paragraph::new(trimmed_time_text) + .style(Style::default().fg(time_color)) + .alignment(Alignment::Center); + + f.render_widget(timer_paragraph, inner_vertical[0]); + + if let Some(viz) = visualizer + && let Ok(bar_data) = viz.bar_data.lock() + { + render_visualizer(f, viz_horizontal[1], &bar_data); + } + + if app.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::Rgb(58, 62, 70))); + + 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::Rgb(224, 228, 232))) + .alignment(Alignment::Left); + + f.render_widget(Clear, popup_area); + f.render_widget(help_content, popup_area); + } + } +} -- 2.54.0 From a92bfe79cc29f6da5cdd238822895b2bb6e322c3 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:33:50 -0500 Subject: [PATCH 08/10] refactor: update main.rs to use new architecture --- src/main.rs | 777 ++-------------------------------------------------- 1 file changed, 24 insertions(+), 753 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5e8650b..b0a7f50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,478 +1,29 @@ +mod app; +mod audio; +mod events; +mod msg; +mod notification; +mod terminal; +mod ui; + use std::{ io::{self, stdout}, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - mpsc, - }, + sync::mpsc, thread, time::Duration, }; -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use crossterm::{ - ExecutableCommand, - event::{self, Event, KeyCode, KeyModifiers}, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; - +use crossterm::event; use figlet_rs::Toilet; -use notify_rust::Notification; -use ratatui::{ - Frame, Terminal, - backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, - widgets::{Block, Borders, Clear, Paragraph}, -}; -use realfft::{RealFftPlanner, num_complex}; +use ratatui::{Terminal, backend::CrosstermBackend}; -const SECONDS_PER_MIN: u32 = 60; +use app::Termato; +use audio::AudioVisualizer; +use events::handle_event; +use msg::Message; +use terminal::TerminalGuard; +use ui::render_app; -#[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 { - work_mins: u32, - break_mins: u32, - mode: TimerMode, - is_running: bool, - duration_in_secs: u32, - time_remaining_in_sec: u32, - show_help: bool, - enable_notifications: bool, - enable_visualizer: bool, -} - -impl Termato { - fn new(work_mins: u32, break_mins: u32) -> Self { - Termato { - work_mins, - break_mins, - mode: TimerMode::Work, - is_running: false, - duration_in_secs: work_mins * SECONDS_PER_MIN, - time_remaining_in_sec: work_mins * SECONDS_PER_MIN, - show_help: false, - enable_notifications: false, - enable_visualizer: false, - } - } - - fn with_notifications(mut self, enable: bool) -> Self { - self.enable_notifications = enable; - self - } - - fn with_visualizer(mut self, enable: bool) -> Self { - self.enable_visualizer = enable; - self - } - - fn toggle_running(&mut self) { - self.is_running = !self.is_running; - } - - fn toggle_mode(&mut self) { - 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; - self.mode = TimerMode::Work; - self.duration_in_secs = self.work_mins * SECONDS_PER_MIN; - self.time_remaining_in_sec = self.duration_in_secs; - } - - fn toggle_help(&mut self) { - self.show_help = !self.show_help; - } -} - -struct TerminalGuard; - -impl TerminalGuard { - fn new() -> Result { - enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; - Ok(TerminalGuard) - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - let _ = disable_raw_mode(); - let _ = stdout().execute(LeaveAlternateScreen); - } -} - -struct AudioVisualizer { - bar_data: Arc>>, - stop_flag: Arc, -} - -// This is a implementation largely lifted from these -// open source implementation. I just wrote it in Rust here: -// 1. CAVA (C): https://github.com/karlstav/cava -// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer -impl AudioVisualizer { - fn new(num_bars: usize) -> Self { - let bar_data = Arc::new(Mutex::new(vec![0; num_bars])); - let bar_data_clone = Arc::clone(&bar_data); - let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_clone = Arc::clone(&stop_flag); - - thread::spawn(move || { - if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars, stop_clone) { - eprintln!("Audio capture error: {:?}", e); - } - }); - - Self { - bar_data, - stop_flag, - } - } - - // Doing best effort to support cross-platform functionality - fn get_audio_device( - host: &cpal::Host, - ) -> Result<(cpal::Device, cpal::StreamConfig), Box> { - // Try windows WASAPI loopback on default output device - #[cfg(target_os = "windows")] - { - if let Some(device) = host.default_output_device() - && let Ok(config) = device.default_output_config() - { - return Ok((device, config.into())); - } - } - - // Try Linux PipeWire/PulseAudio output monitor device - #[cfg(target_os = "linux")] - { - if let Ok(devices) = host.devices() { - for dev in devices { - if let Ok(desc) = dev.description() { - let name = desc.to_string(); - - if name.contains("monitor") { - // Monitor devices mirror system output under PulseAudio/PipeWire - if let Ok(config) = dev.default_input_config() { - return Ok((dev, config.into())); - } - } - } - } - } - } - - // Fallback to default input device - let device = host - .default_input_device() - .or_else(|| host.default_output_device()) - .ok_or("No audio input or output device found")?; - - let config = device - .default_input_config() - .or_else(|_| device.default_output_config())? - .into(); - - Ok((device, config)) - } - - // we here in doubling octaves but fft outputs linearly spaced bins. - // so we bundle bins on a log scale from 20 hz to 12 kHz - // this allows bass, mids, and trembls to have equal visual - // proportions during display - // - // i.e. - // bins like this [0-500Hz] [500-1k] [1k-1.5k] [1.5k-2k] [2k-2.5k] [2.5k-3k] [3k-12kHz] - // vs - // bins like this [20-60Hz] [60-250Hz] [250-500Hz] [500-2kHz] [2k-4kHz] [4k-8kHz] [8k-12kHz] - fn build_log_bins(num_bars: usize, sample_rate: f32, chunk_size: usize) -> Vec<(usize, usize)> { - let nyquist = sample_rate / 2.0; - let max_hz = 12000.0f32; - - (0..num_bars) - .map(|i| { - let low_hz = 20.0 * (max_hz / 20.0).powf(i as f32 / num_bars as f32); - let high_hz = 20.0 * (max_hz / 20.0).powf((i + 1) as f32 / num_bars as f32); - - let low = ((low_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize; - let high = ((high_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize; - - (low.max(1), high.max(low + 1)) - }) - .collect() - } - - // without we get sharp edges which gives noise in FFT processing - fn apply_hann_window(samples: &[f32], input_buffer: &mut [f32]) { - let chunk_size = samples.len(); - - for (i, sample) in samples.iter().enumerate() { - let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / chunk_size as f32).cos()); - input_buffer[i] = sample * window; - } - } - - fn process_fft_magnitudes( - spectrum: &[num_complex::Complex32], - log_bins: &[(usize, usize)], - freq_boost: &[f32], - prev_heights: &[f32], - autosens: f32, - smoothing: f32, - falloff: f32, - ) -> Vec { - let num_bars = log_bins.len(); - let mut current_bars = vec![0.0f32; num_bars]; - - // determine magnitude - for i in 0..num_bars { - let (start, stop) = log_bins[i]; - let bin_slice = &spectrum[start..stop.min(spectrum.len())]; - let magnitude_sum: f32 = bin_slice.iter().map(|c| c.norm()).sum(); - let avg_mag = if !bin_slice.is_empty() { - magnitude_sum / bin_slice.len() as f32 - } else { - 0.0 - }; - - // ignore quiet static noise below specific amplitude - let raw_val = if avg_mag < 0.02 { - 0.0 - } else { - (avg_mag * freq_boost[i] * autosens + 1.0).log10() * 3.5 - }; - - // rise smoothly from previous height - let target = (raw_val * (1.0 - smoothing)) + (prev_heights[i] * smoothing); - - // prevent sudden drops - if target < prev_heights[i] { - current_bars[i] = (prev_heights[i] - falloff).max(0.0); - } else { - current_bars[i] = target; - } - } - - current_bars - } - - // blend the heights between neighboring freq bins - // so instead of sharp spikes we get more of waves - fn apply_monstercat_smoothing(bars: &[f32]) -> Vec { - let num_bars = bars.len(); - let mut smoothed = bars.to_vec(); - - for i in 1..(num_bars - 1) { - smoothed[i] = (bars[i - 1] * 0.25) + (bars[i] * 0.50) + (bars[i + 1] * 0.25); - } - - smoothed - } - - // songs can be quiet and loud so we try to adjust - // sensitivity to avoid bars becoming flattened or - // clipped - fn adjust_autosens(autosens: &mut f32, bars: &[f32]) { - let max_val = bars.iter().copied().fold(0.0f32, f32::max); - - if max_val > 8.0 { - *autosens *= 0.98; - } else if max_val < 3.0 && *autosens < 3.0 { - *autosens *= 1.01; - } - } - - // captures output and runs through pipeline - // system audio -> audio buffer -> windowing -> fft process -> binning -> floor/autosens -> smoothing - fn run_audio_loop( - bar_data: Arc>>, - num_bars: usize, - stop: Arc, - ) -> Result<(), Box> { - let host = cpal::default_host(); - let (device, config) = Self::get_audio_device(&host)?; - - let sample_rate = config.sample_rate as f32; - let channels = config.channels as usize; - - let chunk_size = 2048; - let mut planner = RealFftPlanner::::new(); - let fft = planner.plan_fft_forward(chunk_size); - let mut windowed_buffer = fft.make_input_vec(); - let mut spectrum = fft.make_output_vec(); - - let mut prev_heights = vec![0.0f32; num_bars]; - let smoothing = 0.70f32; - let falloff = 0.08f32; - let mut autosens = 1.0f32; - - let log_bins = Self::build_log_bins(num_bars, sample_rate, chunk_size); - - // high freq have less amplitude so we boost - // more and more as we go right - let freq_boost: Vec = (0..num_bars) - .map(|i| 1.0 + (3.5 * (i as f32 / num_bars as f32).powf(1.2))) - .collect(); - - let audio_buffer = Arc::new(Mutex::new(Vec::::with_capacity(chunk_size * 2))); - let buffer_clone = Arc::clone(&audio_buffer); - - let stream = device.build_input_stream( - config, - move |data: &[f32], _| { - if let Ok(mut buf) = buffer_clone.lock() { - for chunk in data.chunks(channels) { - let mono: f32 = chunk.iter().sum::() / channels as f32; - buf.push(mono); - } - - if buf.len() > chunk_size * 2 { - let drain_amt = buf.len() - chunk_size; - buf.drain(0..drain_amt); - } - } - }, - |err| eprintln!("Stream error: {}", err), - None, - )?; - - stream.play()?; - - while !stop.load(Ordering::Relaxed) { - thread::sleep(std::time::Duration::from_millis(16)); - - let samples = { - let buf = match audio_buffer.lock() { - Ok(b) => b, - Err(_) => continue, - }; - if buf.len() < chunk_size { - continue; - } - buf[buf.len() - chunk_size..].to_vec() - }; - - Self::apply_hann_window(&samples, &mut windowed_buffer); - - if let Err(e) = fft.process(&mut windowed_buffer, &mut spectrum) { - eprintln!("FFT error: {:?}", e); - continue; - } - - let raw_bars = Self::process_fft_magnitudes( - &spectrum, - &log_bins, - &freq_boost, - &prev_heights, - autosens, - smoothing, - falloff, - ); - - let smoothed_bars = Self::apply_monstercat_smoothing(&raw_bars); - prev_heights = smoothed_bars.clone(); - - Self::adjust_autosens(&mut autosens, &smoothed_bars); - - if let Ok(mut bars) = bar_data.lock() { - for (i, val) in smoothed_bars.iter().enumerate() { - bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64; - } - } - } - - Ok(()) - } -} - -impl Drop for AudioVisualizer { - fn drop(&mut self) { - self.stop_flag.store(true, Ordering::Relaxed); - } -} - -// use unicode blocks for rendering -// Empty -> " " -// 1/8th height -> "▂" -// 2/8th height -> "▃" -// 3/8th height -> "▄" -// 4/8th height -> "▅" -// 5/8th height -> "▆" -// 6/8th height -> "▇" -// Full Height -> "█" -fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) { - const BLOCKS: [&str; 8] = ["▂", "▃", "▄", "▅", "▆", "▇", "█", "█"]; - - let max_bars = area.width as usize; - let line: String = bar_values - .iter() - .take(max_bars) - .map(|&val| { - if val == 0 { - " " - } else { - let idx = ((val as f32 / 100.0) * (BLOCKS.len() - 1) as f32) - .clamp(0.0, (BLOCKS.len() - 1) as f32) as usize; - - BLOCKS[idx] - } - }) - .collect(); - - let viz_paragraph = Paragraph::new(line) - .style(Style::default().fg(Color::Rgb(200, 184, 224))) - .alignment(Alignment::Center); - - f.render_widget(viz_paragraph, area); -} - -// TODO: Refactor to an Elm like architecture fn main() -> Result<(), io::Error> { let args: Vec = std::env::args().collect(); @@ -547,300 +98,20 @@ fn main() -> Result<(), io::Error> { } }); - loop { - terminal.draw(|f| { - let size = f.area(); + while !termato.should_quit { + terminal.draw(|f| render_app(&termato, f, &font, visualizer.as_ref()))?; - 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); - - let time_color = if !termato.is_running { - Color::Rgb(212, 200, 122) - } else { - match termato.mode { - TimerMode::Work => Color::Rgb(212, 115, 115), - TimerMode::Break => Color::Rgb(126, 200, 192), - } - }; - - let raw_time_text = if let Some(fig) = font.convert(&time_str) { - fig.to_string() - } else { - time_str - }; - - let trimmed_time_text = raw_time_text.trim_matches('\n'); - let text_lines: Vec<&str> = trimmed_time_text.lines().collect(); - let text_height = text_lines.len() as u16; - - let viz_height = if visualizer.is_some() { 1 } else { 0 }; - let viz_width = 64; - - let content_height = text_height + viz_height; - - let outer_vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), - Constraint::Length(content_height), - Constraint::Min(0), - ]) - .split(size); - - let inner_vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(text_height), - Constraint::Length(viz_height), - ]) - .split(outer_vertical[1]); - - let viz_horizontal = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(0), - Constraint::Length(viz_width.min(size.width)), - Constraint::Min(0), - ]) - .split(inner_vertical[1]); - - let timer_paragraph = Paragraph::new(trimmed_time_text) - .style(Style::default().fg(time_color)) - .alignment(Alignment::Center); - - f.render_widget(timer_paragraph, inner_vertical[0]); - - if let Some(ref viz) = visualizer - && let Ok(bar_data) = viz.bar_data.lock() - { - render_visualizer(f, viz_horizontal[1], &bar_data); - } - - 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::Rgb(58, 62, 70))); - - 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::Rgb(224, 228, 232))) - .alignment(Alignment::Left); - - f.render_widget(Clear, popup_area); - f.render_widget(help_content, popup_area); - } - } - })?; - - if event::poll(Duration::from_millis(16))? - && 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(), - KeyCode::Char('?') => termato.toggle_help(), - _ => {} + if event::poll(Duration::from_millis(16))? { + let raw_event = event::read()?; + if let Some(msg) = handle_event(raw_event) { + termato.update(msg); } } if rx.try_recv().is_ok() { - termato.tick(); + termato.update(Message::Tick); } } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - 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); - 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); - 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(); - - 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); - termato.mode = TimerMode::Break; - termato.is_running = true; - termato.time_remaining_in_sec = 1; - - termato.tick(); - - 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_mode_when_called_it_should_toggle_the_mode() { - let mut termato = Termato::new(2, 1); - - termato.toggle_mode(); - assert_eq!(termato.mode, TimerMode::Break); - assert_eq!(termato.duration_in_secs, 60); - assert_eq!(termato.time_remaining_in_sec, 60); - - 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; - termato.mode = TimerMode::Break; - termato.duration_in_secs = 1; - termato.time_remaining_in_sec = 1; - - termato.reset(); - - assert!(!termato.is_running); - assert_eq!(termato.mode, TimerMode::Work); - assert_eq!(termato.duration_in_secs, 60); - assert_eq!(termato.time_remaining_in_sec, 60); - } - - #[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); - } - - #[test] - fn termato_visualizer_when_called_it_should_verify_opt_in_behavior() { - let result = Termato::new(25, 5); - assert!(!result.enable_visualizer); - - let result = result.with_visualizer(true); - assert!(result.enable_visualizer); - } -} -- 2.54.0 From a62f54e85b01dbb0c25e7f26c29b1513231cdb99 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:55:17 -0500 Subject: [PATCH 09/10] refactor: extract argument parsing to separate module --- src/args.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 72 ++++++++-------------- 2 files changed, 190 insertions(+), 49 deletions(-) create mode 100644 src/args.rs diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..bc93d3a --- /dev/null +++ b/src/args.rs @@ -0,0 +1,167 @@ +use std::env; + +#[derive(Debug, PartialEq, Eq)] +pub struct CliConfig { + pub work_mins: u32, + pub break_mins: u32, + pub enable_notifications: bool, + pub enable_visualizer: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum CliAction { + Run(CliConfig), + PrintHelp, + PrintVersion, + Error(String), +} + +pub struct Cli; + +impl Cli { + pub fn parse() -> CliAction { + Self::parse_args(env::args().collect()) + } + + pub fn parse_args(args: Vec) -> CliAction { + let mut enable_notifications = false; + let mut enable_visualizer = false; + let mut positional = Vec::new(); + + for arg in args.into_iter().skip(1) { + match arg.as_str() { + "-h" | "--help" => return CliAction::PrintHelp, + "-v" | "--version" => return CliAction::PrintVersion, + "-n" | "--notify" => enable_notifications = true, + "-z" | "--visualizer" => enable_visualizer = true, + "--" => {} + s if s.starts_with('-') => { + return CliAction::Error(format!("Unknown option: '{}'", s)); + } + s => positional.push(s.to_string()), + } + } + + let numbers: Vec = positional + .iter() + .filter_map(|s| s.parse::().ok()) + .collect(); + + let work_mins = numbers.first().copied().unwrap_or(25); + let break_mins = numbers.get(1).copied().unwrap_or(5); + + if numbers.len() > 2 { + return CliAction::Error(format!("Unexpected argument: '{}'", positional[2])); + } + + CliAction::Run(CliConfig { + work_mins, + break_mins, + enable_notifications, + enable_visualizer, + }) + } + + pub fn print_help() { + println!("Usage: termato [options] [work_minutes] [break_minutes]"); + println!(); + println!("Options:"); + println!(" -n, --notify Enable desktop notifications"); + println!(" -z, --visualizer Display visualizer of playing audio"); + println!(" -h, --help Print this help message"); + println!(" -v, --version Print the version number"); + println!(); + println!("Defaults: work_minutes = 25, break_minutes = 5"); + } + + pub fn print_version() { + println!("termato version {}", env!("CARGO_PKG_VERSION")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults() { + let args = vec!["termato".to_string()]; + assert_eq!( + Cli::parse_args(args), + CliAction::Run(CliConfig { + work_mins: 25, + break_mins: 5, + enable_notifications: false, + enable_visualizer: false, + }) + ); + } + + #[test] + fn test_cargo_run_syntax_with_dot_and_dash_dash() { + let args = vec![ + "target/debug/termato.exe".to_string(), + ".".to_string(), + "--".to_string(), + "-z".to_string(), + ]; + assert_eq!( + Cli::parse_args(args), + CliAction::Run(CliConfig { + work_mins: 25, + break_mins: 5, + enable_notifications: false, + enable_visualizer: true, + }) + ); + } + + #[test] + fn test_custom_time_and_flags() { + let args = vec![ + "termato".to_string(), + "-n".to_string(), + "-z".to_string(), + "50".to_string(), + "10".to_string(), + ]; + assert_eq!( + Cli::parse_args(args), + CliAction::Run(CliConfig { + work_mins: 50, + break_mins: 10, + enable_notifications: true, + enable_visualizer: true, + }) + ); + } + + #[test] + fn test_help_flag() { + let args = vec!["termato".to_string(), "--help".to_string()]; + assert_eq!(Cli::parse_args(args), CliAction::PrintHelp); + } + + #[test] + fn test_unknown_flag() { + let args = vec!["termato".to_string(), "--foo".to_string()]; + assert_eq!( + Cli::parse_args(args), + CliAction::Error("Unknown option: '--foo'".to_string()) + ); + } + + #[test] + fn test_non_numeric_positional_defaults() { + let args = vec!["termato".to_string(), "abc".to_string()]; + assert_eq!( + Cli::parse_args(args), + CliAction::Run(CliConfig { + work_mins: 25, + break_mins: 5, + enable_notifications: false, + enable_visualizer: false, + }) + ); + } +} diff --git a/src/main.rs b/src/main.rs index b0a7f50..ee93d9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod args; mod audio; mod events; mod msg; @@ -24,26 +25,25 @@ use msg::Message; use terminal::TerminalGuard; use ui::render_app; +use args::{Cli, CliAction}; + fn main() -> Result<(), io::Error> { - let args: Vec = 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"); - println!(" -z, --visualizer Display visualizer of playing audio"); - println!(); - 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 config = match Cli::parse() { + CliAction::PrintHelp => { + Cli::print_help(); + return Ok(()); + } + CliAction::PrintVersion => { + Cli::print_version(); + return Ok(()); + } + CliAction::Error(err) => { + eprintln!("Error: {}", err); + eprintln!("Run 'termato --help' for usage instructions."); + std::process::exit(1); + } + CliAction::Run(cfg) => cfg, + }; let _guard = TerminalGuard::new()?; @@ -51,41 +51,15 @@ fn main() -> Result<(), io::Error> { let mut terminal = Terminal::new(backend)?; let font = Toilet::smblock().unwrap(); - let enable_visualizer = args.iter().any(|arg| arg == "-z" || arg == "--visualizer"); - let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify"); - - let visualizer = if enable_visualizer { + let visualizer = if config.enable_visualizer { Some(AudioVisualizer::new(64)) } else { None }; - let is_flag = |arg: &str| { - arg == "-n" - || arg == "--notify" - || arg == "-z" - || arg == "--visualizer" - || arg == "-h" - || arg == "--help" - || arg == "-v" - || arg == "--version" - }; - - let positional_args: Vec<&String> = args.iter().skip(1).filter(|arg| !is_flag(arg)).collect(); - - 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); - - let mut termato = Termato::new(work_mins, break_mins) - .with_notifications(enable_notifications) - .with_visualizer(enable_visualizer); + let mut termato = Termato::new(config.work_mins, config.break_mins) + .with_notifications(config.enable_notifications) + .with_visualizer(config.enable_visualizer); let (tx, rx) = mpsc::channel(); -- 2.54.0 From 15e0fd9d08381a2e34c56ca59882dff2ba5d74c5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 10:20:37 -0500 Subject: [PATCH 10/10] refactor: extract constants --- src/app.rs | 26 ++++++---------------- src/args.rs | 56 ++++++++++++++++++++++++++++++++++++++---------- src/constants.rs | 4 ++++ src/main.rs | 10 +++++---- src/ui.rs | 13 ++++++----- 5 files changed, 69 insertions(+), 40 deletions(-) create mode 100644 src/constants.rs diff --git a/src/app.rs b/src/app.rs index 498cfe5..39ce384 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,7 @@ +use crate::constants::SECONDS_PER_MIN; use crate::msg::Message; use crate::notification::send_desktop_notification; -const SECONDS_PER_MIN: u32 = 60; - #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum TimerMode { Work, @@ -18,8 +17,7 @@ pub struct Termato { pub time_remaining_in_sec: u32, pub show_help: bool, pub enable_notifications: bool, - pub enable_visualizer: bool, - pub should_quit: bool, + should_quit: bool, } impl Termato { @@ -33,18 +31,16 @@ impl Termato { time_remaining_in_sec: work_mins * SECONDS_PER_MIN, show_help: false, enable_notifications: false, - enable_visualizer: false, should_quit: false, } } - pub fn with_notifications(mut self, enable: bool) -> Self { - self.enable_notifications = enable; - self + pub fn should_quit(&self) -> bool { + self.should_quit } - pub fn with_visualizer(mut self, enable: bool) -> Self { - self.enable_visualizer = enable; + pub fn with_notifications(mut self, enable: bool) -> Self { + self.enable_notifications = enable; self } @@ -120,7 +116,6 @@ mod tests { assert_eq!(result.time_remaining_in_sec, 1500); assert!(!result.show_help); assert!(!result.enable_notifications); - assert!(!result.enable_visualizer); assert!(!result.should_quit); } @@ -273,13 +268,4 @@ mod tests { let result = result.with_notifications(true); assert!(result.enable_notifications); } - - #[test] - fn termato_visualizer_when_called_it_should_verify_opt_in_behavior() { - let result = Termato::new(25, 5); - assert!(!result.enable_visualizer); - - let result = result.with_visualizer(true); - assert!(result.enable_visualizer); - } } diff --git a/src/args.rs b/src/args.rs index bc93d3a..41977ac 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,5 +1,16 @@ use std::env; +use crate::constants::{DEFAULT_BREAK_MINS, DEFAULT_WORK_MINS}; + +pub const FLAG_HELP: &str = "-h"; +pub const FLAG_HELP_LONG: &str = "--help"; +pub const FLAG_VERSION: &str = "-v"; +pub const FLAG_VERSION_LONG: &str = "--version"; +pub const FLAG_NOTIFY: &str = "-n"; +pub const FLAG_NOTIFY_LONG: &str = "--notify"; +pub const FLAG_VISUALIZER: &str = "-z"; +pub const FLAG_VISUALIZER_LONG: &str = "--visualizer"; + #[derive(Debug, PartialEq, Eq)] pub struct CliConfig { pub work_mins: u32, @@ -30,10 +41,10 @@ impl Cli { for arg in args.into_iter().skip(1) { match arg.as_str() { - "-h" | "--help" => return CliAction::PrintHelp, - "-v" | "--version" => return CliAction::PrintVersion, - "-n" | "--notify" => enable_notifications = true, - "-z" | "--visualizer" => enable_visualizer = true, + FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp, + FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion, + FLAG_NOTIFY | FLAG_NOTIFY_LONG => enable_notifications = true, + FLAG_VISUALIZER | FLAG_VISUALIZER_LONG => enable_visualizer = true, "--" => {} s if s.starts_with('-') => { return CliAction::Error(format!("Unknown option: '{}'", s)); @@ -47,8 +58,8 @@ impl Cli { .filter_map(|s| s.parse::().ok()) .collect(); - let work_mins = numbers.first().copied().unwrap_or(25); - let break_mins = numbers.get(1).copied().unwrap_or(5); + let work_mins = numbers.first().copied().unwrap_or(DEFAULT_WORK_MINS); + let break_mins = numbers.get(1).copied().unwrap_or(DEFAULT_BREAK_MINS); if numbers.len() > 2 { return CliAction::Error(format!("Unexpected argument: '{}'", positional[2])); @@ -64,14 +75,37 @@ impl Cli { pub fn print_help() { println!("Usage: termato [options] [work_minutes] [break_minutes]"); + println!(); + println!("Options:"); - println!(" -n, --notify Enable desktop notifications"); - println!(" -z, --visualizer Display visualizer of playing audio"); - println!(" -h, --help Print this help message"); - println!(" -v, --version Print the version number"); + + println!( + " {:<19} Enable desktop notifications", + format_args!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG) + ); + + println!( + " {:<19} Display visualizer of playing audio", + format_args!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG) + ); + + println!( + " {:<19} Print this help message", + format_args!("{}, {}", FLAG_HELP, FLAG_HELP_LONG) + ); + + println!( + " {:<19} Print the version number", + format_args!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG) + ); + println!(); - println!("Defaults: work_minutes = 25, break_minutes = 5"); + + println!( + "Defaults: work_minutes = {}, break_minutes = {}", + DEFAULT_WORK_MINS, DEFAULT_BREAK_MINS + ); } pub fn print_version() { diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..77def8e --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,4 @@ +pub const SECONDS_PER_MIN: u32 = 60; +pub const DEFAULT_WORK_MINS: u32 = 25; +pub const DEFAULT_BREAK_MINS: u32 = 5; +pub const VIZ_NUM_BARS: u16 = 64; diff --git a/src/main.rs b/src/main.rs index ee93d9d..2c03afb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod args; mod audio; +mod constants; mod events; mod msg; mod notification; @@ -27,6 +28,8 @@ use ui::render_app; use args::{Cli, CliAction}; +use crate::constants::VIZ_NUM_BARS; + fn main() -> Result<(), io::Error> { let config = match Cli::parse() { CliAction::PrintHelp => { @@ -52,14 +55,13 @@ fn main() -> Result<(), io::Error> { let font = Toilet::smblock().unwrap(); let visualizer = if config.enable_visualizer { - Some(AudioVisualizer::new(64)) + Some(AudioVisualizer::new(VIZ_NUM_BARS as usize)) } else { None }; let mut termato = Termato::new(config.work_mins, config.break_mins) - .with_notifications(config.enable_notifications) - .with_visualizer(config.enable_visualizer); + .with_notifications(config.enable_notifications); let (tx, rx) = mpsc::channel(); @@ -72,7 +74,7 @@ fn main() -> Result<(), io::Error> { } }); - while !termato.should_quit { + while !termato.should_quit() { terminal.draw(|f| render_app(&termato, f, &font, visualizer.as_ref()))?; if event::poll(Duration::from_millis(16))? { diff --git a/src/ui.rs b/src/ui.rs index 875cb80..7984fec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,8 +6,11 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph}, }; -use crate::app::{Termato, TimerMode}; -use crate::audio::AudioVisualizer; +use crate::{audio::AudioVisualizer, constants::VIZ_NUM_BARS}; +use crate::{ + app::{Termato, TimerMode}, + constants::SECONDS_PER_MIN, +}; // use unicode blocks for rendering // Empty -> " " @@ -52,8 +55,8 @@ pub fn render_app( ) { let size = f.area(); - let minutes = app.time_remaining_in_sec / 60; - let seconds = app.time_remaining_in_sec % 60; + let minutes = app.time_remaining_in_sec / SECONDS_PER_MIN; + let seconds = app.time_remaining_in_sec % SECONDS_PER_MIN; let time_str = format!("{:02}:{:02}", minutes, seconds); let time_color = if !app.is_running { @@ -76,7 +79,7 @@ pub fn render_app( let text_height = text_lines.len() as u16; let viz_height = if visualizer.is_some() { 1 } else { 0 }; - let viz_width = 64; + let viz_width = VIZ_NUM_BARS; let content_height = text_height + viz_height; -- 2.54.0