Merge pull request 'feat: add sound notification' (#6) from stevanfreeborn/feat/add-sound-notification into main
This commit was merged in pull request #6.
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "termato"
|
name = "termato"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
repository = "https://github.com/StevanFreeborn/termato"
|
repository = "https://github.com/StevanFreeborn/termato"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+42
-42
@@ -1,6 +1,8 @@
|
|||||||
|
use crate::args::CliConfig;
|
||||||
use crate::constants::SECONDS_PER_MIN;
|
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;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
pub enum TimerMode {
|
pub enum TimerMode {
|
||||||
@@ -9,28 +11,25 @@ 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,
|
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,
|
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,11 +38,6 @@ impl Termato {
|
|||||||
self.should_quit
|
self.should_quit
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_notifications(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_notifications = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&mut self, msg: Message) {
|
pub fn update(&mut self, msg: Message) {
|
||||||
match msg {
|
match msg {
|
||||||
Message::Tick => self.tick(),
|
Message::Tick => self.tick(),
|
||||||
@@ -65,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;
|
||||||
}
|
}
|
||||||
@@ -76,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,24 +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.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);
|
||||||
@@ -142,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);
|
||||||
|
|
||||||
@@ -151,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;
|
||||||
|
|
||||||
@@ -162,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);
|
||||||
@@ -172,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;
|
||||||
|
|
||||||
@@ -185,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;
|
||||||
@@ -199,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);
|
||||||
@@ -214,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();
|
||||||
@@ -224,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();
|
||||||
|
|
||||||
@@ -233,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;
|
||||||
@@ -249,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();
|
||||||
|
|
||||||
@@ -259,13 +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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-6
@@ -8,6 +8,8 @@ pub const FLAG_VERSION: &str = "-v";
|
|||||||
pub const FLAG_VERSION_LONG: &str = "--version";
|
pub const FLAG_VERSION_LONG: &str = "--version";
|
||||||
pub const FLAG_NOTIFY: &str = "-n";
|
pub const FLAG_NOTIFY: &str = "-n";
|
||||||
pub const FLAG_NOTIFY_LONG: &str = "--notify";
|
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: &str = "-z";
|
||||||
pub const FLAG_VISUALIZER_LONG: &str = "--visualizer";
|
pub const FLAG_VISUALIZER_LONG: &str = "--visualizer";
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ pub struct CliConfig {
|
|||||||
pub work_mins: u32,
|
pub work_mins: u32,
|
||||||
pub break_mins: u32,
|
pub break_mins: u32,
|
||||||
pub enable_notifications: bool,
|
pub enable_notifications: bool,
|
||||||
|
pub enable_sound: bool,
|
||||||
pub enable_visualizer: bool,
|
pub enable_visualizer: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,17 +40,33 @@ impl Cli {
|
|||||||
pub fn parse_args(args: Vec<String>) -> CliAction {
|
pub fn parse_args(args: Vec<String>) -> CliAction {
|
||||||
let mut enable_notifications = false;
|
let mut enable_notifications = false;
|
||||||
let mut enable_visualizer = false;
|
let mut enable_visualizer = false;
|
||||||
|
let mut enable_sound = false;
|
||||||
let mut positional = Vec::new();
|
let mut positional = Vec::new();
|
||||||
|
|
||||||
for arg in args.into_iter().skip(1) {
|
for arg in args.into_iter().skip(1) {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp,
|
FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp,
|
||||||
FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion,
|
FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion,
|
||||||
FLAG_NOTIFY | FLAG_NOTIFY_LONG => enable_notifications = true,
|
FLAG_SOUND | FLAG_SOUND_LONG => enable_sound = true,
|
||||||
FLAG_VISUALIZER | FLAG_VISUALIZER_LONG => enable_visualizer = 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('-') => {
|
s if s.starts_with('-') => {
|
||||||
return CliAction::Error(format!("Unknown option: '{}'", s));
|
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()),
|
s => positional.push(s.to_string()),
|
||||||
}
|
}
|
||||||
@@ -69,6 +88,7 @@ impl Cli {
|
|||||||
work_mins,
|
work_mins,
|
||||||
break_mins,
|
break_mins,
|
||||||
enable_notifications,
|
enable_notifications,
|
||||||
|
enable_sound,
|
||||||
enable_visualizer,
|
enable_visualizer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,22 +102,27 @@ impl Cli {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {:<19} Enable desktop notifications",
|
" {:<19} Enable desktop notifications",
|
||||||
format_args!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG)
|
format!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG)
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {:<19} Enable sound notifications",
|
||||||
|
format!("{}, {}", FLAG_SOUND, FLAG_SOUND_LONG)
|
||||||
);
|
);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {:<19} Display visualizer of playing audio",
|
" {:<19} Display visualizer of playing audio",
|
||||||
format_args!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG)
|
format!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG)
|
||||||
);
|
);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {:<19} Print this help message",
|
" {:<19} Print this help message",
|
||||||
format_args!("{}, {}", FLAG_HELP, FLAG_HELP_LONG)
|
format!("{}, {}", FLAG_HELP, FLAG_HELP_LONG)
|
||||||
);
|
);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {:<19} Print the version number",
|
" {:<19} Print the version number",
|
||||||
format_args!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG)
|
format!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG)
|
||||||
);
|
);
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
@@ -126,6 +151,7 @@ mod tests {
|
|||||||
work_mins: 25,
|
work_mins: 25,
|
||||||
break_mins: 5,
|
break_mins: 5,
|
||||||
enable_notifications: false,
|
enable_notifications: false,
|
||||||
|
enable_sound: false,
|
||||||
enable_visualizer: false,
|
enable_visualizer: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -145,6 +171,7 @@ mod tests {
|
|||||||
work_mins: 25,
|
work_mins: 25,
|
||||||
break_mins: 5,
|
break_mins: 5,
|
||||||
enable_notifications: false,
|
enable_notifications: false,
|
||||||
|
enable_sound: false,
|
||||||
enable_visualizer: true,
|
enable_visualizer: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -165,6 +192,7 @@ mod tests {
|
|||||||
work_mins: 50,
|
work_mins: 50,
|
||||||
break_mins: 10,
|
break_mins: 10,
|
||||||
enable_notifications: true,
|
enable_notifications: true,
|
||||||
|
enable_sound: false,
|
||||||
enable_visualizer: true,
|
enable_visualizer: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -194,8 +222,38 @@ mod tests {
|
|||||||
work_mins: 25,
|
work_mins: 25,
|
||||||
break_mins: 5,
|
break_mins: 5,
|
||||||
enable_notifications: false,
|
enable_notifications: false,
|
||||||
|
enable_sound: false,
|
||||||
enable_visualizer: 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
@@ -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);
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -5,6 +5,7 @@ mod constants;
|
|||||||
mod events;
|
mod events;
|
||||||
mod msg;
|
mod msg;
|
||||||
mod notification;
|
mod notification;
|
||||||
|
mod sound;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -60,8 +61,7 @@ fn main() -> Result<(), io::Error> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut termato = Termato::new(config.work_mins, config.break_mins)
|
let mut termato = Termato::new(config);
|
||||||
.with_notifications(config.enable_notifications);
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ use ratatui::{
|
|||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{audio::AudioVisualizer, constants::VIZ_NUM_BARS};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{Termato, TimerMode},
|
app::{Termato, TimerMode},
|
||||||
constants::SECONDS_PER_MIN,
|
constants::SECONDS_PER_MIN,
|
||||||
};
|
};
|
||||||
|
use crate::{audio::AudioVisualizer, constants::VIZ_NUM_BARS};
|
||||||
|
|
||||||
// use unicode blocks for rendering
|
// use unicode blocks for rendering
|
||||||
// Empty -> " "
|
// Empty -> " "
|
||||||
|
|||||||
Reference in New Issue
Block a user