Skip to main content

i3status_rs/blocks/
watson.rs

1//! Watson statistics
2//!
3//! [Watson](http://tailordev.github.io/Watson/) is a simple CLI time tracking application. This block will show the name of your current active project, tags and optionally recorded time. Clicking the widget will toggle the `show_time` variable dynamically.
4//!
5//! # Configuration
6//!
7//! Key | Values | Default
8//! ----|--------|--------
9//! `format` | A string to customise the output of this block. See below for available placeholders | `" $text \|"`
10//! `show_time` | Whether to show recorded time. | `false`
11//! `state_path` | Path to the Watson state file. Supports path expansions e.g. `~`. | `$XDG_CONFIG_HOME/watson/state`
12//! `interval` | Update interval, in seconds. | `60`
13//!
14//! Placeholder   | Value                   | Type   | Unit
15//! --------------|-------------------------|--------|-----
16//! `text`        | Current activity        | Text   | -
17//!
18//! Action             | Description                     | Default button
19//! -------------------|---------------------------------|---------------
20//! `toggle_show_time` | Toggle the value of `show_time` | Left
21//!
22//! # Example
23//!
24//! ```toml
25//! [[block]]
26//! block = "watson"
27//! show_time = true
28//! state_path = "~/.config/watson/state"
29//! ```
30//!
31//! # TODO
32//! - Extend functionality: start / stop watson using this block
33
34use chrono::{DateTime, offset::Local};
35use dirs::config_dir;
36use inotify::{Inotify, WatchMask};
37use serde::de::Deserializer;
38use std::path::PathBuf;
39use tokio::fs::read_to_string;
40
41use super::prelude::*;
42
43#[derive(Deserialize, Debug, SmartDefault)]
44#[serde(deny_unknown_fields, default)]
45pub struct Config {
46    pub format: FormatConfig,
47    pub state_path: Option<ShellString>,
48    #[default(60.into())]
49    pub interval: Seconds,
50    pub show_time: bool,
51}
52
53pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
54    BlockPlan::new(vec![OutputPlan::new(
55        "main",
56        config.format.with_default(" $text |")?,
57    )])
58}
59
60pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
61    let mut actions = api.get_actions()?;
62    api.set_default_actions(&[(MouseButton::Left, None, "toggle_show_time")])?;
63
64    let output_main = plan.output("main")?;
65
66    let mut show_time = config.show_time;
67
68    let (state_dir, state_file, state_path) = match &config.state_path {
69        Some(p) => {
70            let mut p: PathBuf = (*p.expand()?).into();
71            let path = p.clone();
72            let file = p.file_name().error("Failed to parse state_dir")?.to_owned();
73            p.pop();
74            (p, file, path)
75        }
76        None => {
77            let mut path = config_dir().error("xdg config directory not found")?;
78            path.push("watson");
79            let dir = path.clone();
80            path.push("state");
81            (dir, "state".into(), path)
82        }
83    };
84
85    let notify = Inotify::init().error("Failed to start inotify")?;
86    notify
87        .watches()
88        .add(&state_dir, WatchMask::CREATE | WatchMask::MOVED_TO)
89        .error("Failed to watch watson state directory")?;
90    let mut state_updates = notify
91        .into_event_stream([0; 1024])
92        .error("Failed to create event stream")?;
93
94    let mut timer = config.interval.timer();
95    let mut prev_state = None;
96
97    loop {
98        let state = read_to_string(&state_path)
99            .await
100            .error("Failed to read state file")?;
101        let state = serde_json::from_str(&state).unwrap_or(WatsonState::Idle {});
102
103        let mut widget = output_main.new_widget();
104
105        match state {
106            state @ WatsonState::Active { .. } => {
107                widget.state = State::Good;
108                widget.set_values(map!(
109                  "text" => Value::text(state.format(show_time, "started", format_delta_past))
110                ));
111                prev_state = Some(state);
112            }
113            WatsonState::Idle {} => {
114                if let Some(prev @ WatsonState::Active { .. }) = &prev_state {
115                    // The previous state was active, which means that we just now stopped the time
116                    // tracking. This means that we could show some statistics.
117                    widget.state = State::Idle;
118                    widget.set_values(map!(
119                      "text" => Value::text(prev.format(true, "stopped", format_delta_after))
120                    ));
121                } else {
122                    // File is empty which means that there is currently no active time tracking,
123                    // and the previous state wasn't time tracking neither so we reset the
124                    // contents.
125                    widget.state = State::Idle;
126                    widget.set_values(Values::default());
127                }
128                prev_state = Some(state);
129            }
130        }
131
132        api.set_widget(widget)?;
133
134        loop {
135            select! {
136                _ = timer.tick() => break,
137                _ = api.wait_for_update_request() => break,
138                Some(update) = state_updates.next() => {
139                    let update = update.error("Bad inotify update")?;
140                    if update.name.is_some_and(|x| state_file == x) {
141                        break;
142                    }
143                }
144                Some(action) = actions.recv() => match action.as_ref() {
145                    "toggle_show_time" => {
146                        show_time = !show_time;
147                        break;
148                    }
149                    _ => (),
150                }
151            }
152        }
153    }
154}
155
156fn format_delta_past(delta: &chrono::Duration) -> String {
157    let spans = &[
158        ("week", delta.num_weeks()),
159        ("day", delta.num_days()),
160        ("hour", delta.num_hours()),
161        ("minute", delta.num_minutes()),
162    ];
163
164    spans
165        .iter()
166        .filter(|&(_, n)| *n != 0)
167        .map(|&(label, n)| format!("{n} {label}{} ago", if n > 1 { "s" } else { "" }))
168        .next()
169        .unwrap_or_else(|| "now".into())
170}
171
172fn format_delta_after(delta: &chrono::Duration) -> String {
173    let spans = &[
174        ("week", delta.num_weeks()),
175        ("day", delta.num_days()),
176        ("hour", delta.num_hours()),
177        ("minute", delta.num_minutes()),
178        ("second", delta.num_seconds()),
179    ];
180
181    spans
182        .iter()
183        .find(|&(_, n)| *n != 0)
184        .map(|&(label, n)| format!("after {n} {label}{}", if n > 1 { "s" } else { "" }))
185        .unwrap_or_else(|| "now".into())
186}
187
188#[derive(Deserialize, Clone, Debug)]
189#[serde(untagged)]
190enum WatsonState {
191    Active {
192        project: String,
193        #[serde(deserialize_with = "deserialize_local_timestamp")]
194        start: DateTime<Local>,
195        tags: Vec<String>,
196    },
197    // This matches an empty JSON object
198    Idle {},
199}
200
201impl WatsonState {
202    fn format(&self, show_time: bool, verb: &str, f: fn(&chrono::Duration) -> String) -> String {
203        if let WatsonState::Active {
204            project,
205            start,
206            tags,
207        } = self
208        {
209            let mut s = project.clone();
210            if let [first, other @ ..] = &tags[..] {
211                s.push_str(" [");
212                s.push_str(first);
213                for tag in other {
214                    s.push(' ');
215                    s.push_str(tag);
216                }
217                s.push(']');
218            }
219            if show_time {
220                s.push(' ');
221                s.push_str(verb);
222                let delta = Local::now() - *start;
223                s.push(' ');
224                s.push_str(&f(&delta));
225            }
226            s
227        } else {
228            panic!("WatsonState::Idle does not have a specified format")
229        }
230    }
231}
232
233pub fn deserialize_local_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
234where
235    D: Deserializer<'de>,
236{
237    use chrono::TimeZone as _;
238    i64::deserialize(deserializer).map(|seconds| Local.timestamp_opt(seconds, 0).single().unwrap())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn plan_declares_single_output_without_icons() {
247        let plan = prepare(&Config::default()).unwrap();
248        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
249        assert_eq!(ids, ["main"]);
250        let main = plan.output("main").unwrap();
251        assert!(main.format().contains_key("text"));
252        assert_eq!(main.output().icon_placeholders().count(), 0);
253    }
254
255    #[test]
256    fn plan_uses_configured_format() {
257        let config = Config {
258            format: " watson: $text ".parse().unwrap(),
259            ..Config::default()
260        };
261        let plan = prepare(&config).unwrap();
262        assert!(plan.output("main").unwrap().format().contains_key("text"));
263    }
264}