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(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 widget.state = State::Idle;
118 widget.set_values(map!(
119 "text" => Value::text(prev.format(true, "stopped", format_delta_after))
120 ));
121 } else {
122 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 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}