Skip to content

pomodoro: Overhaul block #2020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 10, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -60,6 +60,7 @@ nix = { version = "0.29", features = ["fs", "process"] }
nom = "7.1.2"
notmuch = { version = "0.8", optional = true }
oauth2 = { version = "4.4.2" }
num-traits = "0.2"
pipewire = { version = "0.8", default-features = false, optional = true }
quick-xml = { version = "0.31.0", features = ["serialize"] }
regex = "1.5"
293 changes: 197 additions & 96 deletions src/blocks/pomodoro.rs
Original file line number Diff line number Diff line change
@@ -15,16 +15,21 @@
//!
//! Key | Values | Default
//! ----|--------|--------
//! `format` | A string to customise the output of this block. | <code>\" $icon{ $message&vert;} \"</code>
//! `format` | The format used when in idle, prompt, or notify states | <code>\" $icon{ $message\|} \"</code>
//! `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>
//! `break_format` |The format used when the pomodoro is during the break | <code>\" $icon $status_icon Break: $time_remaining.duration(hms:true) \"</code>
//! `message` | Message when timer expires | `"Pomodoro over! Take a break!"`
//! `break_message` | Message when break is over | `"Break over! Time to work!"`
//! `notify_cmd` | A shell command to run as a notifier. `{msg}` will be substituted with either `message` or `break_message`. | `None`
//! `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`
//!
//! Placeholder | Value | Type
//! ------------|-------------------------------------|------
//! `icon` | A static icon | Icon
//! `message` | Current message | Text
//! Placeholder | Value | Type | Supported by
//! ----------------------|-----------------------------------------------|----------|--------------
//! `icon` | A static icon | Icon | All formats
//! `status_icon` | An icon that reflects the pomodoro state | Icon | `pomodoro_format`, `break_format`
//! `message` | Current message | Text | `format`
//! `time_remaining` | How much time is left (minutes) | Duration | `pomodoro_format`, `break_format`
//! `completed_pomodoros` | The number of completed pomodoros | Number | `pomodoro_format`
//!
//! # Example
//!
@@ -52,21 +57,25 @@
//! - `pomodoro_stopped`
//! - `pomodoro_paused`
//! - `pomodoro_break`
//!
//! # TODO
//! - Use different icons.
//! - Use format strings.
use num_traits::{Num, NumAssignOps, SaturatingSub};
use tokio::sync::mpsc;

use super::prelude::*;
use crate::subprocess::{spawn_shell, spawn_shell_sync};
use crate::{
formatting::Format,
subprocess::{spawn_shell, spawn_shell_sync},
};
use std::time::Instant;

make_log_macro!(debug, "pomodoro");

#[derive(Deserialize, Debug, SmartDefault)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
pub format: FormatConfig,
pub pomodoro_format: FormatConfig,
pub break_format: FormatConfig,
#[default("Pomodoro over! Take a break!".into())]
pub message: String,
#[default("Break over! Time to work!".into())]
@@ -75,21 +84,70 @@ pub struct Config {
pub blocking_cmd: bool,
}

enum PomodoroState {
Idle,
Prompt,
Notify,
Break,
PomodoroRunning,
PomodoroPaused,
}

impl PomodoroState {
fn get_block_state(&self) -> State {
use PomodoroState::*;
match self {
Idle | PomodoroPaused => State::Idle,
Prompt => State::Warning,
Notify => State::Good,
Break | PomodoroRunning => State::Info,
}
}

fn get_status_icon(&self) -> Option<&'static str> {
use PomodoroState::*;
match self {
Idle => Some("pomodoro_stopped"),
Break => Some("pomodoro_break"),
PomodoroRunning => Some("pomodoro_started"),
PomodoroPaused => Some("pomodoro_paused"),
_ => None,
}
}
}

struct Block<'a> {
widget: Widget,
actions: mpsc::UnboundedReceiver<BlockAction>,
api: &'a CommonApi,
block_config: &'a Config,
config: &'a Config,
state: PomodoroState,
format: Format,
pomodoro_format: Format,
break_format: Format,
}

