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:
parent
97787f718d
commit
d3c436da0b
@ -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
941
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
21
README.md
21
README.md
@ -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
|
||||
|
||||
52
src/app.rs
52
src/app.rs
@ -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,
|
||||
|
||||
16
src/args.rs
16
src/args.rs
@ -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>,
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ use ratatui::{
|
||||
};
|
||||
use std::cmp::max;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimerState {
|
||||
clock: ClockState<clock::Timer>,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user