diff --git a/cspell.yaml b/cspell.yaml index 2be42c67f7..81ae7845b4 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -154,6 +154,7 @@ words: - sysfs - tebi - tera + - timew - tzname - tzset - udev diff --git a/src/blocks.rs b/src/blocks.rs index a3e8acb0e9..a0ba6036ae 100644 --- a/src/blocks.rs +++ b/src/blocks.rs @@ -193,6 +193,7 @@ define_blocks!( taskwarrior, temperature, time, + timewarrior, tea_timer, toggle, uptime, diff --git a/src/blocks/timewarrior.rs b/src/blocks/timewarrior.rs new file mode 100644 index 0000000000..26d4a67cf3 --- /dev/null +++ b/src/blocks/timewarrior.rs @@ -0,0 +1,193 @@ +//! Time and information of the current timewarrior task +//! +//! Clicking left mouse stops or resumes the task +//! +//! # Configuration +//! +//! Key | Values | Default +//! ----|--------|-------- +//! `interval` | Update interval in seconds | `30` +//! `format` | A string to customise the output of the block. See placeholders. | " $icon {$elapsed |}" +//! `info` | The threshold of minutes the task turns into a info state | - +//! `good` | The threshold of minutes the task turns into a good state | - +//! `warning` | The threshold of minutes the task turns into a warning state | - +//! `critical` | The threshold of minutes the task turns into a critical state | - +//! +//! Placeholder | Value | Type | Unit +//! ------------|-------|------|------ +//! `icon` | A static icon | Icon | - +//! `elapsed`| Elapsed time in format H:MM (Only present if task is active) | Text | - +//! `tags` | Tags of the active task separated by space (Only present if task is active) | Text | - +//! `annotation` | Annotation of the active task (Only present if task is active) | Text | - +//! +//! Action | Default button +//! ----------------|---------------- +//! `stop_continue` | Left +//! +//! # Example +//! ```toml +//! [[block]] +//! block = "timewarrior" +//! format = " $icon {$tags.str(w:8,rot_interval:4) $elapsed|}" +//! ``` +//! +//! # Icons Used +//! - `tasks` + +use super::prelude::*; +use chrono::DateTime; +use tokio::process::Command; + +#[derive(Deserialize, Debug, SmartDefault)] +#[serde(deny_unknown_fields, default)] +pub struct Config { + #[default(30.into())] + interval: Seconds, + format: FormatConfig, + + info: Option, + good: Option, + warning: Option, + critical: Option, +} + +pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { + let mut actions = api.get_actions()?; + api.set_default_actions(&[(MouseButton::Left, None, "stop_continue")])?; + + let widget = Widget::new().with_format(config.format.with_default(" $icon {$elapsed|}")?); + + loop { + let mut values = map! { + "icon" => Value::icon("tasks"), + }; + let mut state = State::Idle; + let mut widget = widget.clone(); + + let data = get_current_timewarrior_task().await?; + if let Some(tw) = data { + if tw.end.is_none() { + // only show active tasks + let elapsed = chrono::Utc::now() - tw.start; + + // calculate state + for (level, st) in [ + (&config.critical, State::Critical), + (&config.warning, State::Warning), + (&config.good, State::Good), + (&config.info, State::Info), + ] { + if let Some(value) = level { + if (elapsed.num_minutes() as u64) >= *value { + state = st; + break; + } + } + } + + values.insert("tags".into(), Value::text(tw.tags.join(" "))); + + let elapsedstr = + format!("{}:{:0>2}", elapsed.num_hours(), elapsed.num_minutes() % 60); + values.insert("elapsed".into(), Value::text(elapsedstr)); + + if let Some(annotation) = tw.annotation { + values.insert("annotation".into(), Value::text(annotation)); + } + } + } + + widget.state = state; + widget.set_values(values); + api.set_widget(widget)?; + + select! { + _ = sleep(config.interval.0) => (), + _ = api.wait_for_update_request() => (), + Some(action) = actions.recv() => match action.as_ref() { + "stop_continue" => { stop_continue().await?; } + _ => (), + } + } + } +} + +/// Raw output from timew +#[derive(Deserialize, Debug)] +struct TimewarriorRAW { + pub id: u32, + pub start: String, + pub tags: Vec, + pub annotation: Option, + pub end: Option, +} + +/// TimeWarrior entry +#[derive(Debug, PartialEq, Deserialize)] +#[serde(from = "TimewarriorRAW")] +struct TimewarriorData { + pub id: u32, + pub start: DateTime, + pub tags: Vec, + pub annotation: Option, + pub end: Option>, +} + +impl From for TimewarriorData { + fn from(item: TimewarriorRAW) -> Self { + Self { + id: item.id, + tags: item.tags, + annotation: item.annotation, + start: chrono::TimeZone::from_utc_datetime( + &chrono::Utc, + &chrono::NaiveDateTime::parse_from_str(&item.start, "%Y%m%dT%H%M%SZ").unwrap() + ), + end: item.end.map(|v| { + chrono::TimeZone::from_utc_datetime( + &chrono::Utc, + &chrono::NaiveDateTime::parse_from_str(&v, "%Y%m%dT%H%M%SZ").unwrap() + ) + }), + } + } +} + +/// Format a DateTime given a format string +#[allow(dead_code)] +fn format_datetime(date: &DateTime, format: &str) -> String { + date.format(format).to_string() +} + +/// Execute "timew export now" and return the current task (if any) +async fn get_current_timewarrior_task() -> Result> { + let out = Command::new("timew") + .args(["export", "now"]) + .output() + .await + .error("failed to run timewarrior")? + .stdout; + Ok(serde_json::from_slice::>(&out) + .unwrap_or_default() + .into_iter() + .next()) +} + +/// Stop or continue a task +async fn stop_continue() -> Result<()> { + let is_stopped = get_current_timewarrior_task() + .await? + .map_or(true, |tw| tw.end.is_some()); + let args = if is_stopped { "continue" } else { "stop" }; + Command::new("timew") + .args([args]) + .stdout(std::process::Stdio::null()) + .spawn() + .error("Error spawning timew")? + .wait() + .await + .error("Error executing stop/continue")? + .success() + .then_some(()) + .error("timew exited with non-zero value when attempting to stop/continue") +}