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 async fn run(config: &Config, api: &CommonApi) -> Result<()> {
54    let mut actions = api.get_actions()?;
55    api.set_default_actions(&[(MouseButton::Left, None, "toggle_show_time")])?;
56
57    let format = config.format.with_default(" $text |")?;
58
59    let mut show_time = config.show_time;
60
61    let (state_dir, state_file, state_path) = match &config.state_path {
62        Some(p) => {
63            let mut p: PathBuf = (*p.expand()?).into();
64            let path = p.clone();
65            let file = p.file_name().error("Failed to parse state_dir")?.to_owned();
66            p.pop();
67            (p, file, path)
68        }
69        None => {
70            let mut path = config_dir().error("xdg config directory not found")?;
71            path.push("watson");
72            let dir = path.clone();
73            path.push("state");
74            (dir, "state".into(), path)
75        }
76    };
77
78    let notify = Inotify::init().error("Failed to start inotify")?;
79    notify
80        .watches()
81        .add(&state_dir, WatchMask::CREATE | WatchMask::MOVED_TO)
82        .error("Failed to watch watson state directory")?;
83    let mut state_updates = notify
84        .into_event_stream([0; 1024])
85        .error("Failed to create event stream")?;
86
87    let mut timer = config.interval.timer();
88    let mut prev_state = None;
89
90    loop {
91        let state = read_to_string(&state_path)
92            .await
93            .error("Failed to read state file")?;
94        let state = serde_json::from_str(&state).unwrap_or(WatsonState::Idle {});
95
96        let mut widget = Widget::new().with_format(format.clone());
97
98        match state {
99            state @ WatsonState::Active { .. } => {
100                widget.state = State::Good;
101                widget.set_values(map!(
102                  "text" => Value::text(state.format(show_time, "started", format_delta_past))
103                ));
104                prev_state = Some(state);
105            }
106            WatsonState::Idle {} => {
107                if let Some(prev @ WatsonState::Active { .. }) = &prev_state {
108                    // The previous state was active, which means that we just now stopped the time
109                    // tracking. This means that we could show some statistics.
110                    widget.state = State::Idle;
111                    widget.set_values(map!(
112                      "text" => Value::text(prev.format(true, "stopped", format_delta_after))
113                    ));
114                } else {
115                    // File is empty which means that there is currently no active time tracking,
116                    // and the previous state wasn't time tracking neither so we reset the
117                    // contents.
118                    widget.state = State::Idle;
119                    widget.set_values(Values::default());
120                }
121                prev_state = Some(state);
122            }
123        }
124
125        api.set_widget(widget)?;
126
127        loop {
128            select! {
129                _ = timer.tick() => break,
130                _ = api.wait_for_update_request() => break,
131                Some(update) = state_updates.next() => {
132                    let update = update.error("Bad inotify update")?;
133                    if update.name.is_some_and(|x| state_file == x) {
134                        break;
135                    }
136                }
137                Some(action) = actions.recv() => match action.as_ref() {
138                    "toggle_show_time" => {
139                        show_time = !show_time;
140                        break;
141                    }
142                    _ => (),
143                }
144            }
145        }
146    }
147}
148
149fn format_delta_past(delta: &chrono::Duration) -> String {
150    let spans = &[
151        ("week", delta.num_weeks()),
152        ("day", delta.num_days()),
153        ("hour", delta.num_hours()),
154        ("minute", delta.num_minutes()),
155    ];
156
157    spans
158        .iter()
159        .filter(|&(_, n)| *n != 0)
160        .map(|&(label, n)| format!("{n} {label}{} ago", if n > 1 { "s" } else { "" }))
161        .next()
162        .unwrap_or_else(|| "now".into())
163}
164
165fn format_delta_after(delta: &chrono::Duration) -> String {
166    let spans = &[
167        ("week", delta.num_weeks()),
168        ("day", delta.num_days()),
169        ("hour", delta.num_hours()),
170        ("minute", delta.num_minutes()),
171        ("second", delta.num_seconds()),
172    ];
173
174    spans
175        .iter()
176        .find(|&(_, n)| *n != 0)
177        .map(|&(label, n)| format!("after {n} {label}{}", if n > 1 { "s" } else { "" }))
178        .unwrap_or_else(|| "now".into())
179}
180
181#[derive(Deserialize, Clone, Debug)]
182#[serde(untagged)]
183enum WatsonState {
184    Active {
185        project: String,
186        #[serde(deserialize_with = "deserialize_local_timestamp")]
187        start: DateTime<Local>,
188        tags: Vec<String>,
189    },
190    // This matches an empty JSON object
191    Idle {},
192}
193
194impl WatsonState {
195    fn format(&self, show_time: bool, verb: &str, f: fn(&chrono::Duration) -> String) -> String {
196        if let WatsonState::Active {
197            project,
198            start,
199            tags,
200        } = self
201        {
202            let mut s = project.clone();
203            if let [first, other @ ..] = &tags[..] {
204                s.push_str(" [");
205                s.push_str(first);
206                for tag in other {
207                    s.push(' ');
208                    s.push_str(tag);
209                }
210                s.push(']');
211            }
212            if show_time {
213                s.push(' ');
214                s.push_str(verb);
215                let delta = Local::now() - *start;
216                s.push(' ');
217                s.push_str(&f(&delta));
218            }
219            s
220        } else {
221            panic!("WatsonState::Idle does not have a specified format")
222        }
223    }
224}
225
226pub fn deserialize_local_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
227where
228    D: Deserializer<'de>,
229{
230    use chrono::TimeZone as _;
231    i64::deserialize(deserializer).map(|seconds| Local.timestamp_opt(seconds, 0).single().unwrap())
232}