Skip to main content

i3status_rs/blocks/
custom.rs

1//! The output of a custom shell command
2//!
3//! For further customisation, use the `json` option and have the shell command output valid JSON in the schema below:
4//! ```json
5//! {"icon": "...", "state": "...", "text": "...", "short_text": "..."}
6//! ```
7//! `icon` is optional (default "")
8//! `state` is optional, it may be Idle, Info, Good, Warning, Critical (default Idle)
9//! `short_text` is optional.
10//!
11//! # Configuration
12//!
13//! Key | Values | Default
14//! ----|--------|--------
15//! `format` | A string to customise the output of this block. See below for available placeholders. | <code>\"{ $icon\|} $text.pango-str() \"</code>
16//! `command` | Shell command to execute & display | `None`
17//! `persistent` | Run command in the background; update display for each output line of the command | `false`
18//! `cycle` | Commands to execute and change when the button is clicked | `None`
19//! `interval` | Update interval in seconds (or "once" to update only once) | `10`
20//! `json` | Use JSON from command output to format the block. If the JSON is not valid, the block will error out. | `false`
21//! `watch_files` | Watch files to trigger update on file modification. Supports path expansions e.g. `~`. | `None`
22//! `hide_when_empty` | Hides the block when the command output (or json text field) is empty | `false`
23//! `shell` | Specify the shell to use when running commands | `$SHELL` if set, otherwise fallback to `sh`
24//!
25//! Placeholder      | Value                                                      | Type   | Unit
26//! -----------------|------------------------------------------------------------|--------|---------------
27//! `icon`           | Value of icon field from JSON output when it's non-empty   | Icon   | -
28//! `text`           | Output of the script or text field from JSON output        | Text   |
29//! `short_text`     | short_text field from JSON output                          | Text   |
30//!
31//! Action  | Default button
32//! --------|---------------
33//! `cycle` | Left
34//!
35//! # Examples
36//!
37//! Display temperature, update every 10 seconds:
38//!
39//! ```toml
40//! [[block]]
41//! block = "custom"
42//! command = ''' cat /sys/class/thermal/thermal_zone0/temp | awk '{printf("%.1f\n",$1/1000)}' '''
43//! ```
44//!
45//! Cycle between "ON" and "OFF", update every 1 second, run next cycle command when block is clicked:
46//!
47//! ```toml
48//! [[block]]
49//! block = "custom"
50//! cycle = ["echo ON", "echo OFF"]
51//! interval = 1
52//! [[block.click]]
53//! button = "left"
54//! action = "cycle"
55//! ```
56//!
57//! Use JSON output:
58//!
59//! ```toml
60//! [[block]]
61//! block = "custom"
62//! command = "echo '{\"icon\":\"weather_thunder\",\"state\":\"Critical\", \"text\": \"Danger!\"}'"
63//! json = true
64//! ```
65//!
66//! Display kernel, update the block only once:
67//!
68//! ```toml
69//! [[block]]
70//! block = "custom"
71//! command = "uname -r"
72//! interval = "once"
73//! ```
74//!
75//! Display the screen brightness on an intel machine and update this only when `pkill -SIGRTMIN+4 i3status-rs` is called:
76//!
77//! ```toml
78//! [[block]]
79//! block = "custom"
80//! command = ''' cat /sys/class/backlight/intel_backlight/brightness | awk '{print $1}' '''
81//! signal = 4
82//! interval = "once"
83//! ```
84//!
85//! Update block when one or more specified files are modified:
86//!
87//! ```toml
88//! [[block]]
89//! block = "custom"
90//! command = "cat custom_status"
91//! watch_files = ["custom_status"]
92//! interval = "once"
93//! ```
94//!
95//! # TODO:
96//! - Use `shellexpand`
97
98use super::prelude::*;
99use inotify::{Inotify, WatchMask};
100use std::process::Stdio;
101use tokio::io::{self, BufReader};
102use tokio::process::Command;
103
104#[derive(Deserialize, Debug, SmartDefault)]
105#[serde(deny_unknown_fields, default)]
106pub struct Config {
107    pub format: FormatConfig,
108    pub command: Option<String>,
109    pub persistent: bool,
110    pub cycle: Option<Vec<String>>,
111    #[default(10.into())]
112    pub interval: Seconds,
113    pub json: bool,
114    pub hide_when_empty: bool,
115    pub shell: Option<String>,
116    pub watch_files: Vec<ShellString>,
117}
118
119pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
120    let mut output = OutputPlan::new(
121        "main",
122        config.format.with_defaults(
123            "{ $icon|} $text.pango-str() ",
124            "{ $icon|} $short_text.pango-str() |",
125        )?,
126    );
127    // Only JSON output can carry an icon name; a plain-text command never
128    // sets one. When it can, any name is permitted and resolves through the
129    // normal icon set and override rules.
130    if config.json {
131        output = output.icon("icon", IconChoices::OpenResolvable);
132    }
133    BlockPlan::new(vec![output])
134}
135
136async fn update_bar(
137    stdout: &str,
138    hide_when_empty: bool,
139    json: bool,
140    api: &CommonApi,
141    output: &OutputHandle,
142) -> Result<()> {
143    let mut widget = output.new_widget();
144
145    let text_empty;
146
147    if json {
148        match serde_json::from_str::<Input>(stdout).error("Invalid JSON") {
149            Ok(input) => {
150                text_empty = input.text.is_empty();
151                widget.set_values(map! {
152                    "text" => Value::text(input.text),
153                    [if !input.icon.is_empty()] "icon" => Value::icon(input.icon),
154                    [if let Some(t) = input.short_text] "short_text" => Value::text(t)
155                });
156                widget.state = input.state;
157            }
158            Err(error) => return Err(error),
159        }
160    } else {
161        text_empty = stdout.is_empty();
162        widget.set_values(map!("text" => Value::text(stdout.into())));
163    }
164
165    if text_empty && hide_when_empty {
166        api.hide()
167    } else {
168        api.set_widget(widget)
169    }
170}
171
172pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
173    api.set_default_actions(&[(MouseButton::Left, None, "cycle")])?;
174
175    let output_main = plan.output("main")?;
176
177    let mut timer = config.interval.timer();
178
179    type FileStream = Pin<Box<dyn Stream<Item = io::Result<inotify::EventOwned>> + Send + Sync>>;
180    let mut file_updates: FileStream = match config.watch_files.as_slice() {
181        [] => Box::pin(futures::stream::pending()),
182        files => {
183            let notify = Inotify::init().error("Failed to start inotify")?;
184            let mut watches = notify.watches();
185            for file in files {
186                let file = file.expand()?;
187                watches
188                    .add(
189                        &*file,
190                        WatchMask::MODIFY
191                            | WatchMask::CLOSE_WRITE
192                            | WatchMask::DELETE
193                            | WatchMask::MOVE,
194                    )
195                    .error("Failed to add file watch")?;
196            }
197            Box::pin(
198                notify
199                    .into_event_stream([0; 1024])
200                    .error("Failed to create event stream")?,
201            )
202        }
203    };
204
205    let shell = config
206        .shell
207        .clone()
208        .or_else(|| std::env::var("SHELL").ok())
209        .unwrap_or_else(|| "sh".to_string());
210
211    if config.persistent {
212        let mut process = Command::new(&shell)
213            .args([
214                "-c",
215                config
216                    .command
217                    .as_deref()
218                    .error("'command' must be specified when 'persistent' is set")?,
219            ])
220            .stdout(Stdio::piped())
221            .stdin(Stdio::null())
222            .kill_on_drop(true)
223            .spawn()
224            .error("failed to run command")?;
225
226        let stdout = process
227            .stdout
228            .take()
229            .expect("child did not have a handle to stdout");
230        let mut reader = BufReader::new(stdout).lines();
231
232        tokio::spawn(async move {
233            let _ = process.wait().await;
234        });
235
236        loop {
237            let line = reader
238                .next_line()
239                .await
240                .error("error reading line from child process")?
241                .error("child process exited unexpectedly")?;
242            update_bar(
243                &line,
244                config.hide_when_empty,
245                config.json,
246                api,
247                &output_main,
248            )
249            .await?;
250        }
251    } else {
252        let mut actions = api.get_actions()?;
253
254        let mut cycle = config
255            .cycle
256            .clone()
257            .or_else(|| config.command.clone().map(|cmd| vec![cmd]))
258            .error("either 'command' or 'cycle' must be specified")?
259            .into_iter()
260            .cycle();
261        let mut cmd = cycle.next().unwrap();
262
263        loop {
264            // Run command
265            let output = Command::new(&shell)
266                .args(["-c", &cmd])
267                .stdin(Stdio::null())
268                .output()
269                .await
270                .error("failed to run command")?;
271            let stdout = std::str::from_utf8(&output.stdout)
272                .error("the output of command is invalid UTF-8")?
273                .trim();
274
275            update_bar(
276                stdout,
277                config.hide_when_empty,
278                config.json,
279                api,
280                &output_main,
281            )
282            .await?;
283
284            loop {
285                select! {
286                    _ = timer.tick() => break,
287                    _ = file_updates.next_debounced() => break,
288                    _ = api.wait_for_update_request() => break,
289                    Some(action) = actions.recv() => match action.as_ref() {
290                        "cycle" => {
291                            cmd = cycle.next().unwrap();
292                            break;
293                        }
294                        _ => (),
295                    }
296                }
297            }
298        }
299    }
300}
301
302#[derive(Deserialize, Debug, Default)]
303#[serde(default)]
304struct Input {
305    icon: String,
306    state: State,
307    text: String,
308    short_text: Option<String>,
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn icon_choices_are_open_only_with_json() {
317        // A plain-text command can never set an icon.
318        let plan = prepare(&Config::default()).unwrap();
319        let output = plan.output("main").unwrap();
320        assert!(output.output().choices_for("icon").is_none());
321
322        let config = Config {
323            json: true,
324            ..Config::default()
325        };
326        let plan = prepare(&config).unwrap();
327        let output = plan.output("main").unwrap();
328        let choices = output.output().choices_for("icon").unwrap();
329        assert!(matches!(choices, IconChoices::OpenResolvable));
330        assert!(choices.permits("any_name_the_command_emits"));
331    }
332}