From a92bfe79cc29f6da5cdd238822895b2bb6e322c3 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 09:33:50 -0500 Subject: [PATCH] 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); - } -}