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
## 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
### Features

941
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ categories = ["command-line-utilities"]
[dependencies]
ratatui = "0.29.0"
crossterm = {version = "0.28.1", features = ["event-stream", "serde"] }
crossterm = { version = "0.28.1", features = ["event-stream", "serde"] }
color-eyre = "0.6.2"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
@ -27,3 +27,4 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
directories = "5.0.1"
clap = { version = "4.5.23", features = ["derive"] }
time = { version = "0.3.37", features = ["formatting", "local-offset"] }
notify-rust = "4.11.4"

View File

@ -68,16 +68,17 @@ timr-tui --help
Usage: timr-tui [OPTIONS]
Options:
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 10:00]
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 25:00]
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 5:00]
-d, --decis Wether to show deciseconds or not. [default: false]
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro] [default: timer]
--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] [default: full]
-r, --reset Reset stored values to default.
-h, --help Print help
-V, --version Print version
-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'
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-d, --decis Show deciseconds.
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro]
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
--menu Open the menu.
-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
-V, --version Print version
```
# Installation

View File

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

View File

@ -1,5 +1,5 @@
use crate::{
common::{Content, Style},
common::{Content, Notification, Style},
duration,
};
use clap::Parser;
@ -23,7 +23,7 @@ pub struct Args {
)]
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,
#[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.")]
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,
#[arg(long, short = 'r', help = "Reset stored values to default.")]
#[arg(long, short = 'r', help = "Reset stored values to default values.")]
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,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Notification {
#[value(name = "on")]
On,
#[default]
#[value(name = "off")]
Off,
}
#[cfg(test)]
mod tests {

View File

@ -3,7 +3,10 @@ use color_eyre::eyre::{eyre, Result};
use directories::ProjectDirs;
use std::fs;
use std::path::PathBuf;
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 data_dir: PathBuf,
}

View File

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

View File

@ -65,7 +65,6 @@ pub enum Format {
HhMmSs,
}
#[derive(Debug, Clone)]
pub struct ClockState<T> {
initial_value: DurationEx,
current_value: DurationEx,
@ -73,6 +72,7 @@ pub struct ClockState<T> {
mode: Mode,
format: Format,
pub with_decis: bool,
on_done: Option<Box<dyn Fn() + 'static>>,
phantom: PhantomData<T>,
}
@ -310,6 +310,28 @@ impl<T> ClockState<T> {
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) {
self.format = self.get_format();
}
@ -355,6 +377,7 @@ impl ClockState<Countdown> {
},
format: Format::S,
with_decis,
on_done: None,
phantom: PhantomData,
};
// update format once
@ -365,14 +388,14 @@ impl ClockState<Countdown> {
pub fn tick(&mut self) {
if self.mode == Mode::Tick {
self.current_value = self.current_value.saturating_sub(self.tick_value);
self.set_done();
self.check_done();
self.update_format();
}
}
fn set_done(&mut self) {
fn check_done(&mut self) {
if self.current_value.eq(&Duration::ZERO.into()) {
self.mode = Mode::Done;
self.done();
}
}
@ -422,8 +445,9 @@ impl ClockState<Timer> {
Mode::Pause
},
format: Format::S,
phantom: PhantomData,
with_decis,
on_done: None,
phantom: PhantomData,
};
// update format once
instance.update_format();
@ -433,14 +457,14 @@ impl ClockState<Timer> {
pub fn tick(&mut self) {
if self.mode == Mode::Tick {
self.current_value = self.current_value.saturating_add(self.tick_value);
self.set_done();
self.check_done();
self.update_format();
}
}
fn set_done(&mut self) {
fn check_done(&mut self) {
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,
},
};
use crossterm::event::KeyModifiers;
use notify_rust::Notification;
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
@ -17,6 +19,7 @@ use ratatui::{
text::Line,
widgets::{StatefulWidget, Widget},
};
use tracing::{debug, error};
use std::ops::Sub;
use std::{cmp::max, time::Duration};
@ -24,8 +27,16 @@ use time::OffsetDateTime;
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
#[derive(Debug, Clone)]
pub struct CountdownState {
/// clock to count down
clock: ClockState<clock::Countdown>,
@ -37,13 +48,32 @@ pub struct CountdownState {
}
impl CountdownState {
pub fn new(
clock: ClockState<clock::Countdown>,
elapsed_value: Duration,
app_time: AppTime,
) -> Self {
pub fn new(args: CountdownStateArgs) -> Self {
let CountdownStateArgs {
initial_value,
current_value,
elapsed_value,
with_notification,
with_decis,
app_time,
} = args;
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 {
initial_value: Duration::ZERO,
current_value: elapsed_value,

View File

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

View File

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