feat(notification): Animate (blink) clock entering done mode (#65)
Optional.
This commit is contained in:
parent
886deb3311
commit
e95ecb9e9c
@ -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
|
||||
|
||||
|
||||
@ -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
BIN
demo/blink.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
23
demo/blink.tape
Normal file
23
demo/blink.tape
Normal 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
|
||||
5
justfile
5
justfile
@ -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
|
||||
|
||||
32
src/app.rs
32
src/app.rs
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
13
src/args.rs
13
src/args.rs
@ -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(
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {} {}",
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user