1use 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 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 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 self.set_notification(&self.config.message).await?;
268
269 if pomodoro == pomodoros - 1 {
271 break;
272 }
273
274 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 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 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 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 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}