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:
parent
2277eeb033
commit
3f4acec9f5
@ -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",
|
||||
|
||||
41
README.md
41
README.md
@ -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):
|
||||
|
||||
42
src/app.rs
42
src/app.rs
@ -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);
|
||||
}
|
||||
|
||||
17
src/args.rs
17
src/args.rs
@ -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>,
|
||||
|
||||
@ -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]",
|
||||
|
||||
200
src/duration.rs
200
src/duration.rs
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user