impl Block<'_> {
async fn set_text(&mut self, text: String) -> Result<()> {
let mut values = map!(
async fn set_text(&mut self, additional_values: Values) -> Result<()> {
let mut values = map! {
"icon" => Value::icon("pomodoro"),
);
if !text.is_empty() {
values.insert("message".into(), Value::text(text));
};
values.extend(additional_values);

if let Some(icon) = self.state.get_status_icon() {
values.insert("status_icon".into(), Value::icon(icon));
}
self.widget.set_format(match self.state {
PomodoroState::Idle | PomodoroState::Prompt | PomodoroState::Notify => {
self.format.clone()
}
PomodoroState::Break => self.break_format.clone(),
PomodoroState::PomodoroRunning | PomodoroState::PomodoroPaused => {
self.pomodoro_format.clone()
}
});
self.widget.state = self.state.get_block_state();
debug!("{:?}", values);
self.widget.set_values(values);
self.api.set_widget(self.widget.clone())
}
@@ -99,124 +157,155 @@ impl Block<'_> {
Ok(())
}

async fn read_params(&mut self) -> Result<(Duration, Duration, u64)> {
let task_len = self.read_u64(25, "Task length:").await?;
let break_len = self.read_u64(5, "Break length:").await?;
let pomodoros = self.read_u64(4, "Pomodoros:").await?;
Ok((
async fn read_params(&mut self) -> Result<Option<(Duration, Duration, usize)>> {
self.state = PomodoroState::Prompt;
let task_len = match self.read_number(25, "Task length:").await? {
Some(task_len) => task_len,
None => return Ok(None),
};
let break_len = match self.read_number(5, "Break length:").await? {
Some(break_len) => break_len,
None => return Ok(None),
};
let pomodoros = match self.read_number(4, "Pomodoros:").await? {
Some(pomodoros) => pomodoros,
None => return Ok(None),
};
Ok(Some((
Duration::from_secs(task_len * 60),
Duration::from_secs(break_len * 60),
pomodoros,
))
)))
}

async fn read_u64(&mut self, mut number: u64, msg: &str) -> Result<u64> {
async fn read_number<T: Num + NumAssignOps + SaturatingSub + std::fmt::Display>(
&mut self,
mut number: T,
msg: &str,
) -> Result<Option<T>> {
loop {
self.set_text(format!("{msg} {number}")).await?;
self.set_text(map! {"message" => Value::text(format!("{msg} {number}"))})
.await?;
match &*self.actions.recv().await.error("channel closed")? {
"_left" => break,
"_up" => number += 1,
"_down" => number = number.saturating_sub(1),
"_up" => number += T::one(),
"_down" => number = number.saturating_sub(&T::one()),
"_middle" | "_right" => return Ok(None),
_ => (),
}
}
Ok(number)
Ok(Some(number))
}

async fn set_notification(&mut self, message: &str) -> Result<()> {
self.state = PomodoroState::Notify;
self.set_text(map! {"message" => Value::text(message.to_string())})
.await?;
if let Some(cmd) = &self.config.notify_cmd {
let cmd = cmd.replace("{msg}", message);
if self.config.blocking_cmd {
spawn_shell_sync(&cmd)
.await
.error("failed to run notify_cmd")?;
} else {
spawn_shell(&cmd).error("failed to run notify_cmd")?;
self.wait_for_click("_left").await?;
}
} else {
self.wait_for_click("_left").await?;
}
Ok(())
}

async fn run_pomodoro(
&mut self,
task_len: Duration,
break_len: Duration,
pomodoros: u64,
pomodoros: usize,
) -> Result<()> {
let interval: Seconds = 1.into();
let mut update_timer = interval.timer();
for pomodoro in 0..pomodoros {
// Task timer
self.widget.state = State::Idle;
let timer = Instant::now();
loop {
let elapsed = timer.elapsed();
if elapsed >= task_len {
break;
}
let left = task_len - elapsed;
let text = if pomodoro == 0 {
format!("{} min", (left.as_secs() + 59) / 60,)
} else {
format!(
"{} {} min",
"|".repeat(pomodoro as usize),
(left.as_secs() + 59) / 60,
)
};
self.set_text(text).await?;
select! {
_ = sleep(Duration::from_secs(10)) => (),
_ = self.wait_for_click("_middle") => return Ok(()),
let mut total_elapsed = Duration::ZERO;
'pomodoro_run: loop {
// Task timer
self.state = PomodoroState::PomodoroRunning;
let timer = Instant::now();
loop {
let elapsed = timer.elapsed();
if total_elapsed + elapsed >= task_len {
break 'pomodoro_run;
}
let remaining_time = task_len - total_elapsed - elapsed;
let values = map! {
[if pomodoro != 0] "completed_pomodoros" => Value::number(pomodoro),
"time_remaining" => Value::duration(remaining_time),
};
self.set_text(values.clone()).await?;
select! {
_ = update_timer.tick() => (),
Some(action) = self.actions.recv() => match action.as_ref() {
"_middle" | "_right" => return Ok(()),
"_left" => {
self.state = PomodoroState::PomodoroPaused;
self.set_text(values).await?;
total_elapsed += timer.elapsed();
loop {
match self.actions.recv().await.as_deref() {
Some("_middle") | Some("_right") => return Ok(()),
Some("_left") => {
continue 'pomodoro_run;
},
_ => ()

}
}
},
_ => ()
}
}
}
}

// Show break message
self.widget.state = State::Good;
self.set_text(self.block_config.message.clone()).await?;
if let Some(cmd) = &self.block_config.notify_cmd {
let cmd = cmd.replace("{msg}", &self.block_config.message);
if self.block_config.blocking_cmd {
spawn_shell_sync(&cmd)
.await
.error("failed to run notify_cmd")?;
} else {
spawn_shell(&cmd).error("failed to run notify_cmd")?;
self.wait_for_click("_left").await?;
}
} else {
self.wait_for_click("_left").await?;
}
self.set_notification(&self.config.message).await?;

// No break after the last pomodoro
if pomodoro == pomodoros - 1 {
break;
}

// Break timer
self.state = PomodoroState::Break;
let timer = Instant::now();
loop {
let elapsed = timer.elapsed();
if elapsed >= break_len {
break;
}
let left = break_len - elapsed;
self.set_text(format!("Break: {} min", (left.as_secs() + 59) / 60,))
.await?;
let remaining_time = break_len - elapsed;
self.set_text(map! {
"time_remaining" => Value::duration(remaining_time),
})
.await?;
select! {
_ = sleep(Duration::from_secs(10)) => (),
_ = self.wait_for_click("_middle") => return Ok(()),
_ = update_timer.tick() => (),
Some(action) = self.actions.recv() => match action.as_ref() {
"_middle" | "_right" => return Ok(()),
_ => ()
}
}
}

// Show task message
self.widget.state = State::Good;
self.set_text(self.block_config.break_message.clone())
.await?;
if let Some(cmd) = &self.block_config.notify_cmd {
let cmd = cmd.replace("{msg}", &self.block_config.break_message);
if self.block_config.blocking_cmd {
spawn_shell_sync(&cmd)
.await
.error("failed to run notify_cmd")?;
} else {
spawn_shell(&cmd).error("failed to run notify_cmd")?;
self.wait_for_click("_left").await?;
}
} else {
self.wait_for_click("_left").await?;
}
self.set_notification(&self.config.break_message).await?;
}

Ok(())
}
}

pub async fn run(block_config: &Config, api: &CommonApi) -> Result<()> {
pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
api.set_default_actions(&[
(MouseButton::Left, None, "_left"),
(MouseButton::Middle, None, "_middle"),
@@ -225,27 +314,39 @@ pub async fn run(block_config: &Config, api: &CommonApi) -> Result<()> {
(MouseButton::WheelDown, None, "_down"),
])?;

let format = block_config
.format
let format = config.format.clone().with_default(" $icon{ $message|} ")?;

let pomodoro_format = config.pomodoro_format.clone().with_default(
" $icon $status_icon{ $completed_pomodoros.tally()|} $time_remaining.duration(hms:true) ",
)?;

let break_format = config
.break_format
.clone()
.with_default(" $icon{ $message|} ")?;
let widget = Widget::new().with_format(format);
.with_default(" $icon $status_icon Break: $time_remaining.duration(hms:true) ")?;

let widget = Widget::new();

let mut block = Block {
widget,
actions: api.get_actions()?,
api,
block_config,
config,
state: PomodoroState::Idle,
format,
pomodoro_format,
break_format,
};

loop {
// Send collaped block
block.widget.state = State::Idle;
block.set_text(String::new()).await?;
block.state = PomodoroState::Idle;
block.set_text(Values::default()).await?;

block.wait_for_click("_left").await?;

let (task_len, break_len, pomodoros) = block.read_params().await?;
block.run_pomodoro(task_len, break_len, pomodoros).await?;
if let Some((task_len, break_len, pomodoros)) = block.read_params().await? {
block.run_pomodoro(task_len, break_len, pomodoros).await?;
}
}
}