Skip to main content

i3status_rs/blocks/
pomodoro.rs

1//! A [pomodoro timer](https://en.wikipedia.org/wiki/Pomodoro_Technique)
2//!
3//! # Technique
4//!
5//! There are six steps in the original technique:
6//! 1) Decide on the task to be done.
7//! 2) Set the pomodoro timer (traditionally to 25 minutes).
8//! 3) Work on the task.
9//! 4) End work when the timer rings and put a checkmark on a piece of paper.
10//! 5) If you have fewer than four checkmarks, take a short break (3–5 minutes) and then return to step 2.
11//! 6) After four pomodoros, take a longer break (15–30 minutes), reset your checkmark count to zero, then go to step 1.
12//!
13//!
14//! # Configuration
15//!
16//! Key | Values | Default
17//! ----|--------|--------
18//! `format` | The format used when in idle, prompt, or notify states | <code>\" $icon{ $message\|} \"</code>
19//! `pomodoro_format` | The format used when the pomodoro is running or paused | <code>\" $icon $status_icon{ $completed_pomodoros.tally()\|} $time_remaining.duration(hms:true) \"</code>
20//! `break_format` |The format used when the pomodoro is during the break | <code>\" $icon $status_icon Break: $time_remaining.duration(hms:true) \"</code>
21//! `message` | Message when timer expires | `"Pomodoro over! Take a break!"`
22//! `break_message` | Message when break is over | `"Break over! Time to work!"`
23//! `notify_cmd` | A shell command to run as a notifier. `{msg}` will be substituted with either `message` or `break_message`. | `None`
24//! `blocking_cmd` | Is `notify_cmd` blocking? If it is, then pomodoro block will wait until the command finishes before proceeding. Otherwise, you will have to click on the block in order to proceed. | `false`
25//!
26//! Placeholder           | Value                                         | Type     | Supported by
27//! ----------------------|-----------------------------------------------|----------|--------------
28//! `icon`                | A static icon                                 | Icon     | All formats
29//! `status_icon`         | An icon that reflects the pomodoro state      | Icon     | `pomodoro_format`, `break_format`
30//! `message`             | Current message                               | Text     | `format`
31//! `time_remaining`      | How much time is left (minutes)               | Duration | `pomodoro_format`, `break_format`
32//! `completed_pomodoros` | The number of completed pomodoros             | Number   | `pomodoro_format`
33//!
34//! # Example
35//!
36//! Use `swaynag` as a notifier:
37//!
38//! ```toml
39//! [[block]]
40//! block = "pomodoro"
41//! notify_cmd = "swaynag -m '{msg}'"
42//! blocking_cmd = true
43//! ```
44//!
45//! Use `notify-send` as a notifier:
46//!
47//! ```toml
48//! [[block]]
49//! block = "pomodoro"
50//! notify_cmd = "notify-send '{msg}'"
51//! blocking_cmd = false
52//! ```
53//!
54//! # Icons Used
55//! - `pomodoro` (`$icon`)
56//! - `pomodoro_started` (`$status_icon`, in `pomodoro_format`)
57//! - `pomodoro_stopped` (`$status_icon`, in `format`)
58//! - `pomodoro_paused` (`$status_icon`, in `pomodoro_format`)
59//! - `pomodoro_break` (`$status_icon`, in `break_format`)
60
61use num_traits::{Num, NumAssignOps, SaturatingSub};
62use tokio::sync::mpsc;
63
64use super::prelude::*;
65use crate::subprocess::{spawn_shell, spawn_shell_sync};
66use std::time::Instant;
67
68make_log_macro!(debug, "pomodoro");
69
70#[derive(Deserialize, Debug, SmartDefault)]
71#[serde(deny_unknown_fields, default)]
72pub struct Config {
73    pub format: FormatConfig,
74    pub pomodoro_format: FormatConfig,
75    pub break_format: FormatConfig,
76    #[default("Pomodoro over! Take a break!".into())]
77    pub message: String,
78    #[default("Break over! Time to work!".into())]
79    pub break_message: String,
80    pub notify_cmd: Option<String>,
81    pub blocking_cmd: bool,
82}
83
84enum PomodoroState {
85    Idle,
86    Prompt,
87    Notify,
88    Break,
89    PomodoroRunning,
90    PomodoroPaused,
91}
92
93impl PomodoroState {
94    fn get_block_state(&self) -> State {
95        use PomodoroState::*;
96        match self {
97            Idle | PomodoroPaused => State::Idle,
98            Prompt => State::Warning,
99            Notify => State::Good,
100            Break | PomodoroRunning => State::Info,
101        }
102    }
103
104    fn get_status_icon(&self) -> Option<&'static str> {
105        use PomodoroState::*;
106        match self {
107            Idle => Some(icons::POMODORO_STOPPED),
108            Break => Some(icons::POMODORO_BREAK),
109            PomodoroRunning => Some(icons::POMODORO_STARTED),
110            PomodoroPaused => Some(icons::POMODORO_PAUSED),
111            _ => None,
112        }
113    }
114}
115
116struct Block<'a> {
117    actions: mpsc::UnboundedReceiver<BlockAction>,
118    api: &'a CommonApi,
119    config: &'a Config,
120    state: PomodoroState,
121    plan: Arc<BlockPlan>,
122}
123
124impl Block<'_> {
125    async fn set_text(&mut self, additional_values: Values) -> Result<()> {
126        let output = self.plan.output(match self.state {
127            PomodoroState::Idle => "idle",
128            PomodoroState::Prompt => "prompt",
129            PomodoroState::Notify => "notify",
130            PomodoroState::Break => "break",
131            PomodoroState::PomodoroRunning => "running",
132            PomodoroState::PomodoroPaused => "paused",
133        })?;
134        let mut values = map! {
135            "icon" => output.icon_value("icon")?,
136        };
137        values.extend(additional_values);
138
139        // Each state that shows a status icon declares exactly one for its
140        // output, so the name comes from the plan itself.
141        if self.state.get_status_icon().is_some() {
142            values.insert("status_icon".into(), output.icon_value("status_icon")?);
143        }
144        let mut widget = output.new_widget();
145        widget.state = self.state.get_block_state();
146        debug!("{:?}", values);
147        widget.set_values(values);
148        self.api.set_widget(widget)
149    }
150
151    async fn wait_for_click(&mut self, button: &str) -> Result<()> {
152        while self.actions.recv().await.error("channel closed")? != button {}
153        Ok(())
154    }
155
156    async fn read_params(&mut self) -> Result<Option<(Duration, Duration, usize)>> {
157        self.state = PomodoroState::Prompt;
158        let task_len = match self.read_number(25, "Task length:").await? {
159            Some(task_len) => task_len,
160            None => return Ok(None),
161        };
162        let break_len = match self.read_number(5, "Break length:").await? {
163            Some(break_len) => break_len,
164            None => return Ok(None),
165        };
166        let pomodoros = match self.read_number(4, "Pomodoros:").await? {
167            Some(pomodoros) => pomodoros,
168            None => return Ok(None),
169        };
170        Ok(Some((
171            Duration::from_secs(task_len * 60),
172            Duration::from_secs(break_len * 60),
173            pomodoros,
174        )))
175    }
176
177    async fn read_number<T: Num + NumAssignOps + SaturatingSub + std::fmt::Display>(
178        &mut self,
179        mut number: T,
180        msg: &str,
181    ) -> Result<Option<T>> {
182        loop {
183            self.set_text(map! {"message" => Value::text(format!("{msg} {number}"))})
184                .await?;
185            match &*self.actions.recv().await.error("channel closed")? {
186                "_left" => break,
187                "_up" => number += T::one(),
188                "_down" => number = number.saturating_sub(&T::one()),
189                "_middle" | "_right" => return Ok(None),
190                _ => (),
191            }
192        }
193        Ok(Some(number))
194    }
195
196    async fn set_notification(&mut self, message: &str) -> Result<()> {
197        self.state = PomodoroState::Notify;
198        self.set_text(map! {"message" => Value::text(message.to_string())})
199            .await?;
200        if let Some(cmd) = &self.config.notify_cmd {
201            let cmd = cmd.replace("{msg}", message);
202            if self.config.blocking_cmd {
203                spawn_shell_sync(&cmd)
204                    .await
205                    .error("failed to run notify_cmd")?;
206            } else {
207                spawn_shell(&cmd).error("failed to run notify_cmd")?;
208                self.wait_for_click("_left").await?;
209            }
210        } else {
211            self.wait_for_click("_left").await?;
212        }
213        Ok(())
214    }
215
216    async fn run_pomodoro(
217        &mut self,
218        task_len: Duration,
219        break_len: Duration,
220        pomodoros: usize,
221    ) -> Result<()> {
222        let interval: Seconds = 1.into();
223        let mut update_timer = interval.timer();
224        for pomodoro in 0..pomodoros {
225            let mut total_elapsed = Duration::ZERO;
226            'pomodoro_run: loop {
227                // Task timer
228                self.state = PomodoroState::PomodoroRunning;
229                let timer = Instant::now();
230                loop {
231                    let elapsed = timer.elapsed();
232                    if total_elapsed + elapsed >= task_len {
233                        break 'pomodoro_run;
234                    }
235                    let remaining_time = task_len - total_elapsed - elapsed;
236                    let values = map! {
237                        [if pomodoro != 0] "completed_pomodoros" => Value::number(pomodoro),
238                        "time_remaining" => Value::duration(remaining_time),
239                    };
240                    self.set_text(values.clone()).await?;
241                    select! {
242                        _ = update_timer.tick() => (),
243                        Some(action) = self.actions.recv() => match action.as_ref() {
244                            "_middle" | "_right" => return Ok(()),
245                            "_left" => {
246                                self.state = PomodoroState::PomodoroPaused;
247                                self.set_text(values).await?;
248                                total_elapsed += timer.elapsed();
249                                loop {
250                                    match self.actions.recv().await.as_deref() {
251                                        Some("_middle") | Some("_right") => return Ok(()),
252                                        Some("_left") =>  {
253                                            continue 'pomodoro_run;
254                                        },
255                                        _ => ()
256
257                                    }
258                                }
259                            },
260                            _ => ()
261                        }
262                    }
263                }
264            }
265
266            // Show break message
267            self.set_notification(&self.config.message).await?;
268
269            // No break after the last pomodoro
270            if pomodoro == pomodoros - 1 {
271                break;
272            }
273
274            // Break timer
275            self.state = PomodoroState::Break;
276            let timer = Instant::now();
277            loop {
278                let elapsed = timer.elapsed();
279                if elapsed >= break_len {
280                    break;
281                }
282                let remaining_time = break_len - elapsed;
283                self.set_text(map! {
284                    "time_remaining" => Value::duration(remaining_time),
285                })
286                .await?;
287                select! {
288                    _ = update_timer.tick() => (),
289                    Some(action) = self.actions.recv() => match action.as_ref() {
290                        "_middle" | "_right" => return Ok(()),
291                        _ => ()
292                    }
293                }
294            }
295
296            // Show task message
297            self.set_notification(&self.config.break_message).await?;
298        }
299
300        Ok(())
301    }
302}
303
304pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
305    let format = config.format.clone().with_default(" $icon{ $message|} ")?;
306
307    let pomodoro_format = config.pomodoro_format.clone().with_default(
308        " $icon $status_icon{ $completed_pomodoros.tally()|} $time_remaining.duration(hms:true) ",
309    )?;
310
311    let break_format = config
312        .break_format
313        .clone()
314        .with_default(" $icon $status_icon Break: $time_remaining.duration(hms:true) ")?;
315
316    let base = || IconChoices::one(icons::POMODORO);
317    BlockPlan::new(vec![
318        OutputPlan::new("idle", format.clone())
319            .icon("icon", base())
320            .icon("status_icon", IconChoices::one(icons::POMODORO_STOPPED)),
321        // Prompt and notify renders always carry the interactive message.
322        OutputPlan::new("prompt", format.clone()).icon("icon", base()),
323        OutputPlan::new("notify", format).icon("icon", base()),
324        OutputPlan::new("running", pomodoro_format.clone())
325            .icon("icon", base())
326            .icon("status_icon", IconChoices::one(icons::POMODORO_STARTED)),
327        OutputPlan::new("paused", pomodoro_format)
328            .icon("icon", base())
329            .icon("status_icon", IconChoices::one(icons::POMODORO_PAUSED)),
330        OutputPlan::new("break", break_format)
331            .icon("icon", base())
332            .icon("status_icon", IconChoices::one(icons::POMODORO_BREAK)),
333    ])
334}
335
336pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
337    api.set_default_actions(&[
338        (MouseButton::Left, None, "_left"),
339        (MouseButton::Middle, None, "_middle"),
340        (MouseButton::Right, None, "_right"),
341        (MouseButton::WheelUp, None, "_up"),
342        (MouseButton::WheelDown, None, "_down"),
343    ])?;
344
345    let mut block = Block {
346        actions: api.get_actions()?,
347        api,
348        config,
349        state: PomodoroState::Idle,
350        plan: plan.clone(),
351    };
352
353    loop {
354        // Send collaped block
355        block.state = PomodoroState::Idle;
356        block.set_text(Values::default()).await?;
357
358        block.wait_for_click("_left").await?;
359
360        if let Some((task_len, break_len, pomodoros)) = block.read_params().await? {
361            block.run_pomodoro(task_len, break_len, pomodoros).await?;
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn plan_scopes_status_icons_to_their_states() {
372        let plan = prepare(&Config::default()).unwrap();
373        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
374        assert_eq!(
375            ids,
376            ["idle", "prompt", "notify", "running", "paused", "break"]
377        );
378        for (id, status_icon) in [
379            ("idle", Some("pomodoro_stopped")),
380            ("prompt", None),
381            ("notify", None),
382            ("running", Some("pomodoro_started")),
383            ("paused", Some("pomodoro_paused")),
384            ("break", Some("pomodoro_break")),
385        ] {
386            let output = plan.output(id).unwrap();
387            assert_eq!(output.single_icon("icon").unwrap(), "pomodoro");
388            match status_icon {
389                Some(name) => assert_eq!(output.single_icon("status_icon").unwrap(), name),
390                None => assert!(
391                    output.output().choices_for("status_icon").is_none(),
392                    "{id} sets no status icon"
393                ),
394            }
395        }
396    }
397
398    #[test]
399    fn runtime_status_icon_chooser_matches_the_plan() {
400        // Ties `PomodoroState::get_status_icon` to the per-output
401        // declarations so neither can drift from the other.
402        let plan = prepare(&Config::default()).unwrap();
403        use PomodoroState::*;
404        for (state, id) in [
405            (Idle, "idle"),
406            (Prompt, "prompt"),
407            (Notify, "notify"),
408            (Break, "break"),
409            (PomodoroRunning, "running"),
410            (PomodoroPaused, "paused"),
411        ] {
412            let output = plan.output(id).unwrap();
413            match state.get_status_icon() {
414                Some(icon) => {
415                    let choices = output.output().choices_for("status_icon").unwrap();
416                    assert!(choices.permits(icon), "{id} must permit {icon}");
417                }
418                None => assert!(output.output().choices_for("status_icon").is_none()),
419            }
420        }
421    }
422
423    #[test]
424    fn states_share_the_expected_formats() {
425        let config = Config {
426            pomodoro_format: " $time_remaining ".parse().unwrap(),
427            ..Config::default()
428        };
429        let plan = prepare(&config).unwrap();
430        for id in ["running", "paused"] {
431            let format = plan.output(id).unwrap().format().clone();
432            assert!(format.contains_key("time_remaining"));
433            assert!(!format.contains_key("icon"));
434        }
435        for id in ["idle", "prompt", "notify"] {
436            assert!(plan.output(id).unwrap().format().contains_key("icon"));
437        }
438    }
439}