feat(notification): Animate (blink) clock entering done mode (#65)

Optional.
This commit is contained in:
Jens Krause 2025-02-05 19:29:56 +01:00 committed by GitHub
parent 886deb3311
commit e95ecb9e9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 130 additions and 24 deletions

View File

@ -4,8 +4,9 @@
### Features
- (notification) Native desktop notification (experimental) [#49](https://github.com/sectore/timr-tui/pull/59)
- (notification) Sound notification (experimental) [#62](https://github.com/sectore/timr-tui/pull/62)
- (notification) Blink clock when it reaches its `done` mode. (optional) [#65](https://github.com/sectore/timr-tui/pull/65)
- (notification) Native desktop notification (optional, experimental) [#49](https://github.com/sectore/timr-tui/pull/59)
- (notification) Sound notification (optional, experimental, available in local build only) [#62](https://github.com/sectore/timr-tui/pull/62)
## v1.1.0 - 2025-01-22

View File

@ -76,7 +76,8 @@ Options:
-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]
-n, --notification <NOTIFICATION> Toggle desktop notifications. Experimental. [possible values: on, off]
--blink <BLINK> Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
-h, --help Print help
-V, --version Print version
```

BIN
demo/blink.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

23
demo/blink.tape Normal file
View File

@ -0,0 +1,23 @@
Output demo/blink.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "nord-light"
Set FontSize 14
Set Width 800
Set Height 400
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
# countdown 1.0s
Type "cargo run -- -r -m countdown -d -c 1 --blink=on"
Enter
Sleep 0.2
Type "m"
Type ":::"
Show
Type "s"
Sleep 4

View File

@ -78,3 +78,8 @@ alias drc := demo-rocket-countdown
demo-rocket-countdown:
vhs demo/met.tape
alias db := demo-blink
demo-blink:
vhs demo/blink.tape

View File

@ -1,6 +1,6 @@
use crate::{
args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Notification, Style},
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
events::{self, TuiEventHandler},
storage::AppStorage,
@ -39,7 +39,8 @@ enum Mode {
pub struct App {
content: Content,
mode: Mode,
notification: Notification,
notification: Toggle,
blink: Toggle,
#[allow(dead_code)] // w/ `--features sound` available only
sound_path: Option<PathBuf>,
app_time: AppTime,
@ -54,7 +55,8 @@ pub struct App {
pub struct AppArgs {
pub style: Style,
pub with_decis: bool,
pub notification: Notification,
pub notification: Toggle,
pub blink: Toggle,
pub show_menu: bool,
pub app_time_format: AppTimeFormat,
pub content: Content,
@ -87,6 +89,7 @@ impl From<FromAppArgs> for App {
with_decis: args.decis || stg.with_decis,
show_menu: args.menu || stg.show_menu,
notification: args.notification.unwrap_or(stg.notification),
blink: args.blink.unwrap_or(stg.blink),
app_time_format: stg.app_time_format,
content: args.mode.unwrap_or(stg.content),
style: args.style.unwrap_or(stg.style),
@ -136,6 +139,7 @@ impl App {
with_decis,
pomodoro_mode,
notification,
blink,
sound_path,
app_tx,
} = args;
@ -144,6 +148,7 @@ impl App {
Self {
mode: Mode::Running,
notification,
blink,
sound_path,
content,
app_time,
@ -243,7 +248,7 @@ impl App {
events::AppEvent::ClockDone(type_id, name) => {
debug!("AppEvent::ClockDone");
if app.notification == Notification::On {
if app.notification == Toggle::On {
let msg = match type_id {
ClockTypeId::Timer => {
format!("{name} stopped by reaching its maximum value.")
@ -345,6 +350,7 @@ impl App {
content: self.content,
show_menu: self.footer.get_show_menu(),
notification: self.notification,
blink: self.blink,
app_time_format: *self.footer.app_time_format(),
style: self.style,
with_decis: self.with_decis,
@ -373,14 +379,22 @@ impl AppWidget {
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) {
match state.content {
Content::Timer => {
Timer { style: state.style }.render(area, buf, &mut state.timer);
Timer {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.timer);
}
Content::Countdown => {
Countdown { style: state.style }.render(area, buf, &mut state.countdown)
Content::Countdown => Countdown {
style: state.style,
blink: state.blink == Toggle::On,
}
Content::Pomodoro => {
PomodoroWidget { style: state.style }.render(area, buf, &mut state.pomodoro)
.render(area, buf, &mut state.countdown),
Content::Pomodoro => PomodoroWidget {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.pomodoro),
};
}
}

View File

@ -1,5 +1,5 @@
use crate::{
common::{Content, Notification, Style},
common::{Content, Style, Toggle},
duration,
};
#[cfg(feature = "sound")]
@ -46,9 +46,16 @@ pub struct Args {
long,
short,
value_enum,
help = "Toggle desktop notifications on or off. Experimental."
help = "Toggle desktop notifications. Experimental."
)]
pub notification: Option<Notification>,
pub notification: Option<Toggle>,
#[arg(
long,
value_enum,
help = "Toggle blink mode to animate a clock when it reaches its finished mode."
)]
pub blink: Option<Toggle>,
#[cfg(feature = "sound")]
#[arg(

View File

@ -142,7 +142,7 @@ pub enum AppEditMode {
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Notification {
pub enum Toggle {
#[value(name = "on")]
On,
#[default]

View File

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

View File

@ -66,6 +66,9 @@ pub enum Format {
HhMmSs,
}
const RANGE_OF_DONE_COUNT: u64 = 4;
const MAX_DONE_COUNT: u64 = RANGE_OF_DONE_COUNT * 5;
pub struct ClockState<T> {
type_id: ClockTypeId,
name: Option<String>,
@ -76,6 +79,11 @@ pub struct ClockState<T> {
format: Format,
pub with_decis: bool,
app_tx: Option<AppEventTx>,
/// Tick counter starting whenever `Mode::DONE` has been reached.
/// Initial value is set in `done()`.
/// Updates happened in `update_done_count`
/// Default value: `None`
done_count: Option<u64>,
phantom: PhantomData<T>,
}
@ -335,6 +343,7 @@ impl<T> ClockState<T> {
if let Some(tx) = &self.app_tx {
_ = tx.send(AppEvent::ClockDone(type_id, name));
};
self.done_count = Some(MAX_DONE_COUNT);
}
}
@ -357,6 +366,23 @@ impl<T> ClockState<T> {
Format::S
}
}
/// Updates inner value of `done_count`.
/// It should be called whenever `TuiEvent::Tick` is handled.
/// At first glance it might happen in `Clock::tick`, but sometimes
/// `tick` won't be called again after `Mode::Done` event (e.g. in `widget::Countdown`).
/// That's why `update_done_count` is called from "outside".
pub fn update_done_count(&mut self) {
if let Some(count) = self.done_count {
if count > 0 {
let value = count - 1;
self.done_count = Some(value)
} else {
// None means we are done and no counting anymore.
self.done_count = None
}
}
}
}
#[derive(Debug, Clone)]
@ -387,6 +413,7 @@ impl ClockState<Countdown> {
format: Format::S,
with_decis,
app_tx,
done_count: None,
phantom: PhantomData,
};
// update format once
@ -459,6 +486,7 @@ impl ClockState<Timer> {
format: Format::S,
with_decis,
app_tx,
done_count: None,
phantom: PhantomData,
};
// update format once
@ -502,6 +530,7 @@ where
T: std::fmt::Debug,
{
style: Style,
blink: bool,
phantom: PhantomData<T>,
}
@ -509,9 +538,10 @@ impl<T> ClockWidget<T>
where
T: std::fmt::Debug,
{
pub fn new(style: Style) -> Self {
pub fn new(style: Style, blink: bool) -> Self {
Self {
style,
blink,
phantom: PhantomData,
}
}
@ -604,6 +634,17 @@ where
pub fn get_height(&self) -> u16 {
DIGIT_HEIGHT
}
/// Checks whether to blink the clock while rendering.
/// Its logic is based on a given `count` value.
fn should_blink(&self, count_value: &Option<u64>) -> bool {
// Example:
// if `RANGE_OF_DONE_COUNT` is 4
// then for ranges `0..4`, `8..12` etc. it will return `true`
count_value
.map(|b| (b % (RANGE_OF_DONE_COUNT * 2)) < RANGE_OF_DONE_COUNT)
.unwrap_or(false)
}
}
impl<T> StatefulWidget for ClockWidget<T>
@ -615,7 +656,13 @@ where
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let with_decis = state.with_decis;
let format = state.format;
let symbol = self.style.get_digit_symbol();
// to simulate a blink effect, just use an "empty" symbol (string)
// to "empty" all digits and to have an "empty" render area
let symbol = if self.blink && self.should_blink(&state.done_count) {
" "
} else {
self.style.get_digit_symbol()
};
let widths = self.get_horizontal_lengths(&format, with_decis);
let area = center_horizontal(
area,

View File

@ -151,6 +151,7 @@ impl TuiEventHandler for CountdownState {
if !self.clock.is_done() {
self.clock.tick();
} else {
self.clock.update_done_count();
self.elapsed_clock.tick();
if self.elapsed_clock.is_initial() {
self.elapsed_clock.run();
@ -278,6 +279,7 @@ impl TuiEventHandler for CountdownState {
pub struct Countdown {
pub style: Style,
pub blink: bool,
}
fn human_days_diff(a: &OffsetDateTime, b: &OffsetDateTime) -> String {
@ -337,7 +339,7 @@ impl StatefulWidget for Countdown {
}
.to_uppercase(),
);
let widget = ClockWidget::new(self.style);
let widget = ClockWidget::new(self.style, self.blink);
let area = center(
area,
Constraint::Length(max(

View File

@ -130,6 +130,7 @@ impl TuiEventHandler for PomodoroState {
match event {
TuiEvent::Tick => {
self.get_clock_mut().tick();
self.get_clock_mut().update_done_count();
}
TuiEvent::Key(key) => match key.code {
KeyCode::Char('s') => {
@ -170,12 +171,13 @@ impl TuiEventHandler for PomodoroState {
pub struct PomodoroWidget {
pub style: Style,
pub blink: bool,
}
impl StatefulWidget for PomodoroWidget {
type State = PomodoroState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock_widget = ClockWidget::new(self.style);
let clock_widget = ClockWidget::new(self.style, self.blink);
let label = Line::raw(
(format!(
"Pomodoro {} {}",

View File

@ -37,6 +37,7 @@ impl TuiEventHandler for TimerState {
match event {
TuiEvent::Tick => {
self.clock.tick();
self.clock.update_done_count();
}
TuiEvent::Key(key) => match key.code {
KeyCode::Char('s') => {
@ -70,13 +71,14 @@ impl TuiEventHandler for TimerState {
pub struct Timer {
pub style: Style,
pub blink: bool,
}
impl StatefulWidget for Timer {
type State = TimerState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock = &mut state.clock;
let clock_widget = ClockWidget::new(self.style);
let clock_widget = ClockWidget::new(self.style, self.blink);
let label = Line::raw((format!("Timer {}", clock.get_mode())).to_uppercase());
let area = center(