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`
56//! - `pomodoro_started`
57//! - `pomodoro_stopped`
58//! - `pomodoro_paused`
59//! - `pomodoro_break`
60
61use num_traits::{Num, NumAssignOps, SaturatingSub};
62use tokio::sync::mpsc;
63
64use super::prelude::*;
65use crate::{
66    formatting::Format,
67    subprocess::{spawn_shell, spawn_shell_sync},
68};
69use std::time::Instant;
70
71make_log_macro!(debug, "pomodoro");
72
73#[derive(Deserialize, Debug, SmartDefault)]
74#[serde(deny_unknown_fields, default)]
75pub struct Config {
76    pub format: FormatConfig,
77    pub pomodoro_format: FormatConfig,
78    pub break_format: FormatConfig,
79    #[default("Pomodoro over! Take a break!".into())]
80    pub message: String,
81    #[default("Break over! Time to work!".into())]
82    pub break_message: String,
83    pub notify_cmd: Option<String>,
84    pub blocking_cmd: bool,
85}
86
87enum PomodoroState {
88    Idle,
89    Prompt,
90    Notify,
91    Break,
92    PomodoroRunning,
93    PomodoroPaused,
94}
95
96impl PomodoroState {
97    fn get_block_state(&self) -> State {
98        use PomodoroState::*;
99        match self {
100            Idle | PomodoroPaused => State::Idle,
101            Prompt => State::Warning,
102            Notify => State::Good,
103            Break | PomodoroRunning => State::Info,
104        }
105    }
106
107    fn get_status_icon(&self) -> Option<&'static str> {
108        use PomodoroState::*;
109        match self {
110            Idle => Some("pomodoro_stopped"),
111            Break => Some("pomodoro_break"),
112            PomodoroRunning => Some("pomodoro_started"),
113            PomodoroPaused => Some("pomodoro_paused"),
114            _ => None,
115        }
116    }
117}
118
119struct Block<'a> {
120    widget: Widget,
121    actions: mpsc::UnboundedReceiver<BlockAction>,
122    api: &'a CommonApi,
123    config: &'a Config,
124    state: PomodoroState,
125    format: Format,
126    pomodoro_format: Format,
127    break_format: Format,
128}
129
130impl Block<'_> {
131    async fn set_text(&mut self, additional_values: Values) -> Result<()> {
132        let mut values = map! {
133            "icon" => Value::icon("pomodoro"),
134        };
135        values.extend(additional_values);
136
137        if let Some(icon) = self.state.get_status_icon() {
138            values.insert("status_icon".into(), Value::icon(icon));
139        }
140        self.widget.set_format(match self.state {
141            PomodoroState::Idle | PomodoroState::Prompt | PomodoroState::Notify => {
142                self.format.clone()
143            }
144            PomodoroState::Break => self.break_format.clone(),
145            PomodoroState::PomodoroRunning | PomodoroState::PomodoroPaused => {
146                self.pomodoro_format.clone()
147            }
148        });
149        self.widget.state = self.state.get_block_state();
150        debug!("{:?}", values);
151        self.widget.set_values(values);
152        self.api.set_widget(self.widget.clone())
153    }
154
155    async fn wait_for_click(&mut self, button: &str) -> Result<()> {
156        while self.actions.recv().await.error("channel closed")? != button {}
157        Ok(())
158    }
159
160    async fn read_params(&mut self) -> Result<Option<(Duration, Duration, usize)>> {
161        self.state = PomodoroState::Prompt;
162        let task_len = match self.read_number(25, "Task length:").await? {
163            Some(task_len) => task_len,
164            None => return Ok(None),
165        };
166        let break_len = match self.read_number(5, "Break length:").await? {
167            Some(break_len) => break_len,
168            None => return Ok(None),
169        };
170        let pomodoros = match self.read_number(4, "Pomodoros:").await? {
171            Some(pomodoros) => pomodoros,
172            None => return Ok(None),
173        };
174        Ok(Some((
175            Duration::from_secs(task_len * 60),
176            Duration::from_secs(break_len * 60),
177            pomodoros,
178        )))
179    }
180
181    async fn read_number<T: Num + NumAssignOps + SaturatingSub + std::fmt::Display>(
182        &mut self,
183        mut number: T,
184        msg: &str,
185    ) -> Result<Option<T>> {
186        loop {
187            self.set_text(map! {"message" => Value::text(format!("{msg} {number}"))})
188                .await?;
189            match &*self.actions.recv().await.error("channel closed")? {
190                "_left" => break,
191                "_up" => number += T::one(),
192                "_down" => number = number.saturating_sub(&T::one()),
193                "_middle" | "_right" => return Ok(None),
194                _ => (),
195            }
196        }
197        Ok(Some(number))
198    }
199
200    async fn set_notification(&mut self, message: &str) -> Result<()> {
201        self.state = PomodoroState::Notify;
202        self.set_text(map! {"message" => Value::text(message.to_string())})
203            .await?;
204        if let Some(cmd) = &self.config.notify_cmd {
205            let cmd = cmd.replace("{msg}", message);
206            if self.config.blocking_cmd {
207                spawn_shell_sync(&cmd)
208                    .await
209                    .error("failed to run notify_cmd")?;
210            } else {
211                spawn_shell(&cmd).error("failed to run notify_cmd")?;
212                self.wait_for_click("_left").await?;
213            }
214        } else {
215            self.wait_for_click("_left").await?;
216        }
217        Ok(())
218    }
219
220    async fn run_pomodoro(
221        &mut self,
222        task_len: Duration,
223        break_len: Duration,
224        pomodoros: usize,
225    ) -> Result<()> {
226        let interval: Seconds = 1.into();
227        let mut update_timer = interval.timer();
228        for pomodoro in 0..pomodoros {
229            let mut total_elapsed = Duration::ZERO;
230            'pomodoro_run: loop {
231                // Task timer
232                self.state = PomodoroState::PomodoroRunning;
233                let timer = Instant::now();
234                loop {
235                    let elapsed = timer.elapsed();
236                    if total_elapsed + elapsed >= task_len {
237                        break 'pomodoro_run;
238                    }
239                    let remaining_time = task_len - total_elapsed - elapsed;
240                    let values = map! {
241                        [if pomodoro != 0] "completed_pomodoros" => Value::number(pomodoro),
242                        "time_remaining" => Value::duration(remaining_time),
243                    };
244                    self.set_text(values.clone()).await?;
245                    select! {
246                        _ = update_timer.tick() => (),
247                        Some(action) = self.actions.recv() => match action.as_ref() {
248                            "_middle" | "_right" => return Ok(()),
249                            "_left" => {
250                                self.state = PomodoroState::PomodoroPaused;
251                                self.set_text(values).await?;
252                                total_elapsed += timer.elapsed();
253                                loop {
254                                    match self.actions.recv().await.as_deref() {
255                                        Some("_middle") | Some("_right") => return Ok(()),
256                                        Some("_left") =>  {
257                                            continue 'pomodoro_run;
258                                        },
259                                        _ => ()
260
261                                    }
262                                }
263                            },
264                            _ => ()
265                        }
266                    }
267                }
268            }
269
270            // Show break message
271            self.set_notification(&self.config.message).await?;
272
273            // No break after the last pomodoro
274            if pomodoro == pomodoros - 1 {
275                break;
276            }
277
278            // Break timer
279            self.state = PomodoroState::Break;
280            let timer = Instant::now();
281            loop {
282                let elapsed = timer.elapsed();
283                if elapsed >= break_len {
284                    break;
285                }
286                let remaining_time = break_len - elapsed;
287                self.set_text(map! {
288                    "time_remaining" => Value::duration(remaining_time),
289                })
290                .await?;
291                select! {
292                    _ = update_timer.tick() => (),
293                    Some(action) = self.actions.recv() => match action.as_ref() {
294                        "_middle" | "_right" => return Ok(()),
295                        _ => ()
296                    }
297                }
298            }
299
300            // Show task message
301            self.set_notification(&self.config.break_message).await?;
302        }
303
304        Ok(())
305    }
306}
307
308pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
309    api.set_default_actions(&[
310        (MouseButton::Left, None, "_left"),
311        (MouseButton::Middle, None, "_middle"),
312        (MouseButton::Right, None, "_right"),
313        (MouseButton::WheelUp, None, "_up"),
314        (MouseButton::WheelDown, None, "_down"),
315    ])?;
316
317    let format = config.format.clone().with_default(" $icon{ $message|} ")?;
318
319    let pomodoro_format = config.pomodoro_format.clone().with_default(
320        " $icon $status_icon{ $completed_pomodoros.tally()|} $time_remaining.duration(hms:true) ",
321    )?;
322
323    let break_format = config
324        .break_format
325        .clone()
326        .with_default(" $icon $status_icon Break: $time_remaining.duration(hms:true) ")?;
327
328    let widget = Widget::new();
329
330    let mut block = Block {
331        widget,
332        actions: api.get_actions()?,
333        api,
334        config,
335        state: PomodoroState::Idle,
336        format,
337        pomodoro_format,
338        break_format,
339    };
340
341    loop {
342        // Send collaped block
343        block.state = PomodoroState::Idle;
344        block.set_text(Values::default()).await?;
345
346        block.wait_for_click("_left").await?;
347
348        if let Some((task_len, break_len, pomodoros)) = block.read_params().await? {
349            block.run_pomodoro(task_len, break_len, pomodoros).await?;
350        }
351    }
352}