feat: native desktop notifications (experimental) (#59)

* desktop notification by entering `Mode::DONE` for `countdown` and `pomodoro`

* remove redundant `on_done_called` check

* remove build warning (release only)

* log notification errors

* cli arg to enable desktop notifications

* persistant notification settings

* ctrl shortcut

* update changelog

* max timer notification
This commit is contained in:
Jens Krause 2025-01-28 19:28:34 +01:00 committed by GitHub
parent 97787f718d
commit d3c436da0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 957 additions and 221 deletions

View File

@ -1,5 +1,11 @@
# Changelog # Changelog
## v1.1.0 - 2025-__-__
### Features
- (notification) Native desktop notifications (experimental) [#49](https://github.com/sectore/timr-tui/pull/59)
## v1.1.0 - 2025-01-22 ## v1.1.0 - 2025-01-22
### Features ### Features

941
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -27,3 +27,4 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
directories = "5.0.1" directories = "5.0.1"
clap = { version = "4.5.23", features = ["derive"] } clap = { version = "4.5.23", features = ["derive"] }
time = { version = "0.3.37", features = ["formatting", "local-offset"] } time = { version = "0.3.37", features = ["formatting", "local-offset"] }
notify-rust = "4.11.4"

View File

@ -68,14 +68,15 @@ timr-tui --help
Usage: timr-tui [OPTIONS] Usage: timr-tui [OPTIONS]
Options: Options:
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 10:00] -c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 25:00] -w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 5:00] -p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-d, --decis Wether to show deciseconds or not. [default: false] -d, --decis Show deciseconds.
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro] [default: timer] -m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro]
--menu Whether to open the menu or not. -s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille] [default: full] --menu Open the menu.
-r, --reset Reset stored values to default. -r, --reset Reset stored values to default values.
-n, --notification <NOTIFICATION> Toggle desktop notifications on or off. Experimental. [possible values: on, off]
-h, --help Print help -h, --help Print help
-V, --version Print version -V, --version Print version
``` ```

View File

