From 8b8f62029367ed97da83e2bbb261725594768a66 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:47:29 +0200 Subject: [PATCH 1/9] add `--max-rounds` arg --- src/args.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/args.rs b/src/args.rs index b66a602..ba03cd7 100644 --- a/src/args.rs +++ b/src/args.rs @@ -30,6 +30,9 @@ pub struct Args { )] pub pause: Option, + #[arg(long, help = "Maximum number of pomodoro rounds. 0 = unlimited.")] + pub max_rounds: Option, + #[arg(long, help = "Enable auto-switch between `work` and `pause` screens.")] pub auto_switch: bool, From 78fe22bf6659de19bdb95022c4b66e6626d66e4e Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:50:00 +0200 Subject: [PATCH 2/9] add `pomodoro_max_rounds` to storage Co-Authored-By: Claude Sonnet 4.6 --- src/storage.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/storage.rs b/src/storage.rs index c851bf4..f15a16f 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -46,6 +46,8 @@ pub struct AppStorage { pub pomodoro_mode: PomodoroMode, pub pomodoro_count: u64, pub pomodoro_auto_switch: bool, + #[serde(default)] + pub pomodoro_max_rounds: Option, // pomodoro -> work pub inital_value_work: Duration, pub current_value_work: Duration, @@ -79,6 +81,7 @@ impl Default for AppStorage { pomodoro_mode: PomodoroMode::Work, pomodoro_count: 1, pomodoro_auto_switch: false, + pomodoro_max_rounds: None, // pomodoro -> work inital_value_work: DEFAULT_WORK, current_value_work: DEFAULT_WORK, From 7677d46f6f2d5e2f859163cb722ab2cb41a0a9d1 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:36:16 +0200 Subject: [PATCH 3/9] wire `max_rounds` into pomodoro state and app Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 9 ++++ src/widgets/pomodoro.rs | 92 ++++++++++++++++++++++++++++++----------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/app.rs b/src/app.rs index 760239a..0eace25 100644 --- a/src/app.rs +++ b/src/app.rs @@ -77,6 +77,7 @@ pub struct AppArgs { pub pomodoro_mode: PomodoroMode, pub pomodoro_round: u64, pub pomodoro_auto_switch: bool, + pub pomodoro_max_rounds: Option, pub initial_value_work: Duration, pub current_value_work: Duration, pub pause_duration: PauseDuration, @@ -141,6 +142,11 @@ impl From for App { pomodoro_mode: stg.pomodoro_mode, pomodoro_round: stg.pomodoro_count, pomodoro_auto_switch: args.auto_switch || stg.pomodoro_auto_switch, + pomodoro_max_rounds: args + .max_rounds + // 0 -> resets `max_rounds` + .and_then(|n| (n > 0).then_some(n)) + .or(stg.pomodoro_max_rounds), initial_value_work: args.work.unwrap_or(stg.inital_value_work), // invalidate `current_value_work` if an initial value is set via args current_value_work: args.work.unwrap_or(stg.current_value_work), @@ -184,6 +190,7 @@ impl App { pomodoro_mode, pomodoro_round, pomodoro_auto_switch, + pomodoro_max_rounds, event, notification, blink, @@ -246,6 +253,7 @@ impl App { app_tx: app_tx.clone(), vim_motions, auto_switch: pomodoro_auto_switch, + max_rounds: pomodoro_max_rounds, }), local_time: LocalTimeState::new(LocalTimeStateArgs { app_time, @@ -533,6 +541,7 @@ impl App { pomodoro_mode: self.pomodoro.get_mode().clone(), pomodoro_count: self.pomodoro.get_round(), pomodoro_auto_switch: self.pomodoro.get_auto_switch(), + pomodoro_max_rounds: self.pomodoro.get_max_rounds(), inital_value_work: Duration::from(*self.pomodoro.get_clock_work().get_initial_value()), current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()), pause_duration: self.pomodoro.get_pause_duration().clone(), diff --git a/src/widgets/pomodoro.rs b/src/widgets/pomodoro.rs index 06349fb..e1aa912 100644 --- a/src/widgets/pomodoro.rs +++ b/src/widgets/pomodoro.rs @@ -15,19 +15,31 @@ use serde::{Deserialize, Serialize}; use std::{cmp::max, time::Duration}; use strum::Display; -fn work_clock_name(round: u64) -> String { - format!("work (round {round})") +fn check_is_last_round(round: u64, max_rounds: Option) -> bool { + max_rounds.is_some_and(|m| round >= m) } -fn pause_clock_name(round: u64, pause_duration: &PauseDuration) -> String { - format!( - "{} (round {round})", - if pause_duration.is_special_round(round) { - "pause special" - } else { - "pause" - } - ) +fn work_clock_name(round: u64, is_last_round: bool) -> String { + if is_last_round { + "work (last round)".to_owned() + } else { + format!("work (round {round})") + } +} + +fn pause_clock_name(round: u64, pause_duration: &PauseDuration, is_last_round: bool) -> String { + if is_last_round { + "pause (last round)".to_owned() + } else { + format!( + "{} (round {round})", + if pause_duration.is_special_round(round) { + "pause special" + } else { + "pause" + } + ) + } } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] @@ -97,6 +109,7 @@ pub struct PomodoroState { pause_duration: PauseDuration, vim_motions: bool, auto_switch: bool, + max_rounds: Option, } pub struct PomodoroStateArgs { @@ -110,6 +123,7 @@ pub struct PomodoroStateArgs { pub round: u64, pub vim_motions: bool, pub auto_switch: bool, + pub max_rounds: Option, } impl PomodoroState { @@ -125,7 +139,9 @@ impl PomodoroState { round, vim_motions, auto_switch, + max_rounds, } = args; + let is_last_round = check_is_last_round(round, max_rounds); Self { mode, clock_map: ClockMap { @@ -136,7 +152,7 @@ impl PomodoroState { with_decis, app_tx: Some(app_tx.clone()), }) - .with_name(work_clock_name(round)), + .with_name(work_clock_name(round, is_last_round)), pause: ClockState::::new(ClockStateArgs { initial_value: pause_duration.for_round(round), current_value: current_value_pause, @@ -144,12 +160,17 @@ impl PomodoroState { with_decis, app_tx: Some(app_tx), }) - .with_name(pause_clock_name(round, &pause_duration)), + .with_name(pause_clock_name( + round, + &pause_duration, + is_last_round, + )), }, round, pause_duration, vim_motions, auto_switch, + max_rounds, } } @@ -193,10 +214,23 @@ impl PomodoroState { self.auto_switch } + pub fn get_max_rounds(&self) -> Option { + self.max_rounds + } + + fn is_last_round(&self) -> bool { + check_is_last_round(self.round, self.max_rounds) + } + + pub fn is_complete(&self) -> bool { + self.is_last_round() && self.get_clock_work().is_done() + } + fn update_clock_names(&mut self) { let round = self.round; - let work_name = work_clock_name(round); - let pause_name = pause_clock_name(round, &self.pause_duration); + let is_last_round = self.is_last_round(); + let work_name = work_clock_name(round, is_last_round); + let pause_name = pause_clock_name(round, &self.pause_duration, is_last_round); self.get_clock_work_mut().set_name(work_name); self.get_clock_pause_mut().set_name(pause_name); } @@ -212,12 +246,14 @@ impl PomodoroState { } fn next_round(&mut self) { - // increase round before (!!) updating the clock - self.round += 1; - self.update_clock_names(); - self.update_pause_initial(); - self.get_clock_pause_mut().reset(); - self.get_clock_work_mut().reset(); + if !self.is_last_round() { + // increase round before (!!) updating the clock + self.round += 1; + self.update_clock_names(); + self.update_pause_initial(); + self.get_clock_pause_mut().reset(); + self.get_clock_work_mut().reset(); + } } fn prev_round(&mut self) { @@ -251,8 +287,10 @@ impl PomodoroState { // Switch `Mode` automatically fn switch_mode_auto(&mut self) { - self.switch_mode(); - self.get_clock_mut().run(); + if !self.is_last_round() { + self.switch_mode(); + self.get_clock_mut().run(); + } } } @@ -421,7 +459,13 @@ impl StatefulWidget for PomodoroWidget { )) .to_uppercase(), ); - let label_round = Line::raw((format!("round {}", state.get_round(),)).to_uppercase()); + let label_round = Line::raw(if state.is_last_round() { + "LAST ROUND".to_owned() + } else if let Some(max) = state.get_max_rounds() { + format!("ROUND {} OF {}", state.get_round(), max) + } else { + format!("ROUND {}", state.get_round()) + }); let area = area.centered( Constraint::Length(max( From 337b0ff13becfe91af50602ce39da2cef2df7ba7 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:41:37 +0200 Subject: [PATCH 4/9] refactor pomodoro clock naming into impl methods Co-Authored-By: Claude Sonnet 4.6 --- src/widgets/pomodoro.rs | 80 ++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/src/widgets/pomodoro.rs b/src/widgets/pomodoro.rs index e1aa912..f25ed9b 100644 --- a/src/widgets/pomodoro.rs +++ b/src/widgets/pomodoro.rs @@ -15,33 +15,6 @@ use serde::{Deserialize, Serialize}; use std::{cmp::max, time::Duration}; use strum::Display; -fn check_is_last_round(round: u64, max_rounds: Option) -> bool { - max_rounds.is_some_and(|m| round >= m) -} - -fn work_clock_name(round: u64, is_last_round: bool) -> String { - if is_last_round { - "work (last round)".to_owned() - } else { - format!("work (round {round})") - } -} - -fn pause_clock_name(round: u64, pause_duration: &PauseDuration, is_last_round: bool) -> String { - if is_last_round { - "pause (last round)".to_owned() - } else { - format!( - "{} (round {round})", - if pause_duration.is_special_round(round) { - "pause special" - } else { - "pause" - } - ) - } -} - #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub enum PauseDuration { Fixed(Duration), @@ -141,8 +114,7 @@ impl PomodoroState { auto_switch, max_rounds, } = args; - let is_last_round = check_is_last_round(round, max_rounds); - Self { + let mut state = Self { mode, clock_map: ClockMap { work: ClockState::::new(ClockStateArgs { @@ -151,27 +123,23 @@ impl PomodoroState { tick_value: Duration::from_millis(TICK_VALUE_MS), with_decis, app_tx: Some(app_tx.clone()), - }) - .with_name(work_clock_name(round, is_last_round)), + }), pause: ClockState::::new(ClockStateArgs { initial_value: pause_duration.for_round(round), current_value: current_value_pause, tick_value: Duration::from_millis(TICK_VALUE_MS), with_decis, app_tx: Some(app_tx), - }) - .with_name(pause_clock_name( - round, - &pause_duration, - is_last_round, - )), + }), }, round, pause_duration, vim_motions, auto_switch, max_rounds, - } + }; + state.update_clock_names(); + state } fn get_clock_mut(&mut self) -> &mut ClockState { @@ -219,20 +187,42 @@ impl PomodoroState { } fn is_last_round(&self) -> bool { - check_is_last_round(self.round, self.max_rounds) + self.max_rounds.is_some_and(|m| self.round >= m) } pub fn is_complete(&self) -> bool { self.is_last_round() && self.get_clock_work().is_done() } + fn update_work_name(&mut self) { + let name = if self.is_last_round() { + "work (last round)".to_owned() + } else { + format!("work (round {})", self.round) + }; + self.get_clock_work_mut().set_name(name); + } + + fn update_pause_name(&mut self) { + let name = if self.is_last_round() { + "pause (last round)".to_owned() + } else { + format!( + "{} (round {})", + if self.pause_duration.is_special_round(self.round) { + "pause special" + } else { + "pause" + }, + self.round + ) + }; + self.get_clock_pause_mut().set_name(name); + } + fn update_clock_names(&mut self) { - let round = self.round; - let is_last_round = self.is_last_round(); - let work_name = work_clock_name(round, is_last_round); - let pause_name = pause_clock_name(round, &self.pause_duration, is_last_round); - self.get_clock_work_mut().set_name(work_name); - self.get_clock_pause_mut().set_name(pause_name); + self.update_work_name(); + self.update_pause_name(); } fn update_pause_initial(&mut self) { From d882bdf169124cd1cf9b39b06543cfea0e452615 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:43:02 +0200 Subject: [PATCH 5/9] fix missing `max_rounds` in pomodoro test args Co-Authored-By: Claude Sonnet 4.6 --- src/widgets/pomodoro_test.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widgets/pomodoro_test.rs b/src/widgets/pomodoro_test.rs index 38578c3..abb085a 100644 --- a/src/widgets/pomodoro_test.rs +++ b/src/widgets/pomodoro_test.rs @@ -37,6 +37,7 @@ fn args() -> PomodoroStateArgs { round: 1, vim_motions: false, auto_switch: false, + max_rounds: None, } } From 9c89b3b7d39521144494a364fed2061aed94b909 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:57:37 +0200 Subject: [PATCH 6/9] add snapshot tests for max rounds display states Co-Authored-By: Claude Sonnet 4.6 --- src/widgets/pomodoro_test.rs | 34 +++++++++++++++++++ ..._pomodoro_test__max_rounds_last_round.snap | 20 +++++++++++ ...ts__pomodoro_test__max_rounds_round_1.snap | 20 +++++++++++ ...ts__pomodoro_test__max_rounds_round_2.snap | 20 +++++++++++ 4 files changed, 94 insertions(+) create mode 100644 src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_last_round.snap create mode 100644 src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_1.snap create mode 100644 src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_2.snap diff --git a/src/widgets/pomodoro_test.rs b/src/widgets/pomodoro_test.rs index abb085a..87cc28a 100644 --- a/src/widgets/pomodoro_test.rs +++ b/src/widgets/pomodoro_test.rs @@ -58,6 +58,40 @@ fn terminal(w: PomodoroWidget, st: PomodoroState) -> Terminal { }) } +// max rounds + +#[test] +fn test_max_rounds_round_1() { + let st = st_with_args(PomodoroStateArgs { + max_rounds: Some(3), + ..args() + }); + let t = terminal(w(), st); + assert_snapshot!("max_rounds_round_1", t.backend()); +} + +#[test] +fn test_max_rounds_round_2() { + let st = st_with_args(PomodoroStateArgs { + round: 2, + max_rounds: Some(3), + ..args() + }); + let t = terminal(w(), st); + assert_snapshot!("max_rounds_round_2", t.backend()); +} + +#[test] +fn test_max_rounds_last_round() { + let st = st_with_args(PomodoroStateArgs { + round: 3, + max_rounds: Some(3), + ..args() + }); + let t = terminal(w(), st); + assert_snapshot!("max_rounds_last_round", t.backend()); +} + // work #[test] diff --git a/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_last_round.snap b/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_last_round.snap new file mode 100644 index 0000000..47c102e --- /dev/null +++ b/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_last_round.snap @@ -0,0 +1,20 @@ +--- +source: src/widgets/pomodoro_test.rs +expression: t.backend() +--- +" " +" " +" " +" " +" " +" █████ █████ █████ █████ " +" ██ ██ ██ ██ ██ ██ ██ " +" █████ █████ ██ ██ ██ ██ " +" ██ ██ ██ ██ ██ ██ ██ " +" █████ █████ █████ █████ " +" " +" POMODORO WORK [] " +" LAST ROUND " +" " +" " +" " diff --git a/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_1.snap b/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_1.snap new file mode 100644 index 0000000..992d450 --- /dev/null +++ b/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_1.snap @@ -0,0 +1,20 @@ +--- +source: src/widgets/pomodoro_test.rs +expression: t.backend() +--- +" " +" " +" " +" " +" " +" █████ █████ █████ █████ " +" ██ ██ ██ ██ ██ ██ ██ " +" █████ █████ ██ ██ ██ ██ " +" ██ ██ ██ ██ ██ ██ ██ " +" █████ █████ █████ █████ " +" " +" POMODORO WORK [] " +" ROUND 1 OF 3 " +" " +" " +" " diff --git a/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_2.snap b/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_2.snap new file mode 100644 index 0000000..8fb49ee --- /dev/null +++ b/src/widgets/snapshots/timr_tui__widgets__pomodoro_test__max_rounds_round_2.snap @@ -0,0 +1,20 @@ +--- +source: src/widgets/pomodoro_test.rs +expression: t.backend() +--- +" " +" " +" " +" " +" " +" █████ █████ █████ █████ " +" ██ ██ ██ ██ ██ ██ ██ " +" █████ █████ ██ ██ ██ ██ " +" ██ ██ ██ ██ ██ ██ ██ " +" █████ █████ █████ █████ " +" " +" POMODORO WORK [] " +" ROUND 2 OF 3 " +" " +" " +" " From 3d82332280c93bd96af93ec55510ef8a93b9c150 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:07:10 +0200 Subject: [PATCH 7/9] update CHANGELOG for `--max-rounds` Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0eb422..4fe4b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- (pomodoro) `--max-rounds` arg to limit sessions [#205](https://github.com/sectore/timr-tui/pull/205) - (notification) detailed pomodoro status in system notification [#204](https://github.com/sectore/timr-tui/pull/204) - (cli) Add `--auto-switch` argument [#203](https://github.com/sectore/timr-tui/pull/203) From 269b13eef2f5e2cc3a3de8410d313d7055425e42 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:19:45 +0200 Subject: [PATCH 8/9] include max rounds in system notification Co-Authored-By: Claude Sonnet 4.6 --- src/widgets/pomodoro.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/widgets/pomodoro.rs b/src/widgets/pomodoro.rs index f25ed9b..da8b3ac 100644 --- a/src/widgets/pomodoro.rs +++ b/src/widgets/pomodoro.rs @@ -194,11 +194,18 @@ impl PomodoroState { self.is_last_round() && self.get_clock_work().is_done() } + fn round_label(&self) -> String { + match self.max_rounds { + Some(max) => format!("round {} of {}", self.round, max), + None => format!("round {}", self.round), + } + } + fn update_work_name(&mut self) { let name = if self.is_last_round() { "work (last round)".to_owned() } else { - format!("work (round {})", self.round) + format!("work ({})", self.round_label()) }; self.get_clock_work_mut().set_name(name); } @@ -208,13 +215,13 @@ impl PomodoroState { "pause (last round)".to_owned() } else { format!( - "{} (round {})", + "{} ({})", if self.pause_duration.is_special_round(self.round) { "pause special" } else { "pause" }, - self.round + self.round_label() ) }; self.get_clock_pause_mut().set_name(name); From 3660d85644ce01efdf918bdb2ba9d6684c0fd097 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:29:03 +0200 Subject: [PATCH 9/9] lint --- src/widgets/pomodoro.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widgets/pomodoro.rs b/src/widgets/pomodoro.rs index da8b3ac..44e34e1 100644 --- a/src/widgets/pomodoro.rs +++ b/src/widgets/pomodoro.rs @@ -190,6 +190,7 @@ impl PomodoroState { self.max_rounds.is_some_and(|m| self.round >= m) } + #[allow(dead_code)] pub fn is_complete(&self) -> bool { self.is_last_round() && self.get_clock_work().is_done() }