1use std::io::{BufRead, BufReader, Read};
24use std::process::{Command, Stdio};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use std::thread;
28use std::time::{Duration, Instant};
29
30use serde::Deserialize;
31
32use crate::config::{BuiltinWidget, WidgetConfig};
33
34const MAX_OUTPUT: u64 = 256 * 1024;
36const STOP_POLL: Duration = Duration::from_millis(100);
38
39#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
41#[serde(default)]
42pub struct WidgetOutput {
43 pub rows: Vec<WidgetRow>,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
48#[serde(default)]
49pub struct WidgetRow {
50 pub label: String,
52 #[serde(alias = "text")]
54 pub value: String,
55 pub gauge: Option<f32>,
57 pub gauge_style: GaugeStyle,
59 pub level: Level,
61 pub heading: bool,
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum GaugeStyle {
69 #[default]
71 Heat,
72 Level,
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum Level {
80 #[default]
81 Normal,
82 Dim,
83 Ok,
84 Warn,
85 Bad,
86}
87
88pub fn parse_output(stdout: &str) -> WidgetOutput {
90 let trimmed = stdout.trim();
91 if trimmed.starts_with('{')
92 && let Ok(output) = serde_json::from_str::<WidgetOutput>(trimmed)
93 {
94 return output;
95 }
96 WidgetOutput {
97 rows: trimmed
98 .lines()
99 .map(|line| WidgetRow {
100 value: line.trim_end().to_owned(),
101 ..Default::default()
102 })
103 .collect(),
104 }
105}
106
107#[derive(Debug, Clone, Default)]
109pub struct WidgetState {
110 pub output: Option<WidgetOutput>,
112 pub error: Option<String>,
114 pub updated: Option<Instant>,
116}
117
118pub struct Widget {
120 pub name: String,
121 pub source: WidgetSource,
122}
123
124pub enum WidgetSource {
126 Clock,
128 Command {
130 runner: CommandRunner,
131 interval: Duration,
132 },
133 Fixed(WidgetOutput),
135 Invalid(String),
137}
138
139impl Widget {
140 pub fn state(&self) -> Option<WidgetState> {
142 match &self.source {
143 WidgetSource::Command { runner, .. } => Some(runner.state()),
144 WidgetSource::Fixed(output) => Some(WidgetState {
145 output: Some(output.clone()),
146 error: None,
147 updated: Some(Instant::now()),
148 }),
149 WidgetSource::Clock | WidgetSource::Invalid(_) => None,
150 }
151 }
152
153 pub fn is_stale(&self, state: &WidgetState) -> bool {
155 match (&self.source, state.updated) {
156 (WidgetSource::Command { interval, .. }, Some(at)) => {
157 at.elapsed() > *interval * 3 + Duration::from_secs(5)
158 }
159 _ => false,
160 }
161 }
162}
163
164pub fn start_widgets<F>(configs: &[WidgetConfig], notify: F) -> Vec<Widget>
167where
168 F: Fn() + Clone + Send + 'static,
169{
170 configs
171 .iter()
172 .map(|config| {
173 let source = match (&config.builtin, &config.command) {
174 (Some(BuiltinWidget::Clock), None) => WidgetSource::Clock,
175 (None, Some(command)) => {
176 let interval = Duration::from_secs(config.interval_secs.max(1));
177 WidgetSource::Command {
178 runner: CommandRunner::spawn(
179 command.clone(),
180 interval,
181 Duration::from_secs(config.timeout_secs.max(1)),
182 notify.clone(),
183 ),
184 interval,
185 }
186 }
187 (Some(_), Some(_)) => {
188 WidgetSource::Invalid("set either `command` or `builtin`, not both".to_owned())
189 }
190 (None, None) => WidgetSource::Invalid("set `command` or `builtin`".to_owned()),
191 };
192 Widget {
193 name: config.name.clone(),
194 source,
195 }
196 })
197 .collect()
198}
199
200pub struct CommandRunner {
203 state: Arc<Mutex<WidgetState>>,
204 stop: Arc<AtomicBool>,
205}
206
207impl CommandRunner {
208 pub fn spawn<F>(command: String, interval: Duration, timeout: Duration, notify: F) -> Self
209 where
210 F: Fn() + Send + 'static,
211 {
212 let state = Arc::new(Mutex::new(WidgetState::default()));
213 let stop = Arc::new(AtomicBool::new(false));
214 let (shared, stopping) = (Arc::clone(&state), Arc::clone(&stop));
215 let spawned = thread::Builder::new()
216 .name("rigtop-widget".to_owned())
217 .spawn(move || {
218 while !stopping.load(Ordering::Relaxed) {
219 let result = run_command(&command, timeout);
220 {
221 let mut state = shared.lock().unwrap_or_else(|e| e.into_inner());
222 match result {
223 Ok(stdout) => {
224 state.output = Some(parse_output(&stdout));
225 state.error = None;
226 }
227 Err(error) => state.error = Some(error),
228 }
229 state.updated = Some(Instant::now());
230 }
231 notify();
232 let wake = Instant::now() + interval;
233 while Instant::now() < wake && !stopping.load(Ordering::Relaxed) {
234 thread::sleep(STOP_POLL);
235 }
236 }
237 });
238 if let Err(err) = spawned {
239 state.lock().unwrap_or_else(|e| e.into_inner()).error =
240 Some(format!("cannot start a thread: {err}"));
241 }
242 Self { state, stop }
243 }
244
245 pub fn state(&self) -> WidgetState {
246 self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
247 }
248}
249
250impl Drop for CommandRunner {
251 fn drop(&mut self) {
252 self.stop.store(true, Ordering::Relaxed);
253 }
254}
255
256pub fn run_command(command: &str, timeout: Duration) -> Result<String, String> {
259 let mut process = if cfg!(windows) {
260 let mut process = Command::new("cmd");
261 process.args(["/C", command]);
262 process
263 } else {
264 let mut process = Command::new("sh");
265 process.args(["-c", command]);
266 process
267 };
268 process
269 .stdin(Stdio::null())
270 .stdout(Stdio::piped())
271 .stderr(Stdio::piped());
272 #[cfg(windows)]
273 {
274 use std::os::windows::process::CommandExt;
275 process.creation_flags(0x0800_0000);
277 }
278 let mut child = process
279 .spawn()
280 .map_err(|err| format!("cannot run: {err}"))?;
281 let stdout = child.stdout.take().expect("piped stdout");
282 let stderr = child.stderr.take().expect("piped stderr");
283 let out = thread::spawn(move || {
285 let mut text = String::new();
286 let _ = stdout.take(MAX_OUTPUT).read_to_string(&mut text);
287 text
288 });
289 let err = thread::spawn(move || {
290 BufReader::new(stderr.take(MAX_OUTPUT))
291 .lines()
292 .map_while(Result::ok)
293 .filter(|l| !l.trim().is_empty())
294 .last()
295 });
296 let deadline = Instant::now() + timeout;
297 let status = loop {
298 match child.try_wait() {
299 Ok(Some(status)) => break status,
300 Ok(None) if Instant::now() >= deadline => {
301 let _ = child.kill();
302 let _ = child.wait();
303 return Err(format!("timed out after {}s", timeout.as_secs()));
304 }
305 Ok(None) => thread::sleep(Duration::from_millis(20)),
306 Err(err) => return Err(err.to_string()),
307 }
308 };
309 let stdout = out.join().unwrap_or_default();
310 let last_error = err.join().ok().flatten();
311 if status.success() {
312 Ok(stdout)
313 } else {
314 Err(last_error.unwrap_or_else(|| format!("exited with {status}")))
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn parses_json_and_plain_text() {
324 let json = r#"{"rows":[
325 {"label":"5h","value":"42%","gauge":0.42,"level":"ok"},
326 {"text":"rigtop","heading":true},
327 {}
328 ]}"#;
329 let output = parse_output(json);
330 assert_eq!(output.rows.len(), 3);
331 assert_eq!(output.rows[0].gauge, Some(0.42));
332 assert_eq!(output.rows[0].level, Level::Ok);
333 assert_eq!(output.rows[0].gauge_style, GaugeStyle::Heat, "default");
334 let left = parse_output(r#"{"rows":[{"gauge":0.8,"gauge_style":"level"}]}"#);
335 assert_eq!(left.rows[0].gauge_style, GaugeStyle::Level);
336 assert_eq!(output.rows[1].value, "rigtop", "`text` is `value`");
337 assert!(output.rows[1].heading);
338
339 let text = parse_output("USD/JPY 151.2\nEUR/JPY 163.9\n\n");
340 assert_eq!(text.rows.len(), 2);
341 assert_eq!(text.rows[1].value, "EUR/JPY 163.9");
342 assert_eq!(parse_output("{oops").rows[0].value, "{oops");
344 }
345
346 #[cfg(unix)]
347 #[test]
348 fn runs_commands_with_errors_and_timeouts() {
349 let second = Duration::from_secs(1);
350 assert_eq!(run_command("echo hello", second).unwrap().trim(), "hello");
351 assert_eq!(
352 run_command("echo first >&2; echo 'no data' >&2; exit 3", second),
353 Err("no data".to_owned())
354 );
355 assert!(run_command("exit 4", second).unwrap_err().contains("exit"));
356 let started = Instant::now();
357 assert!(
358 run_command("sleep 5", second)
359 .unwrap_err()
360 .contains("timed out")
361 );
362 assert!(started.elapsed() < Duration::from_secs(3));
363 }
364
365 #[cfg(unix)]
366 #[test]
367 fn widgets_from_configuration() {
368 let config =
369 |name: &str, command: Option<&str>, builtin: Option<BuiltinWidget>| WidgetConfig {
370 name: name.to_owned(),
371 command: command.map(str::to_owned),
372 builtin,
373 ..Default::default()
374 };
375 let (tx, rx) = std::sync::mpsc::channel();
376 let notify = move || {
377 let _ = tx.send(());
378 };
379 let widgets = start_widgets(
380 &[
381 config("Clock", None, Some(BuiltinWidget::Clock)),
382 config("Echo", Some(r#"echo '{"rows":[{"value":"hi"}]}'"#), None),
383 config("Empty", None, None),
384 ],
385 notify,
386 );
387 assert!(matches!(widgets[0].source, WidgetSource::Clock));
388 assert!(matches!(widgets[2].source, WidgetSource::Invalid(_)));
389 rx.recv_timeout(Duration::from_secs(5)).expect("first run");
390 let state = widgets[1].state().unwrap();
391 assert_eq!(state.output.unwrap().rows[0].value, "hi");
392 assert!(state.error.is_none());
393 }
394}