@ -1,13 +1,13 @@
use crate::{ use crate::{
args::Args, args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, Content, Style}, common::{AppEditMode, AppTime, AppTimeFormat, Content, Notification, Style},
constants::TICK_VALUE_MS, constants::TICK_VALUE_MS,
events::{Event, EventHandler, Events}, events::{Event, EventHandler, Events},
storage::AppStorage, storage::AppStorage,
terminal::Terminal, terminal::Terminal,
widgets::{ widgets::{
clock::{self, ClockState, ClockStateArgs}, clock::{self, ClockState, ClockStateArgs},
countdown::{Countdown, CountdownState}, countdown::{Countdown, CountdownState, CountdownStateArgs},
footer::{Footer, FooterState}, footer::{Footer, FooterState},
header::Header, header::Header,
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget}, pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
@ -23,7 +23,7 @@ use ratatui::{
}; };
use std::time::Duration; use std::time::Duration;
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::debug; use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode { enum Mode {
@ -31,10 +31,10 @@ enum Mode {
Quit, Quit,
} }
#[derive(Debug)]
pub struct App { pub struct App {
content: Content, content: Content,
mode: Mode, mode: Mode,
notification: Notification,
app_time: AppTime, app_time: AppTime,
countdown: CountdownState, countdown: CountdownState,
timer: TimerState, timer: TimerState,
@ -47,6 +47,7 @@ pub struct App {
pub struct AppArgs { pub struct AppArgs {
pub style: Style, pub style: Style,
pub with_decis: bool, pub with_decis: bool,
pub notification: Notification,
pub show_menu: bool, pub show_menu: bool,
pub app_time_format: AppTimeFormat, pub app_time_format: AppTimeFormat,
pub content: Content, pub content: Content,
@ -68,6 +69,7 @@ impl From<(Args, AppStorage)> for AppArgs {
AppArgs { AppArgs {
with_decis: args.decis || stg.with_decis, with_decis: args.decis || stg.with_decis,
show_menu: args.menu || stg.show_menu, show_menu: args.menu || stg.show_menu,
notification: args.notification.unwrap_or(stg.notification),
app_time_format: stg.app_time_format, app_time_format: stg.app_time_format,
content: args.mode.unwrap_or(stg.content), content: args.mode.unwrap_or(stg.content),
style: args.style.unwrap_or(stg.style), style: args.style.unwrap_or(stg.style),
@ -111,30 +113,44 @@ impl App {
content, content,
with_decis, with_decis,
pomodoro_mode, pomodoro_mode,
notification,
} = args; } = args;
let app_time = get_app_time(); let app_time = get_app_time();
Self { Self {
mode: Mode::Running, mode: Mode::Running,
notification,
content, content,
app_time, app_time,
style, style,
with_decis, with_decis,
countdown: CountdownState::new( countdown: CountdownState::new(CountdownStateArgs {
ClockState::<clock::Countdown>::new(ClockStateArgs {
initial_value: initial_value_countdown, initial_value: initial_value_countdown,
current_value: current_value_countdown, current_value: current_value_countdown,
tick_value: Duration::from_millis(TICK_VALUE_MS), elapsed_value: elapsed_value_countdown,
with_decis,
}),
elapsed_value_countdown,
app_time, app_time,
), with_decis,
timer: TimerState::new(ClockState::<clock::Timer>::new(ClockStateArgs { with_notification: notification == Notification::On,
}),
timer: TimerState::new(
ClockState::<clock::Timer>::new(ClockStateArgs {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: current_value_timer, current_value: current_value_timer,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis, with_decis,
})), })
.with_on_done_by_condition(
notification == Notification::On,
|| {
debug!("on_done TIMER");
let result = notify_rust::Notification::new()
.summary(&"Timer stopped by reaching its maximum value".to_uppercase())
.show();
if let Err(err) = result {
error!("on_done TIMER error: {err}");
}
},
),
),
pomodoro: PomodoroState::new(PomodoroStateArgs { pomodoro: PomodoroState::new(PomodoroStateArgs {
mode: pomodoro_mode, mode: pomodoro_mode,
initial_value_work, initial_value_work,
@ -142,6 +158,7 @@ impl App {
initial_value_pause, initial_value_pause,
current_value_pause, current_value_pause,
with_decis, with_decis,
with_notification: notification == Notification::On,
}), }),
footer: FooterState::new(show_menu, app_time_format), footer: FooterState::new(show_menu, app_time_format),
} }
@ -261,6 +278,7 @@ impl App {
AppStorage { AppStorage {
content: self.content, content: self.content,
show_menu: self.footer.get_show_menu(), show_menu: self.footer.get_show_menu(),
notification: self.notification,
app_time_format: *self.footer.app_time_format(), app_time_format: *self.footer.app_time_format(),
style: self.style, style: self.style,
with_decis: self.with_decis, with_decis: self.with_decis,

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
common::{Content, Style}, common::{Content, Notification, Style},
duration, duration,
}; };
use clap::Parser; use clap::Parser;
@ -23,7 +23,7 @@ pub struct Args {
)] )]
pub pause: Option<Duration>, pub pause: Option<Duration>,
#[arg(long, short = 'd', help = "Whether to show deciseconds or not.")] #[arg(long, short = 'd', help = "Show deciseconds.")]
pub decis: bool, pub decis: bool,
#[arg(long, short = 'm', value_enum, help = "Mode to start with.")] #[arg(long, short = 'm', value_enum, help = "Mode to start with.")]
@ -32,9 +32,17 @@ pub struct Args {
#[arg(long, short = 's', value_enum, help = "Style to display time with.")] #[arg(long, short = 's', value_enum, help = "Style to display time with.")]
pub style: Option<Style>, pub style: Option<Style>,
#[arg(long, value_enum, help = "Whether to open the menu or not.")] #[arg(long, value_enum, help = "Open the menu.")]
pub menu: bool, pub menu: bool,
#[arg(long, short = 'r', help = "Reset stored values to default.")] #[arg(long, short = 'r', help = "Reset stored values to default values.")]
pub reset: bool, pub reset: bool,
#[arg(
long,
short,
value_enum,
help = "Toggle desktop notifications on or off. Experimental."
)]
pub notification: Option<Notification>,
} }

View File

@ -135,6 +135,15 @@ pub enum AppEditMode {
Time, Time,
} }
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Notification {
#[value(name = "on")]
On,
#[default]
#[value(name = "off")]
Off,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@ -3,7 +3,10 @@ use color_eyre::eyre::{eyre, Result};
use directories::ProjectDirs; use directories::ProjectDirs;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
pub struct Config { pub struct Config {
// silence `field `log_dir` is never read` the easy way
#[cfg_attr(not(debug_assertions), allow(dead_code))]
pub log_dir: PathBuf, pub log_dir: PathBuf,
pub data_dir: PathBuf, pub data_dir: PathBuf,
} }

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
common::{AppTimeFormat, Content, Style}, common::{AppTimeFormat, Content, Notification, Style},
widgets::pomodoro::Mode as PomodoroMode, widgets::pomodoro::Mode as PomodoroMode,
}; };
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
@ -12,6 +12,7 @@ use std::time::Duration;
pub struct AppStorage { pub struct AppStorage {
pub content: Content, pub content: Content,
pub show_menu: bool, pub show_menu: bool,
pub notification: Notification,
pub app_time_format: AppTimeFormat, pub app_time_format: AppTimeFormat,
pub style: Style, pub style: Style,
pub with_decis: bool, pub with_decis: bool,
@ -38,6 +39,7 @@ impl Default for AppStorage {
AppStorage { AppStorage {
content: Content::default(), content: Content::default(),
show_menu: true, show_menu: true,
notification: Notification::Off,
app_time_format: AppTimeFormat::default(), app_time_format: AppTimeFormat::default(),
style: Style::default(), style: Style::default(),
with_decis: false, with_decis: false,

View File

@ -65,7 +65,6 @@ pub enum Format {
HhMmSs, HhMmSs,
} }
#[derive(Debug, Clone)]
pub struct ClockState<T> { pub struct ClockState<T> {
initial_value: DurationEx, initial_value: DurationEx,
current_value: DurationEx, current_value: DurationEx,
@ -73,6 +72,7 @@ pub struct ClockState<T> {
mode: Mode, mode: Mode,
format: Format, format: Format,
pub with_decis: bool, pub with_decis: bool,
on_done: Option<Box<dyn Fn() + 'static>>,
phantom: PhantomData<T>, phantom: PhantomData<T>,
} }
@ -310,6 +310,28 @@ impl<T> ClockState<T> {
self.mode == Mode::Done self.mode == Mode::Done
} }
pub fn with_on_done_by_condition(
mut self,
condition: bool,
handler: impl Fn() + 'static,
) -> Self {
if condition {
self.on_done = Some(Box::new(handler));
self
} else {
self
}
}
fn done(&mut self) {
if !self.is_done() {
self.mode = Mode::Done;
if let Some(handler) = &mut self.on_done {
handler();
};
}
}
fn update_format(&mut self) { fn update_format(&mut self) {
self.format = self.get_format(); self.format = self.get_format();
} }
@ -355,6 +377,7 @@ impl ClockState<Countdown> {
}, },
format: Format::S, format: Format::S,
with_decis, with_decis,
on_done: None,
phantom: PhantomData, phantom: PhantomData,
}; };
// update format once // update format once
@ -365,14 +388,14 @@ impl ClockState<Countdown> {
pub fn tick(&mut self) { pub fn tick(&mut self) {
if self.mode == Mode::Tick { if self.mode == Mode::Tick {
self.current_value = self.current_value.saturating_sub(self.tick_value); self.current_value = self.current_value.saturating_sub(self.tick_value);
self.set_done(); self.check_done();
self.update_format(); self.update_format();
} }
} }
fn set_done(&mut self) { fn check_done(&mut self) {
if self.current_value.eq(&Duration::ZERO.into()) { if self.current_value.eq(&Duration::ZERO.into()) {
self.mode = Mode::Done; self.done();
} }
} }
@ -422,8 +445,9 @@ impl ClockState<Timer> {
Mode::Pause Mode::Pause
}, },
format: Format::S, format: Format::S,
phantom: PhantomData,
with_decis, with_decis,
on_done: None,
phantom: PhantomData,
}; };
// update format once // update format once
instance.update_format(); instance.update_format();
@ -433,14 +457,14 @@ impl ClockState<Timer> {
pub fn tick(&mut self) { pub fn tick(&mut self) {
if self.mode == Mode::Tick { if self.mode == Mode::Tick {
self.current_value = self.current_value.saturating_add(self.tick_value); self.current_value = self.current_value.saturating_add(self.tick_value);
self.set_done(); self.check_done();
self.update_format(); self.update_format();
} }
} }
fn set_done(&mut self) { fn check_done(&mut self) {
if self.current_value.ge(&MAX_DURATION.into()) { if self.current_value.ge(&MAX_DURATION.into()) {
self.mode = Mode::Done; self.done();
} }
} }

View File

@ -9,7 +9,9 @@ use crate::{
edit_time::EditTimeState, edit_time::EditTimeState,
}, },
}; };
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
use notify_rust::Notification;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::KeyCode, crossterm::event::KeyCode,
@ -17,6 +19,7 @@ use ratatui::{
text::Line, text::Line,
widgets::{StatefulWidget, Widget}, widgets::{StatefulWidget, Widget},
}; };
use tracing::{debug, error};
use std::ops::Sub; use std::ops::Sub;
use std::{cmp::max, time::Duration}; use std::{cmp::max, time::Duration};
@ -24,8 +27,16 @@ use time::OffsetDateTime;
use super::edit_time::{EditTimeStateArgs, EditTimeWidget}; use super::edit_time::{EditTimeStateArgs, EditTimeWidget};
pub struct CountdownStateArgs {
pub initial_value: Duration,
pub current_value: Duration,
pub elapsed_value: Duration,
pub app_time: AppTime,
pub with_decis: bool,
pub with_notification: bool,
}
/// State for Countdown Widget /// State for Countdown Widget
#[derive(Debug, Clone)]
pub struct CountdownState { pub struct CountdownState {
/// clock to count down /// clock to count down
clock: ClockState<clock::Countdown>, clock: ClockState<clock::Countdown>,
@ -37,13 +48,32 @@ pub struct CountdownState {
} }
impl CountdownState { impl CountdownState {
pub fn new( pub fn new(args: CountdownStateArgs) -> Self {
clock: ClockState<clock::Countdown>, let CountdownStateArgs {
elapsed_value: Duration, initial_value,
app_time: AppTime, current_value,
) -> Self { elapsed_value,
with_notification,
with_decis,
app_time,
} = args;
Self { Self {
clock, clock: ClockState::<clock::Countdown>::new(ClockStateArgs {
initial_value,
current_value,
tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis,
})
.with_on_done_by_condition(with_notification, || {
debug!("on_done COUNTDOWN");
let result = Notification::new()
.summary(&"Countdown done!".to_uppercase())
.show();
if let Err(err) = result {
error!("on_done COUNTDOWN error: {err}");
}
}),
elapsed_clock: ClockState::<clock::Timer>::new(ClockStateArgs { elapsed_clock: ClockState::<clock::Timer>::new(ClockStateArgs {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: elapsed_value, current_value: elapsed_value,

View File

@ -155,7 +155,7 @@ impl StatefulWidget for Footer {
if self.selected_content == Content::Countdown { if self.selected_content == Content::Countdown {
spans.extend_from_slice(&[ spans.extend_from_slice(&[
Span::from(SPACE), Span::from(SPACE),
Span::from("[ctrl+e]dit by local time"), Span::from("[^e]dit by local time"),
]); ]);
} }
if self.selected_content == Content::Pomodoro { if self.selected_content == Content::Pomodoro {
@ -169,7 +169,7 @@ impl StatefulWidget for Footer {
others => vec![ others => vec![
Span::from(match others { Span::from(match others {
AppEditMode::Clock => "[e]dit done", AppEditMode::Clock => "[e]dit done",
AppEditMode::Time => "[ctrl+e]dit done", AppEditMode::Time => "[^e]dit done",
_ => "", _ => "",
}), }),
Span::from(SPACE), Span::from(SPACE),

View File

@ -3,8 +3,9 @@ use crate::{
constants::TICK_VALUE_MS, constants::TICK_VALUE_MS,
events::{Event, EventHandler}, events::{Event, EventHandler},
utils::center, utils::center,
widgets::clock::{ClockState, ClockWidget, Countdown}, widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown},
}; };
use notify_rust::Notification;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::KeyCode, crossterm::event::KeyCode,
@ -12,13 +13,10 @@ use ratatui::{
text::Line, text::Line,
widgets::{StatefulWidget, Widget}, widgets::{StatefulWidget, Widget},
}; };
use std::{cmp::max, time::Duration};
use strum::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{cmp::max, time::Duration};
use super::clock::ClockStateArgs; use strum::Display;
use tracing::{debug, error};
#[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)]
pub enum Mode { pub enum Mode {
@ -26,7 +24,6 @@ pub enum Mode {
Pause, Pause,
} }
#[derive(Debug, Clone)]
pub struct ClockMap { pub struct ClockMap {
work: ClockState<Countdown>, work: ClockState<Countdown>,
pause: ClockState<Countdown>, pause: ClockState<Countdown>,
@ -47,7 +44,6 @@ impl ClockMap {
} }
} }
#[derive(Debug, Clone)]
pub struct PomodoroState { pub struct PomodoroState {
mode: Mode, mode: Mode,
clock_map: ClockMap, clock_map: ClockMap,
@ -60,6 +56,7 @@ pub struct PomodoroStateArgs {
pub initial_value_pause: Duration, pub initial_value_pause: Duration,
pub current_value_pause: Duration, pub current_value_pause: Duration,
pub with_decis: bool, pub with_decis: bool,
pub with_notification: bool,
} }
impl PomodoroState { impl PomodoroState {
@ -71,6 +68,7 @@ impl PomodoroState {
initial_value_pause, initial_value_pause,
current_value_pause, current_value_pause,
with_decis, with_decis,
with_notification,
} = args; } = args;
Self { Self {
mode, mode,
@ -80,12 +78,30 @@ impl PomodoroState {
current_value: current_value_work, current_value: current_value_work,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis, with_decis,
})
.with_on_done_by_condition(with_notification, || {
debug!("on_done WORK");
let result = Notification::new()
.summary(&"Work done!".to_uppercase())
.show();
if let Err(err) = result {
error!("on_done WORK error: {err}");
}
}), }),
pause: ClockState::<Countdown>::new(ClockStateArgs { pause: ClockState::<Countdown>::new(ClockStateArgs {
initial_value: initial_value_pause, initial_value: initial_value_pause,
current_value: current_value_pause, current_value: current_value_pause,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis, with_decis,
})
.with_on_done_by_condition(with_notification, || {
debug!("on_done PAUSE");
let result = Notification::new()
.summary(&"Pause done!".to_uppercase())
.show();
if let Err(err) = result {
error!("on_done PAUSE error: {err}");
}
}), }),
}, },
} }

View File

@ -13,7 +13,6 @@ use ratatui::{
}; };
use std::cmp::max; use std::cmp::max;
#[derive(Debug, Clone)]
pub struct TimerState { pub struct TimerState {
clock: ClockState<clock::Timer>, clock: ClockState<clock::Timer>,
} }