feat(args): parse countdown by given time (past or future) (#112)

* feat(args): parse `countdown` by time

* fix lint

No `Default` for `AppTime` needed...

* app: pass `countdown_until` down

* fix `parse_duration_by_time` and `parse_duration`

to handle different formats they support

* fix(countdown): percentage panics

`Duration::ZERO` needs to be considered

* `DirectedDuration`

* fix comment

* rename arg: `countdown-target`

* `ss`->`mm`, fix formats, update README

* alias `--ct`
This commit is contained in:
Jens Krause 2025-10-01 12:40:27 +02:00 committed by GitHub
parent 2277eeb033
commit 3f4acec9f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 247 additions and 67 deletions

View File

@ -35,7 +35,7 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
directories = "5.0.1"
clap = { version = "4.5.40", features = ["derive"] }
time = { version = "0.3.41", features = ["formatting", "local-offset"] }
time = { version = "0.3.41", features = ["formatting", "local-offset", "parsing", "macros"] }
notify-rust = "4.11.7"
rodio = { version = "0.20.1", features = [
"symphonia-mp3",

View File

@ -87,19 +87,34 @@ timr-tui --help
Usage: timr-tui [OPTIONS]
Options:
-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, localtime]
-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. Experimental. [possible values: on, off]
--blink <BLINK> Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
--log [<LOG>] Directory to store log file. If not set, standard application log directory is used (check README for details).
-h, --help Print help
-V, --version Print version
-c, --countdown <COUNTDOWN>
Countdown time to start from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
--countdown-target <COUNTDOWN_TARGET>
Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm' [aliases: --ct]
-w, --work <WORK>
Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-p, --pause <PAUSE>
Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-d, --decis
Show deciseconds.
-m, --mode <MODE>
Mode to start with. [possible values: countdown, timer, pomodoro, localtime]
-s, --style <STYLE>
Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
--menu
Open menu.
-r, --reset
Reset stored values to defaults.
-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]
--log [<LOG>]
Directory for log file. If not set, standard application log directory is used (check README for details).
-h, --help
Print help
-V, --version
Print version
```
Extra option (if `--features sound` is enabled by local build only):

View File

@ -2,6 +2,7 @@ use crate::{
args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
duration::DirectedDuration,
events::{self, TuiEventHandler},
storage::AppStorage,
terminal::Terminal,
@ -28,7 +29,6 @@ use ratatui::{
};
use std::path::PathBuf;
use std::time::Duration;
use time::OffsetDateTime;
use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -103,7 +103,7 @@ impl From<FromAppArgs> for App {
None => {
if args.work.is_some() || args.pause.is_some() {
Content::Pomodoro
} else if args.countdown.is_some() {
} else if args.countdown.is_some() || args.countdown_target.is_some() {
Content::Countdown
}
// in other case just use latest stored state
@ -121,13 +121,28 @@ impl From<FromAppArgs> for App {
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
// invalidate `current_value_pause` if an initial value is set via args
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
initial_value_countdown: match (&args.countdown, &args.countdown_target) {
(Some(d), _) => *d,
(None, Some(DirectedDuration::Until(d))) => *d,
// reset for values from "past"
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
(None, None) => stg.inital_value_countdown,
},
// invalidate `current_value_countdown` if an initial value is set via args
current_value_countdown: args.countdown.unwrap_or(stg.current_value_countdown),
elapsed_value_countdown: match args.countdown {
// reset value if countdown is set by arguments
Some(_) => Duration::ZERO,
None => stg.elapsed_value_countdown,
current_value_countdown: match (&args.countdown, &args.countdown_target) {
(Some(d), _) => *d,
(None, Some(DirectedDuration::Until(d))) => *d,
// `zero` makes values from `past` marked as `DONE`
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
(None, None) => stg.inital_value_countdown,
},
elapsed_value_countdown: match (args.countdown, args.countdown_target) {
// use `Since` duration
(_, Some(DirectedDuration::Since(d))) => d,
// reset values
(_, Some(_)) => Duration::ZERO,
(Some(_), _) => Duration::ZERO,
(_, _) => stg.elapsed_value_countdown,
},
current_value_timer: stg.current_value_timer,
app_tx,
@ -140,13 +155,6 @@ impl From<FromAppArgs> for App {
}
}
fn get_app_time() -> AppTime {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
impl App {
pub fn new(args: AppArgs) -> Self {
let AppArgs {
@ -171,7 +179,7 @@ impl App {
app_tx,
footer_toggle_app_time,
} = args;
let app_time = get_app_time();
let app_time = AppTime::new();
Self {
mode: Mode::Running,
@ -292,7 +300,7 @@ impl App {
// Closure to handle `TuiEvent`'s
let mut handle_tui_events = |app: &mut Self, event: events::TuiEvent| -> Result<()> {
if matches!(event, events::TuiEvent::Tick) {
app.app_time = get_app_time();
app.app_time = AppTime::new();
app.countdown.set_app_time(app.app_time);
app.local_time.set_app_time(app.app_time);
}

View File

@ -14,17 +14,22 @@ pub const LOG_DIRECTORY_DEFAULT_MISSING_VALUE: &str = " "; // empty string
#[command(version)]
pub struct Args {
#[arg(long, short, value_parser = duration::parse_duration,
help = "Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
help = "Countdown time to start from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)]
pub countdown: Option<Duration>,
#[arg(long, visible_alias = "ct", value_parser = duration::parse_duration_by_time,
help = "Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm'"
)]
pub countdown_target: Option<duration::DirectedDuration>,
#[arg(long, short, value_parser = duration::parse_duration,
help = "Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)]
pub work: Option<Duration>,
#[arg(long, short, value_parser = duration::parse_duration,
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)]
pub pause: Option<Duration>,
@ -37,10 +42,10 @@ pub struct Args {
#[arg(long, short = 's', value_enum, help = "Style to display time with.")]
pub style: Option<Style>,
#[arg(long, value_enum, help = "Open the menu.")]
#[arg(long, value_enum, help = "Open menu.")]
pub menu: bool,
#[arg(long, short = 'r', help = "Reset stored values to default values.")]
#[arg(long, short = 'r', help = "Reset stored values to defaults.")]
pub reset: bool,
#[arg(
@ -76,7 +81,7 @@ pub struct Args {
// this value will be checked later in `main`
// to use another (default) log directory instead
default_missing_value=LOG_DIRECTORY_DEFAULT_MISSING_VALUE,
help = "Directory to store log file. If not set, standard application log directory is used (check README for details).",
help = "Directory for log file. If not set, standard application log directory is used (check README for details).",
value_hint = clap::ValueHint::DirPath,
)]
pub log: Option<PathBuf>,

View File

@ -118,6 +118,14 @@ impl From<AppTime> for OffsetDateTime {
}
impl AppTime {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
pub fn format(&self, app_format: &AppTimeFormat) -> String {
let parse_str = match app_format {
AppTimeFormat::HhMmSs => "[hour]:[minute]:[second]",

View File

@ -5,6 +5,8 @@ use color_eyre::{
use std::fmt;
use std::time::Duration;
use crate::common::AppTime;
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
pub const ONE_SECOND: Duration = Duration::from_secs(1);
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
@ -34,6 +36,15 @@ pub const MAX_DURATION: Duration = ONE_YEAR
.saturating_mul(1000)
.saturating_sub(ONE_DECI_SECOND);
/// `Duration` with direction in time (past or future)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectedDuration {
/// Time `until` a future moment (positive `Duration`)
Until(Duration),
/// Time `since` a past moment (negative duration, but still represented as positive `Duration`)
Since(Duration),
}
#[derive(Debug, Clone, Copy, PartialOrd)]
pub struct DurationEx {
inner: Duration,
@ -177,45 +188,136 @@ impl fmt::Display for DurationEx {
}
}
/// Parse seconds (must be < 60)
fn parse_seconds(s: &str) -> Result<u8, Report> {
let secs = s.parse::<u8>().map_err(|_| eyre!("Invalid seconds"))?;
ensure!(secs < 60, "Seconds must be less than 60.");
Ok(secs)
}
/// Parse minutes (must be < 60)
fn parse_minutes(m: &str) -> Result<u8, Report> {
let mins = m.parse::<u8>().map_err(|_| eyre!("Invalid minutes"))?;
ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins)
}
/// Parse hours
fn parse_hours(h: &str) -> Result<u8, Report> {
let hours = h.parse::<u8>().map_err(|_| eyre!("Invalid hours"))?;
Ok(hours)
}
/// Parses `DirectedDuration` from following formats:
/// - `yyyy-mm-dd hh:mm:ss`
/// - `yyyy-mm-dd hh:mm`
/// - `hh:mm:ss`
/// - `hh:mm`
/// - `mm`
///
/// Returns `DirectedDuration::Until` for future times, `DirectedDuration::Since` for past times
pub fn parse_duration_by_time(arg: &str) -> Result<DirectedDuration, Report> {
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
let now: OffsetDateTime = AppTime::new().into();
let target_time = if arg.contains('-') {
// First: `YYYY-MM-DD HH:MM:SS`
// Then: `YYYY-MM-DD HH:MM`
let format_with_seconds =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
let format_without_seconds = format_description!("[year]-[month]-[day] [hour]:[minute]");
let pdt = PrimitiveDateTime::parse(arg, format_with_seconds)
.or_else(|_| PrimitiveDateTime::parse(arg, format_without_seconds))
.map_err(|e| {
eyre!("Invalid datetime '{}'. Use format 'yyyy-mm-dd hh:mm:ss' or 'yyyy-mm-dd hh:mm'. Error: {}", arg, e)
})?;
pdt.assume_offset(now.offset())
} else {
// Parse time parts: interpret as HH:MM:SS, HH:MM, or SS
let parts: Vec<&str> = arg.split(':').collect();
let (hour, minute, second) = match parts.as_slice() {
[mm] => {
// Single part: treat as minutes in current hour
let m = parse_minutes(mm)?;
(now.hour(), m, 0)
}
[hh, mm] => {
// Two parts: treat as HH:MM (time of day)
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
(h, m, 0)
}
[hh, mm, ss] => {
// Three parts: HH:MM:SS
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(h, m, s)
}
_ => {
return Err(eyre!(
"Invalid time format. Use 'hh:mm:ss', 'hh:mm', or 'mm'"
));
}
};
now.replace_time(
time::Time::from_hms(hour, minute, second).map_err(|_| eyre!("Invalid time"))?,
)
};
let mut duration_secs = (target_time - now).whole_seconds();
// `Since` for past times
if duration_secs < 0 {
duration_secs *= -1;
Ok(DirectedDuration::Since(Duration::from_secs(
duration_secs as u64,
)))
} else
// `Until` for future times,
{
Ok(DirectedDuration::Until(Duration::from_secs(
duration_secs as u64,
)))
}
}
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
let parts: Vec<&str> = arg.split(':').rev().collect();
let parts: Vec<&str> = arg.split(':').collect();
let parse_seconds = |s: &str| -> Result<u64, Report> {
let secs = s.parse::<u64>().map_err(|_| eyre!("Invalid seconds"))?;
ensure!(secs < 60, "Seconds must be less than 60.");
Ok(secs)
};
let parse_minutes = |m: &str| -> Result<u64, Report> {
let mins = m.parse::<u64>().map_err(|_| eyre!("Invalid minutes"))?;
ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins)
};
let parse_hours = |h: &str| -> Result<u64, Report> {
let hours = h.parse::<u64>().map_err(|_| eyre!("Invalid hours"))?;
ensure!(hours < 100, "Hours must be less than 100.");
Ok(hours)
};
let seconds = match parts.as_slice() {
[ss] => parse_seconds(ss)?,
[ss, mm] => {
let (hours, minutes, seconds) = match parts.as_slice() {
[ss] => {
// Single part: seconds only
let s = parse_seconds(ss)?;
let m = parse_minutes(mm)?;
m * 60 + s
(0u64, 0u64, s as u64)
}
[ss, mm, hh] => {
let s = parse_seconds(ss)?;
[mm, ss] => {
// Two parts: MM:SS
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(0u64, m as u64, s as u64)
}
[hh, mm, ss] => {
// Three parts: HH:MM:SS
let h = parse_hours(hh)?;
h * 60 * 60 + m * 60 + s
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(h as u64, m as u64, s as u64)
}
_ => {
return Err(eyre!(
"Invalid time format. Use 'ss', 'mm:ss', or 'hh:mm:ss'"
));
}
_ => return Err(eyre!("Invalid time format. Use 'ss', mm:ss, or hh:mm:ss")),
};
Ok(Duration::from_secs(seconds))
let total_seconds = hours * 3600 + minutes * 60 + seconds;
Ok(Duration::from_secs(total_seconds))
}
#[cfg(test)]
@ -330,8 +432,46 @@ mod tests {
// errors
assert!(parse_duration("1:60").is_err()); // invalid seconds
assert!(parse_duration("60:00").is_err()); // invalid minutes
assert!(parse_duration("100:00:00").is_err()); // invalid hours
assert!(parse_duration("abc").is_err()); // invalid input
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
}
#[test]
fn test_parse_duration_by_time() {
// YYYY-MM-DD HH:MM:SS - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30:45"),
Ok(DirectedDuration::Until(_))
));
// YYYY-MM-DD HH:MM - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30"),
Ok(DirectedDuration::Until(_))
));
// HH:MM:SS - past
assert!(matches!(
parse_duration_by_time("2000-01-01 23:59:59"),
Ok(DirectedDuration::Since(_))
));
// HH:MM - Until or Since depending on current time
assert!(parse_duration_by_time("18:00").is_ok());
// MM - time in current hour returns Until
assert!(matches!(
parse_duration_by_time("45"),
Ok(DirectedDuration::Until(_))
));
// errors
assert!(parse_duration_by_time("60").is_err()); // invalid seconds
assert!(parse_duration_by_time("24:00").is_err()); // invalid hours
assert!(parse_duration_by_time("24:00:00").is_err()); // invalid hours
assert!(parse_duration_by_time("2030-13-01 12:00:00").is_err()); // invalid month
assert!(parse_duration_by_time("2030-06-32 12:00:00").is_err()); // invalid day
assert!(parse_duration_by_time("abc").is_err()); // invalid input
assert!(parse_duration_by_time("01:02:03:04").is_err()); // too many parts
}
}

View File

@ -511,6 +511,10 @@ impl ClockState<Countdown> {
}
pub fn get_percentage_done(&self) -> u16 {
if Duration::is_zero(&self.initial_value.into()) {
return 0;
}
let elapsed = self.initial_value.saturating_sub(self.current_value);
(elapsed.millis() * 100 / self.initial_value.millis()) as u16