13 Commits
11 changed files with 473 additions and 124 deletions
Generated
+1 -1
View File
@@ -2120,7 +2120,7 @@ dependencies = [
[[package]] [[package]]
name = "termato" name = "termato"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"cpal", "cpal",
"crossterm", "crossterm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "termato" name = "termato"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
repository = "https://github.com/StevanFreeborn/termato" repository = "https://github.com/StevanFreeborn/termato"
+14 -3
View File
@@ -7,6 +7,8 @@ A minimal and unintrusive Pomodoro timer written in Rust for the terminal.
- Supports focus sessions and break sessions. - Supports focus sessions and break sessions.
- System-level alerts to notify you when a session ends. - System-level alerts to notify you when a session ends.
- Define custom focus and break session durations from the command line. - Define custom focus and break session durations from the command line.
- Cross-platform sound notifications when a session ends.
- Cross-platform audio visualizer.
## Keyboard Controls ## Keyboard Controls
@@ -52,7 +54,7 @@ termato
### Specifying Options and Custom Session Times ### Specifying Options and Custom Session Times
By default, desktop notifications are disabled. You can specify options and custom session durations as command-line arguments: By default, desktop notifications, sound alerts, and the visualizer are disabled. You can specify options and custom session durations as command-line arguments:
```bash ```bash
termato [options] [work_minutes] [break_minutes] termato [options] [work_minutes] [break_minutes]
@@ -64,10 +66,19 @@ For example, to enable desktop notifications and run a 50-minute focus session f
termato -n 50 10 termato -n 50 10
``` ```
Or using the long flag: To enable both desktop notifications and sound notifications with custom times:
```bash ```bash
termato --notify 50 10 termato -ns 50 10
# or with long flags
termato --notify -sound 50 10
```
You can also combine short flags together (e.g. -nsz to enable notifications, sound, and the audio visualizer):
```bash
termato -nsz
``` ```
### Help and Version ### Help and Version
+46 -60
View File
@@ -1,7 +1,8 @@
use crate::args::CliConfig;
use crate::constants::SECONDS_PER_MIN;
use crate::msg::Message; use crate::msg::Message;
use crate::notification::send_desktop_notification; use crate::notification::send_desktop_notification;
use crate::sound::play_notification_sound;
const SECONDS_PER_MIN: u32 = 60;
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TimerMode { pub enum TimerMode {
@@ -10,42 +11,31 @@ pub enum TimerMode {
} }
pub struct Termato { pub struct Termato {
pub work_mins: u32, pub config: CliConfig,
pub break_mins: u32,
pub mode: TimerMode, pub mode: TimerMode,
pub is_running: bool, pub is_running: bool,
pub duration_in_secs: u32, pub duration_in_secs: u32,
pub time_remaining_in_sec: u32, pub time_remaining_in_sec: u32,
pub show_help: bool, pub show_help: bool,
pub enable_notifications: bool, should_quit: bool,
pub enable_visualizer: bool,
pub should_quit: bool,
} }
impl Termato { impl Termato {
pub fn new(work_mins: u32, break_mins: u32) -> Self { pub fn new(config: CliConfig) -> Self {
let duration_in_secs = config.work_mins * SECONDS_PER_MIN;
Termato { Termato {
work_mins, config,
break_mins,
mode: TimerMode::Work, mode: TimerMode::Work,
is_running: false, is_running: false,
duration_in_secs: work_mins * SECONDS_PER_MIN, duration_in_secs,
time_remaining_in_sec: work_mins * SECONDS_PER_MIN, time_remaining_in_sec: duration_in_secs,
show_help: false, show_help: false,
enable_notifications: false,
enable_visualizer: false,
should_quit: false, should_quit: false,
} }
} }
pub fn with_notifications(mut self, enable: bool) -> Self { pub fn should_quit(&self) -> bool {
self.enable_notifications = enable; self.should_quit
self
}
pub fn with_visualizer(mut self, enable: bool) -> Self {
self.enable_visualizer = enable;
self
} }
pub fn update(&mut self, msg: Message) { pub fn update(&mut self, msg: Message) {
@@ -69,8 +59,8 @@ impl Termato {
TimerMode::Break => TimerMode::Work, TimerMode::Break => TimerMode::Work,
}; };
self.duration_in_secs = match self.mode { self.duration_in_secs = match self.mode {
TimerMode::Work => self.work_mins * SECONDS_PER_MIN, TimerMode::Work => self.config.work_mins * SECONDS_PER_MIN,
TimerMode::Break => self.break_mins * SECONDS_PER_MIN, TimerMode::Break => self.config.break_mins * SECONDS_PER_MIN,
}; };
self.time_remaining_in_sec = self.duration_in_secs; self.time_remaining_in_sec = self.duration_in_secs;
} }
@@ -80,13 +70,17 @@ impl Termato {
self.time_remaining_in_sec -= 1; self.time_remaining_in_sec -= 1;
if self.time_remaining_in_sec == 0 { if self.time_remaining_in_sec == 0 {
if self.enable_notifications { if self.config.enable_notifications {
match self.mode { match self.mode {
TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."), TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."),
TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."), TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."),
} }
} }
if self.config.enable_sound {
play_notification_sound();
}
self.toggle_mode(); self.toggle_mode();
} }
} }
@@ -95,7 +89,7 @@ impl Termato {
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.is_running = false; self.is_running = false;
self.mode = TimerMode::Work; self.mode = TimerMode::Work;
self.duration_in_secs = self.work_mins * SECONDS_PER_MIN; self.duration_in_secs = self.config.work_mins * SECONDS_PER_MIN;
self.time_remaining_in_sec = self.duration_in_secs; self.time_remaining_in_sec = self.duration_in_secs;
} }
@@ -108,25 +102,35 @@ impl Termato {
mod tests { mod tests {
use super::*; use super::*;
fn get_test_config(work_mins: u32, break_mins: u32) -> CliConfig {
CliConfig {
work_mins,
break_mins,
enable_notifications: false,
enable_sound: false,
enable_visualizer: false,
}
}
#[test] #[test]
fn termato_new_when_called_it_should_return_expected_starting_state() { fn termato_new_when_called_it_should_return_expected_starting_state() {
let result = Termato::new(25, 5); let result = Termato::new(get_test_config(25, 5));
assert_eq!(result.work_mins, 25); assert_eq!(result.config.work_mins, 25);
assert_eq!(result.break_mins, 5); assert_eq!(result.config.break_mins, 5);
assert_eq!(result.mode, TimerMode::Work); assert_eq!(result.mode, TimerMode::Work);
assert!(!result.is_running); assert!(!result.is_running);
assert_eq!(result.duration_in_secs, 1500); assert_eq!(result.duration_in_secs, 1500);
assert_eq!(result.time_remaining_in_sec, 1500); assert_eq!(result.time_remaining_in_sec, 1500);
assert!(!result.show_help); assert!(!result.show_help);
assert!(!result.enable_notifications); assert!(!result.config.enable_notifications);
assert!(!result.enable_visualizer); assert!(!result.config.enable_sound);
assert!(!result.should_quit); assert!(!result.should_quit);
} }
#[test] #[test]
fn termato_update_when_called_with_messages_it_should_update_state() { fn termato_update_when_called_with_messages_it_should_update_state() {
let mut termato = Termato::new(25, 5); let mut termato = Termato::new(get_test_config(25, 5));
termato.update(Message::ToggleRunning); termato.update(Message::ToggleRunning);
assert!(termato.is_running); assert!(termato.is_running);
@@ -147,7 +151,7 @@ mod tests {
#[test] #[test]
fn termato_tick_when_called_and_not_running_it_should_do_nothing() { fn termato_tick_when_called_and_not_running_it_should_do_nothing() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.update(Message::Tick); termato.update(Message::Tick);
@@ -156,7 +160,7 @@ mod tests {
#[test] #[test]
fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() { fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.is_running = true; termato.is_running = true;
termato.time_remaining_in_sec = 0; termato.time_remaining_in_sec = 0;
@@ -167,7 +171,7 @@ mod tests {
#[test] #[test]
fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() { fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.is_running = true; termato.is_running = true;
termato.update(Message::Tick); termato.update(Message::Tick);
@@ -177,7 +181,7 @@ mod tests {
#[test] #[test]
fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() { fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() {
let mut termato = Termato::new(1, 2); let mut termato = Termato::new(get_test_config(1, 2));
termato.is_running = true; termato.is_running = true;
termato.time_remaining_in_sec = 1; termato.time_remaining_in_sec = 1;
@@ -190,7 +194,7 @@ mod tests {
#[test] #[test]
fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() { fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() {
let mut termato = Termato::new(2, 1); let mut termato = Termato::new(get_test_config(2, 1));
termato.mode = TimerMode::Break; termato.mode = TimerMode::Break;
termato.is_running = true; termato.is_running = true;
termato.time_remaining_in_sec = 1; termato.time_remaining_in_sec = 1;
@@ -204,7 +208,7 @@ mod tests {
#[test] #[test]
fn termato_toggle_mode_when_called_it_should_toggle_the_mode() { fn termato_toggle_mode_when_called_it_should_toggle_the_mode() {
let mut termato = Termato::new(2, 1); let mut termato = Termato::new(get_test_config(2, 1));
termato.toggle_mode(); termato.toggle_mode();
assert_eq!(termato.mode, TimerMode::Break); assert_eq!(termato.mode, TimerMode::Break);
@@ -219,7 +223,7 @@ mod tests {
#[test] #[test]
fn termato_toggle_running_when_called_and_already_running_it_should_pause() { fn termato_toggle_running_when_called_and_already_running_it_should_pause() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.is_running = true; termato.is_running = true;
termato.toggle_running(); termato.toggle_running();
@@ -229,7 +233,7 @@ mod tests {
#[test] #[test]
fn termato_toggle_running_when_called_and_already_paused_it_should_run() { fn termato_toggle_running_when_called_and_already_paused_it_should_run() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.toggle_running(); termato.toggle_running();
@@ -238,7 +242,7 @@ mod tests {
#[test] #[test]
fn termato_rest_when_called_it_should_reset_the_apps_state() { fn termato_rest_when_called_it_should_reset_the_apps_state() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.is_running = true; termato.is_running = true;
termato.mode = TimerMode::Break; termato.mode = TimerMode::Break;
termato.duration_in_secs = 1; termato.duration_in_secs = 1;
@@ -254,7 +258,7 @@ mod tests {
#[test] #[test]
fn termato_toggle_help_when_called_it_should_toggle_help() { fn termato_toggle_help_when_called_it_should_toggle_help() {
let mut termato = Termato::new(1, 1); let mut termato = Termato::new(get_test_config(1, 1));
termato.toggle_help(); termato.toggle_help();
@@ -264,22 +268,4 @@ mod tests {
assert!(!termato.show_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);
}
} }
+259
View File
@@ -0,0 +1,259 @@
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_SOUND: &str = "-s";
pub const FLAG_SOUND_LONG: &str = "--sound";
pub const FLAG_VISUALIZER: &str = "-z";
pub const FLAG_VISUALIZER_LONG: &str = "--visualizer";
#[derive(Debug, PartialEq, Eq)]
pub struct CliConfig {
pub work_mins: u32,
pub break_mins: u32,
pub enable_notifications: bool,
pub enable_sound: 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<String>) -> CliAction {
let mut enable_notifications = false;
let mut enable_visualizer = false;
let mut enable_sound = false;
let mut positional = Vec::new();
for arg in args.into_iter().skip(1) {
match arg.as_str() {
FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp,
FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion,
FLAG_SOUND | FLAG_SOUND_LONG => enable_sound = true,
FLAG_VISUALIZER | FLAG_VISUALIZER_LONG => enable_visualizer = true,
"--" => {}
s if s.starts_with("--") => match s {
FLAG_NOTIFY_LONG => enable_notifications = true,
FLAG_VISUALIZER_LONG => enable_visualizer = true,
FLAG_SOUND_LONG => enable_sound = true,
_ => return CliAction::Error(format!("Unknown option: '{}'", s)),
},
s if s.starts_with('-') => {
for ch in s.chars().skip(1) {
match ch {
'h' => return CliAction::PrintHelp,
'v' => return CliAction::PrintVersion,
'n' => enable_notifications = true,
's' => enable_sound = true,
'z' => enable_visualizer = true,
_ => return CliAction::Error(format!("Unknown option: '-{}'", ch)),
}
}
}
s => positional.push(s.to_string()),
}
}
let numbers: Vec<u32> = positional
.iter()
.filter_map(|s| s.parse::<u32>().ok())
.collect();
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]));
}
CliAction::Run(CliConfig {
work_mins,
break_mins,
enable_notifications,
enable_sound,
enable_visualizer,
})
}
pub fn print_help() {
println!("Usage: termato [options] [work_minutes] [break_minutes]");
println!();
println!("Options:");
println!(
" {:<19} Enable desktop notifications",
format!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG)
);
println!(
" {:<19} Enable sound notifications",
format!("{}, {}", FLAG_SOUND, FLAG_SOUND_LONG)
);
println!(
" {:<19} Display visualizer of playing audio",
format!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG)
);
println!(
" {:<19} Print this help message",
format!("{}, {}", FLAG_HELP, FLAG_HELP_LONG)
);
println!(
" {:<19} Print the version number",
format!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG)
);
println!();
println!(
"Defaults: work_minutes = {}, break_minutes = {}",
DEFAULT_WORK_MINS, DEFAULT_BREAK_MINS
);
}
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_sound: 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_sound: 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_sound: false,
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_sound: false,
enable_visualizer: false,
})
);
}
#[test]
fn test_combined_short_flags() {
let args = vec![
"termato".to_string(),
"-nsz".to_string(),
"25".to_string(),
"5".to_string(),
];
assert_eq!(
Cli::parse_args(args),
CliAction::Run(CliConfig {
work_mins: 25,
break_mins: 5,
enable_notifications: true,
enable_sound: true,
enable_visualizer: true,
})
);
}
#[test]
fn test_combined_short_flags_with_invalid_char() {
let args = vec!["termato".to_string(), "-nxz".to_string()];
assert_eq!(
Cli::parse_args(args),
CliAction::Error("Unknown option: '-x'".to_string())
);
}
}
+16 -2
View File
@@ -248,14 +248,28 @@ impl AudioVisualizer {
thread::sleep(std::time::Duration::from_millis(16)); thread::sleep(std::time::Duration::from_millis(16));
let samples = { let samples = {
let buf = match audio_buffer.lock() { let mut buf = match audio_buffer.lock() {
Ok(b) => b, Ok(b) => b,
Err(_) => continue, Err(_) => continue,
}; };
// decay heights smoothly
if buf.len() < chunk_size { if buf.len() < chunk_size {
for val in prev_heights.iter_mut() {
*val = (*val - falloff).max(0.0);
}
if let Ok(mut bars) = bar_data.lock() {
for (i, val) in prev_heights.iter().enumerate() {
bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64;
}
}
continue; continue;
} }
buf[buf.len() - chunk_size..].to_vec()
// drain the consumed samples
buf.drain(0..chunk_size).collect::<Vec<f32>>()
}; };
Self::apply_hann_window(&samples, &mut windowed_buffer); Self::apply_hann_window(&samples, &mut windowed_buffer);
+4
View File
@@ -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;
+1 -1
View File
@@ -1,5 +1,5 @@
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crate::msg::Message; use crate::msg::Message;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
pub fn handle_event(event: Event) -> Option<Message> { pub fn handle_event(event: Event) -> Option<Message> {
if let Event::Key(key) = event if let Event::Key(key) = event
+27 -51
View File
@@ -1,8 +1,11 @@
mod app; mod app;
mod args;
mod audio; mod audio;
mod constants;
mod events; mod events;
mod msg; mod msg;
mod notification; mod notification;
mod sound;
mod terminal; mod terminal;
mod ui; mod ui;
@@ -24,26 +27,27 @@ use msg::Message;
use terminal::TerminalGuard; use terminal::TerminalGuard;
use ui::render_app; use ui::render_app;
use args::{Cli, CliAction};
use crate::constants::VIZ_NUM_BARS;
fn main() -> Result<(), io::Error> { fn main() -> Result<(), io::Error> {
let args: Vec<String> = std::env::args().collect(); let config = match Cli::parse() {
CliAction::PrintHelp => {
if args.iter().any(|arg| arg == "-h" || arg == "--help") { Cli::print_help();
println!("Usage: termato [options] [work_minutes] [break_minutes]"); return Ok(());
println!(); }
println!("Options:"); CliAction::PrintVersion => {
println!(" -n, --notify Enable desktop notifications"); Cli::print_version();
println!(" -h, --help Print this help message"); return Ok(());
println!(" -v, --version Print the version number"); }
println!(" -z, --visualizer Display visualizer of playing audio"); CliAction::Error(err) => {
println!(); eprintln!("Error: {}", err);
println!("Defaults: work_minutes = 25, break_minutes = 5"); eprintln!("Run 'termato --help' for usage instructions.");
return Ok(()); std::process::exit(1);
} }
CliAction::Run(cfg) => cfg,
if args.iter().any(|arg| arg == "-v" || arg == "--version") { };
println!("termato version {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let _guard = TerminalGuard::new()?; let _guard = TerminalGuard::new()?;
@@ -51,41 +55,13 @@ fn main() -> Result<(), io::Error> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let font = Toilet::smblock().unwrap(); let font = Toilet::smblock().unwrap();
let enable_visualizer = args.iter().any(|arg| arg == "-z" || arg == "--visualizer"); let visualizer = if config.enable_visualizer {
let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify"); Some(AudioVisualizer::new(VIZ_NUM_BARS as usize))
let visualizer = if enable_visualizer {
Some(AudioVisualizer::new(64))
} else { } else {
None None
}; };
let is_flag = |arg: &str| { let mut termato = Termato::new(config);
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 (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
@@ -98,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()))?; terminal.draw(|f| render_app(&termato, f, &font, visualizer.as_ref()))?;
if event::poll(Duration::from_millis(16))? { if event::poll(Duration::from_millis(16))? {
+96
View File
@@ -0,0 +1,96 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, mpsc};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
const FREQ_D5_HZ: f32 = 587.33;
const FREQ_A5_HZ: f32 = 880.00;
const NOTE_1_DURATION_SECS: f32 = 0.15;
const NOTE_2_DURATION_SECS: f32 = 0.25;
const TOTAL_CHIME_DURATION_SECS: f32 = NOTE_1_DURATION_SECS + NOTE_2_DURATION_SECS;
const CHIME_VOLUME_GAIN: f32 = 0.25;
static SOUND_BUSY: LazyLock<AtomicBool> = LazyLock::new(|| AtomicBool::new(false));
pub fn play_notification_sound() {
if SOUND_BUSY.swap(true, Ordering::AcqRel) {
return;
}
std::thread::spawn(move || {
// term bell fall back
print!("\x07");
let _ = std::io::Write::flush(&mut std::io::stdout());
let _ = play_audio_chime();
SOUND_BUSY.store(false, Ordering::Release);
});
}
fn synthesize_sine_wave(freq_hz: f32, t_seconds: f32) -> f32 {
(2.0 * std::f32::consts::PI * freq_hz * t_seconds).sin()
}
fn calculate_linear_decay(t_seconds: f32, duration_secs: f32) -> f32 {
(1.0 - (t_seconds / duration_secs)).max(0.0)
}
fn play_audio_chime() -> Result<(), Box<dyn std::error::Error>> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or("No default output audio device found")?;
let config: cpal::StreamConfig = device.default_output_config()?.into();
let sample_rate = config.sample_rate as f32;
let channels = config.channels as usize;
let mut sample_clock = 0u64;
let (tx, rx) = mpsc::channel::<()>();
let stream = device.build_output_stream(
config,
move |data: &mut [f32], _| {
for frame in data.chunks_mut(channels) {
// Convert sample counter to elapsed time in seconds
let elapsed_time = sample_clock as f32 / sample_rate;
sample_clock += 1;
let sample = if elapsed_time < NOTE_1_DURATION_SECS {
let wave = synthesize_sine_wave(FREQ_D5_HZ, elapsed_time);
let envelope = calculate_linear_decay(elapsed_time, NOTE_1_DURATION_SECS);
wave * envelope * CHIME_VOLUME_GAIN
} else if elapsed_time < TOTAL_CHIME_DURATION_SECS {
let t_note2 = elapsed_time - NOTE_1_DURATION_SECS;
let wave = synthesize_sine_wave(FREQ_A5_HZ, t_note2);
let envelope = calculate_linear_decay(t_note2, NOTE_2_DURATION_SECS);
wave * envelope * CHIME_VOLUME_GAIN
} else {
0.0
};
for channel in frame.iter_mut() {
*channel = sample;
}
if elapsed_time >= TOTAL_CHIME_DURATION_SECS {
let _ = tx.send(());
}
}
},
|err| eprintln!("Audio output stream error: {}", err),
None,
)?;
stream.play()?;
let _ = rx.recv_timeout(Duration::from_millis(500));
Ok(())
}
+8 -5
View File
@@ -6,8 +6,11 @@ use ratatui::{
widgets::{Block, Borders, Clear, Paragraph}, widgets::{Block, Borders, Clear, Paragraph},
}; };
use crate::app::{Termato, TimerMode}; use crate::{
use crate::audio::AudioVisualizer; app::{Termato, TimerMode},
constants::SECONDS_PER_MIN,
};
use crate::{audio::AudioVisualizer, constants::VIZ_NUM_BARS};
// use unicode blocks for rendering // use unicode blocks for rendering
// Empty -> " " // Empty -> " "
@@ -52,8 +55,8 @@ pub fn render_app(
) { ) {
let size = f.area(); let size = f.area();
let minutes = app.time_remaining_in_sec / 60; let minutes = app.time_remaining_in_sec / SECONDS_PER_MIN;
let seconds = app.time_remaining_in_sec % 60; let seconds = app.time_remaining_in_sec % SECONDS_PER_MIN;
let time_str = format!("{:02}:{:02}", minutes, seconds); let time_str = format!("{:02}:{:02}", minutes, seconds);
let time_color = if !app.is_running { let time_color = if !app.is_running {
@@ -76,7 +79,7 @@ pub fn render_app(
let text_height = text_lines.len() as u16; let text_height = text_lines.len() as u16;
let viz_height = if visualizer.is_some() { 1 } else { 0 }; 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; let content_height = text_height + viz_height;