i3status_rs/blocks/
watson.rs1use 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 widget.state = State::Idle;
111 widget.set_values(map!(
112 "text" => Value::text(prev.format(true, "stopped", format_delta_after))
113 ));
114 } else {
115 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 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}