Skip to main content

rigtop_core/
widget.rs

1//! Widgets: small panels shown beside the monitor, for whatever else someone
2//! wants on the same screen. A widget is either built in (a large clock) or
3//! fed by any command, which rigtop runs periodically and whose output it
4//! renders.
5//!
6//! A command prints either plain text (one row per line) or JSON:
7//!
8//! ```json
9//! {"rows": [
10//!   {"label": "5h", "value": "42% ยท resets 14:30", "gauge": 0.42, "level": "ok"},
11//!   {"value": "rigtop", "heading": true},
12//!   {"label": "busy", "value": "2 subagents", "level": "warn"}
13//! ]}
14//! ```
15//!
16//! Every field is optional; `text` is accepted for `value`. `level` is one
17//! of `normal`, `dim`, `ok`, `warn`, `bad`; `gauge` is a fraction (0-1)
18//! drawn as a bar. Commands run on the machine that displays them, through
19//! the shell (`sh -c`, or `cmd /C` on Windows).
20
21use std::io::{BufRead, BufReader, Read};
22use std::process::{Command, Stdio};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex};
25use std::thread;
26use std::time::{Duration, Instant};
27
28use serde::Deserialize;
29
30use crate::config::{BuiltinWidget, WidgetConfig};
31
32/// Upper bound for a command's output.
33const MAX_OUTPUT: u64 = 256 * 1024;
34/// How often a sleeping runner checks whether it should stop.
35const STOP_POLL: Duration = Duration::from_millis(100);
36
37/// What a command reported.
38#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
39#[serde(default)]
40pub struct WidgetOutput {
41    pub rows: Vec<WidgetRow>,
42}
43
44/// One line of a widget.
45#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
46#[serde(default)]
47pub struct WidgetRow {
48    /// Short name on the left (may be empty).
49    pub label: String,
50    /// The text.
51    #[serde(alias = "text")]
52    pub value: String,
53    /// A bar for a fraction (0-1), e.g. a quota used.
54    pub gauge: Option<f32>,
55    /// Color of the value.
56    pub level: Level,
57    /// A section title (e.g. a project name) rather than a reading.
58    pub heading: bool,
59}
60
61/// How a row is colored.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Level {
65    #[default]
66    Normal,
67    Dim,
68    Ok,
69    Warn,
70    Bad,
71}
72
73/// JSON if the output looks like it, else one row per line.
74pub fn parse_output(stdout: &str) -> WidgetOutput {
75    let trimmed = stdout.trim();
76    if trimmed.starts_with('{')
77        && let Ok(output) = serde_json::from_str::<WidgetOutput>(trimmed)
78    {
79        return output;
80    }
81    WidgetOutput {
82        rows: trimmed
83            .lines()
84            .map(|line| WidgetRow {
85                value: line.trim_end().to_owned(),
86                ..Default::default()
87            })
88            .collect(),
89    }
90}
91
92/// The latest result of a command widget.
93#[derive(Debug, Clone, Default)]
94pub struct WidgetState {
95    /// The last successful output, kept while later runs fail.
96    pub output: Option<WidgetOutput>,
97    /// Why the last run failed.
98    pub error: Option<String>,
99    /// When the last run finished.
100    pub updated: Option<Instant>,
101}
102
103/// A configured widget, ready to draw.
104pub struct Widget {
105    pub name: String,
106    pub source: WidgetSource,
107}
108
109/// Where a widget's content comes from.
110pub enum WidgetSource {
111    /// A large clock, drawn by the front-end.
112    Clock,
113    /// A command run in the background.
114    Command {
115        runner: CommandRunner,
116        interval: Duration,
117    },
118    /// Fixed content (tests, previews).
119    Fixed(WidgetOutput),
120    /// A configuration mistake to show in place of the widget.
121    Invalid(String),
122}
123
124impl Widget {
125    /// The command's latest state, or the fixed content.
126    pub fn state(&self) -> Option<WidgetState> {
127        match &self.source {
128            WidgetSource::Command { runner, .. } => Some(runner.state()),
129            WidgetSource::Fixed(output) => Some(WidgetState {
130                output: Some(output.clone()),
131                error: None,
132                updated: Some(Instant::now()),
133            }),
134            WidgetSource::Clock | WidgetSource::Invalid(_) => None,
135        }
136    }
137
138    /// Whether the last update is older than three intervals.
139    pub fn is_stale(&self, state: &WidgetState) -> bool {
140        match (&self.source, state.updated) {
141            (WidgetSource::Command { interval, .. }, Some(at)) => {
142                at.elapsed() > *interval * 3 + Duration::from_secs(5)
143            }
144            _ => false,
145        }
146    }
147}
148
149/// Starts the widgets of the configuration; `notify` is called whenever a
150/// command produced new content (to repaint).
151pub fn start_widgets<F>(configs: &[WidgetConfig], notify: F) -> Vec<Widget>
152where
153    F: Fn() + Clone + Send + 'static,
154{
155    configs
156        .iter()
157        .map(|config| {
158            let source = match (&config.builtin, &config.command) {
159                (Some(BuiltinWidget::Clock), None) => WidgetSource::Clock,
160                (None, Some(command)) => {
161                    let interval = Duration::from_secs(config.interval_secs.max(1));
162                    WidgetSource::Command {
163                        runner: CommandRunner::spawn(
164                            command.clone(),
165                            interval,
166                            Duration::from_secs(config.timeout_secs.max(1)),
167                            notify.clone(),
168                        ),
169                        interval,
170                    }
171                }
172                (Some(_), Some(_)) => {
173                    WidgetSource::Invalid("set either `command` or `builtin`, not both".to_owned())
174                }
175                (None, None) => WidgetSource::Invalid("set `command` or `builtin`".to_owned()),
176            };
177            Widget {
178                name: config.name.clone(),
179                source,
180            }
181        })
182        .collect()
183}
184
185/// Runs a command every interval on its own thread. Dropping it stops the
186/// thread after the current run (without waiting for it).
187pub struct CommandRunner {
188    state: Arc<Mutex<WidgetState>>,
189    stop: Arc<AtomicBool>,
190}
191
192impl CommandRunner {
193    pub fn spawn<F>(command: String, interval: Duration, timeout: Duration, notify: F) -> Self
194    where
195        F: Fn() + Send + 'static,
196    {
197        let state = Arc::new(Mutex::new(WidgetState::default()));
198        let stop = Arc::new(AtomicBool::new(false));
199        let (shared, stopping) = (Arc::clone(&state), Arc::clone(&stop));
200        let spawned = thread::Builder::new()
201            .name("rigtop-widget".to_owned())
202            .spawn(move || {
203                while !stopping.load(Ordering::Relaxed) {
204                    let result = run_command(&command, timeout);
205                    {
206                        let mut state = shared.lock().unwrap_or_else(|e| e.into_inner());
207                        match result {
208                            Ok(stdout) => {
209                                state.output = Some(parse_output(&stdout));
210                                state.error = None;
211                            }
212                            Err(error) => state.error = Some(error),
213                        }
214                        state.updated = Some(Instant::now());
215                    }
216                    notify();
217                    let wake = Instant::now() + interval;
218                    while Instant::now() < wake && !stopping.load(Ordering::Relaxed) {
219                        thread::sleep(STOP_POLL);
220                    }
221                }
222            });
223        if let Err(err) = spawned {
224            state.lock().unwrap_or_else(|e| e.into_inner()).error =
225                Some(format!("cannot start a thread: {err}"));
226        }
227        Self { state, stop }
228    }
229
230    pub fn state(&self) -> WidgetState {
231        self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
232    }
233}
234
235impl Drop for CommandRunner {
236    fn drop(&mut self) {
237        self.stop.store(true, Ordering::Relaxed);
238    }
239}
240
241/// Runs `command` through the shell, killing it after `timeout`. Returns its
242/// output, or why it failed (the last line of its stderr if any).
243pub fn run_command(command: &str, timeout: Duration) -> Result<String, String> {
244    let mut process = if cfg!(windows) {
245        let mut process = Command::new("cmd");
246        process.args(["/C", command]);
247        process
248    } else {
249        let mut process = Command::new("sh");
250        process.args(["-c", command]);
251        process
252    };
253    process
254        .stdin(Stdio::null())
255        .stdout(Stdio::piped())
256        .stderr(Stdio::piped());
257    #[cfg(windows)]
258    {
259        use std::os::windows::process::CommandExt;
260        // CREATE_NO_WINDOW: no console flashing up from the GUI.
261        process.creation_flags(0x0800_0000);
262    }
263    let mut child = process
264        .spawn()
265        .map_err(|err| format!("cannot run: {err}"))?;
266    let stdout = child.stdout.take().expect("piped stdout");
267    let stderr = child.stderr.take().expect("piped stderr");
268    // Read both pipes on threads so a chatty command cannot block on them.
269    let out = thread::spawn(move || {
270        let mut text = String::new();
271        let _ = stdout.take(MAX_OUTPUT).read_to_string(&mut text);
272        text
273    });
274    let err = thread::spawn(move || {
275        BufReader::new(stderr.take(MAX_OUTPUT))
276            .lines()
277            .map_while(Result::ok)
278            .filter(|l| !l.trim().is_empty())
279            .last()
280    });
281    let deadline = Instant::now() + timeout;
282    let status = loop {
283        match child.try_wait() {
284            Ok(Some(status)) => break status,
285            Ok(None) if Instant::now() >= deadline => {
286                let _ = child.kill();
287                let _ = child.wait();
288                return Err(format!("timed out after {}s", timeout.as_secs()));
289            }
290            Ok(None) => thread::sleep(Duration::from_millis(20)),
291            Err(err) => return Err(err.to_string()),
292        }
293    };
294    let stdout = out.join().unwrap_or_default();
295    let last_error = err.join().ok().flatten();
296    if status.success() {
297        Ok(stdout)
298    } else {
299        Err(last_error.unwrap_or_else(|| format!("exited with {status}")))
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn parses_json_and_plain_text() {
309        let json = r#"{"rows":[
310            {"label":"5h","value":"42%","gauge":0.42,"level":"ok"},
311            {"text":"rigtop","heading":true},
312            {}
313        ]}"#;
314        let output = parse_output(json);
315        assert_eq!(output.rows.len(), 3);
316        assert_eq!(output.rows[0].gauge, Some(0.42));
317        assert_eq!(output.rows[0].level, Level::Ok);
318        assert_eq!(output.rows[1].value, "rigtop", "`text` is `value`");
319        assert!(output.rows[1].heading);
320
321        let text = parse_output("USD/JPY 151.2\nEUR/JPY 163.9\n\n");
322        assert_eq!(text.rows.len(), 2);
323        assert_eq!(text.rows[1].value, "EUR/JPY 163.9");
324        // Not valid JSON: shown as text rather than lost.
325        assert_eq!(parse_output("{oops").rows[0].value, "{oops");
326    }
327
328    #[cfg(unix)]
329    #[test]
330    fn runs_commands_with_errors_and_timeouts() {
331        let second = Duration::from_secs(1);
332        assert_eq!(run_command("echo hello", second).unwrap().trim(), "hello");
333        assert_eq!(
334            run_command("echo first >&2; echo 'no data' >&2; exit 3", second),
335            Err("no data".to_owned())
336        );
337        assert!(run_command("exit 4", second).unwrap_err().contains("exit"));
338        let started = Instant::now();
339        assert!(
340            run_command("sleep 5", second)
341                .unwrap_err()
342                .contains("timed out")
343        );
344        assert!(started.elapsed() < Duration::from_secs(3));
345    }
346
347    #[cfg(unix)]
348    #[test]
349    fn widgets_from_configuration() {
350        let config =
351            |name: &str, command: Option<&str>, builtin: Option<BuiltinWidget>| WidgetConfig {
352                name: name.to_owned(),
353                command: command.map(str::to_owned),
354                builtin,
355                ..Default::default()
356            };
357        let (tx, rx) = std::sync::mpsc::channel();
358        let notify = move || {
359            let _ = tx.send(());
360        };
361        let widgets = start_widgets(
362            &[
363                config("Clock", None, Some(BuiltinWidget::Clock)),
364                config("Echo", Some(r#"echo '{"rows":[{"value":"hi"}]}'"#), None),
365                config("Empty", None, None),
366            ],
367            notify,
368        );
369        assert!(matches!(widgets[0].source, WidgetSource::Clock));
370        assert!(matches!(widgets[2].source, WidgetSource::Invalid(_)));
371        rx.recv_timeout(Duration::from_secs(5)).expect("first run");
372        let state = widgets[1].state().unwrap();
373        assert_eq!(state.output.unwrap().rows[0].value, "hi");
374        assert!(state.error.is_none());
375    }
376}