diff --git a/src/client.rs b/src/client.rs index bdd475b..5bf2dfd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -97,8 +97,8 @@ pub fn main(cli_cmd: cli::ClientCommand) -> io::Result<()> { // TODO: support passing multiple IDs in protocol let result: ClientResult<()> = match cli_cmd { - cli::ClientCommand::Start(StartArgs { durations }) => { - start(&mut conn, durations).inspect_err(|err| eprintln!("{err}")) + cli::ClientCommand::Start(StartArgs { durations, message }) => { + start(&mut conn, durations, message).inspect_err(|err| eprintln!("{err}")) } cli::ClientCommand::NextDue => next_due(&mut conn).inspect_err(|err| eprintln!("{err}")), cli::ClientCommand::Ls => ls(&mut conn), @@ -119,9 +119,13 @@ pub fn main(cli_cmd: cli::ClientCommand) -> io::Result<()> { // Command handler functions ///////////////////////////////////////////////////////////////////////////////////////// -fn start(conn: &mut DaemonConnection, durations: Vec) -> ClientResult<()> { +fn start( + conn: &mut DaemonConnection, + durations: Vec, + message: Option, +) -> ClientResult<()> { let dur: Duration = durations.iter().sum(); - let StartTimerResponse::Ok { id } = conn.add_timer(dur)?; + let StartTimerResponse::Ok { id } = conn.add_timer(dur, message)?; let dur_string = dur.format_colon_separated(); println!("Timer {id} created for {dur_string}."); diff --git a/src/client/daemon_connection.rs b/src/client/daemon_connection.rs index 2b9ae93..3c7a37b 100644 --- a/src/client/daemon_connection.rs +++ b/src/client/daemon_connection.rs @@ -21,8 +21,12 @@ impl DaemonConnection { Ok(Self { read, write }) } - pub fn add_timer(&mut self, duration: Duration) -> io::Result { - self.send(Command::StartTimer { duration })?; + pub fn add_timer( + &mut self, + duration: Duration, + message: Option, + ) -> io::Result { + self.send(Command::StartTimer { duration, message })?; self.recv::() } diff --git a/src/client/ui.rs b/src/client/ui.rs index 8c80cc4..6c1c09f 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs @@ -12,9 +12,11 @@ struct TableConfig<'a> { status_column_width: usize, id_column_width: usize, remaining_column_width: usize, + message_column_width: usize, gap: &'a str, } +// TODO this needs a complete rework pub fn ls(mut timers: Vec) -> impl Display { if timers.len() == 0 { return "There are currently no timers.\n".to_owned(); @@ -61,11 +63,22 @@ pub fn ls(mut timers: Vec) -> impl Display { widest_remaining_duration.max(remaining_header.len()) }; + let message_header = "Message"; + let message_column_width = { + let widest_message = timers + .iter() + .map(|ti| ti.message.as_ref().map(|s| s.len()).unwrap_or(0)) + .max() + .expect("timers.len() != 0"); + widest_message.max(message_header.len()) + }; + let gap = " "; let table_config = TableConfig { status_column_width, id_column_width, remaining_column_width, + message_column_width, gap, }; @@ -73,13 +86,15 @@ pub fn ls(mut timers: Vec) -> impl Display { // so we have to pre-pad let id_header_padded = format!("{:status_column_width$}{gap}{:>id_column_width$}{gap}{remaining:>remaining_column_width$}\n", + "{play_pause:>status_column_width$}{gap}{:>id_column_width$}{gap}{remaining:>remaining_column_width$}{gap}{message: { log::error!("Bug: do_notification called for nonexistent timer {timer_id}"); return; } - dashmap::Entry::Occupied(occupied_entry) => occupied_entry.get().initial_duration, + dashmap::Entry::Occupied(occupied_entry) => { + let timer = occupied_entry.get(); + (timer.initial_duration, timer.message.clone()) + } }; + + let summary: &str = message.as_deref().unwrap_or("Time's up!"); + let notification = Notification::new() - .summary("Time's up!") + .summary(summary) .body(&format!( "Timer {timer_id} set for {} has elapsed", initial_duration.format_colon_separated() @@ -227,8 +233,13 @@ impl DaemonCtx { }); } - pub async fn start_timer(&self, now: Instant, duration: Duration) -> TimerId { - let id = self._start_timer(now, duration); + pub async fn start_timer( + &self, + now: Instant, + duration: Duration, + message: Option, + ) -> TimerId { + let id = self._start_timer(now, duration, message); log::info!( "Started timer {} for {}", id, @@ -242,10 +253,10 @@ impl DaemonCtx { } /// Helper for start_timer() and again() - fn _start_timer(&self, now: Instant, duration: Duration) -> TimerId { + fn _start_timer(&self, now: Instant, duration: Duration, message: Option) -> TimerId { let vacant = self.timers.first_vacant_entry(); let id = *vacant.key(); - vacant.insert(Timer::new_running(now, duration)); + vacant.insert(Timer::new_running(now, duration, message)); self.refresh_next_due.notify_one(); id } @@ -356,7 +367,13 @@ impl DaemonCtx { let last_started = { *self.last_started.read().await }; match last_started { Some(duration) => { - let id = self._start_timer(now, duration); + let id = self._start_timer( + now, duration, + // TODO: we need to store all details about the most recently + // started timer, (perhaps as another field in `Timers`) so + // that `again` can re-use the same message + None, + ); log::info!( "Restarted most recent timer duration {} with new id {}", duration.format_colon_separated(), diff --git a/src/daemon/handle_client.rs b/src/daemon/handle_client.rs index 61edebc..3f94582 100644 --- a/src/daemon/handle_client.rs +++ b/src/daemon/handle_client.rs @@ -18,8 +18,8 @@ async fn handle_command(cmd: Command, ctx: &DaemonCtx) -> Response { let now = Instant::now(); match cmd { Command::List => ListResponse::ok(ctx.get_timerinfo_for_client(now)).into(), - Command::StartTimer { duration } => { - StartTimerResponse::ok(ctx.start_timer(now, duration).await).into() + Command::StartTimer { duration, message } => { + StartTimerResponse::ok(ctx.start_timer(now, duration, message).await).into() } Command::PauseTimer(id) => ctx.pause_timer(id, now).into(), Command::ResumeTimer(id) => ctx.resume_timer(id, now).into(), diff --git a/src/sand/cli.rs b/src/sand/cli.rs index 50b938a..55c1908 100644 --- a/src/sand/cli.rs +++ b/src/sand/cli.rs @@ -46,6 +46,10 @@ impl Cli { #[derive(Args, Clone)] pub struct StartArgs { + /// Message to display when the timer is done + #[arg(short, long)] + pub message: Option, + #[clap(name = "DURATION", value_parser = sand::duration::parse_duration_component, num_args = 1..)] pub durations: Vec, } diff --git a/src/sand/message.rs b/src/sand/message.rs index b27fe68..dbf01ec 100644 --- a/src/sand/message.rs +++ b/src/sand/message.rs @@ -16,7 +16,10 @@ use crate::sand::timer::{self, PausedTimer, RunningTimer, Timer, TimerId}; #[serde(rename_all = "lowercase")] pub enum Command { List, - StartTimer { duration: Duration }, + StartTimer { + duration: Duration, + message: Option, + }, PauseTimer(TimerId), ResumeTimer(TimerId), CancelTimer(TimerId), @@ -112,6 +115,7 @@ pub struct TimerInfo { pub id: TimerId, pub state: TimerState, pub remaining: Duration, + pub message: Option, } impl TimerInfo { @@ -128,6 +132,7 @@ impl TimerInfo { id, state, remaining, + message: timer.message.clone(), } } diff --git a/src/sand/timer.rs b/src/sand/timer.rs index db967a7..c45382c 100644 --- a/src/sand/timer.rs +++ b/src/sand/timer.rs @@ -39,13 +39,15 @@ pub struct RunningTimer { pub struct Timer { /// The initial duration of the timer. Should not be modified after creation. pub initial_duration: Duration, + pub message: Option, pub state: TimerState, } impl Timer { - pub fn new_running(now: Instant, initial_duration: Duration) -> Self { + pub fn new_running(now: Instant, initial_duration: Duration, message: Option) -> Self { Timer { initial_duration, + message, state: TimerState::Running(RunningTimer { due: now + initial_duration, }), diff --git a/test.py b/test.py index e20ced9..e0a1c58 100755 --- a/test.py +++ b/test.py @@ -155,6 +155,36 @@ def test_add(self, daemon): diff = DeepDiff(expected, response, ignore_order=True) assert not diff, f"Response shape mismatch:\n{pformat(diff)}" + def test_add_with_message(self, daemon): + msg_and_response( + { + "starttimer": { + "duration": {"secs": 10 * 60, "nanos": 0}, + "message": "Hello, world!", + } + } + ) + response = msg_and_response("list") + expected_shape = { + "ok": { + "timers": [ + { + "id": 1, + "message": "Hello, world!", + "state": "Running", + "remaining": None, + }, + ] + } + } + diff = DeepDiff( + expected_shape, + response, + exclude_regex_paths=IGNORE_REMAINING, + ignore_order=True, + ) + assert not diff, f"Response shape mismatch:\n{pformat(diff)}" + def test_list(self, daemon): msg_and_response({"starttimer": {"duration": {"secs": 10 * 60, "nanos": 0}}}) msg_and_response({"starttimer": {"duration": {"secs": 20 * 60, "nanos": 0}}}) @@ -164,8 +194,8 @@ def test_list(self, daemon): expected_shape = { "ok": { "timers": [ - {"id": 2, "state": "Running", "remaining": None}, - {"id": 1, "state": "Running", "remaining": None}, + {"id": 2, "message": None, "state": "Running", "remaining": None}, + {"id": 1, "message": None, "state": "Running", "remaining": None}, ] } } @@ -184,7 +214,11 @@ def test_pause_resume(self, daemon): response = msg_and_response("list") expected_shape = { - "ok": {"timers": [{"id": 1, "state": "Paused", "remaining": None}]} + "ok": { + "timers": [ + {"id": 1, "message": None, "state": "Paused", "remaining": None} + ] + } } diff = DeepDiff( expected_shape, @@ -198,7 +232,11 @@ def test_pause_resume(self, daemon): response = msg_and_response("list") expected_shape = { - "ok": {"timers": [{"id": 1, "state": "Running", "remaining": None}]} + "ok": { + "timers": [ + {"id": 1, "message": None, "state": "Running", "remaining": None} + ] + } } diff = DeepDiff( expected_shape, @@ -223,7 +261,11 @@ def test_cancel_paused(self, daemon): response = msg_and_response("list") expected_shape = { - "ok": {"timers": [{"id": 1, "state": "Paused", "remaining": None}]} + "ok": { + "timers": [ + {"id": 1, "message": None, "state": "Paused", "remaining": None} + ] + } } diff = DeepDiff( expected_shape,