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 crate::formatting::Format;
99
100use super::prelude::*;
101use inotify::{Inotify, WatchMask};
102use std::process::Stdio;
103use tokio::io::{self, BufReader};
104use tokio::process::Command;
105
106#[derive(Deserialize, Debug, SmartDefault)]
107#[serde(deny_unknown_fields, default)]
108pub struct Config {
109    pub format: FormatConfig,
110    pub command: Option<String>,
111    pub persistent: bool,
112    pub cycle: Option<Vec<String>>,
113    #[default(10.into())]
114    pub interval: Seconds,
115    pub json: bool,
116    pub hide_when_empty: bool,
117    pub shell: Option<String>,
118    pub watch_files: Vec<ShellString>,
119}
120
121async fn update_bar(
122    stdout: &str,
123    hide_when_empty: bool,
124    json: bool,
125    api: &CommonApi,
126    format: Format,
127) -> Result<()> {
128    let mut widget = Widget::new().with_format(format);
129
130    let text_empty;
131
132    if json {
133        match serde_json::from_str::<Input>(stdout).error("Invalid JSON") {
134            Ok(input) => {
135                text_empty = input.text.is_empty();
136                widget.set_values(map! {
137                    "text" => Value::text(input.text),
138                    [if !input.icon.is_empty()] "icon" => Value::icon(input.icon),
139                    [if let Some(t) = input.short_text] "short_text" => Value::text(t)
140                });
141                widget.state = input.state;
142            }
143            Err(error) => return api.set_error(error),
144        }
145    } else {
146        text_empty = stdout.is_empty();
147        widget.set_values(map!("text" => Value::text(stdout.into())));
148    }
149
150    if text_empty && hide_when_empty {
151        api.hide()
152    } else {
153        api.set_widget(widget)
154    }
155}
156
157pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
158    api.set_default_actions(&[(MouseButton::Left, None, "cycle")])?;
159
160    let format = config.format.with_defaults(
161        "{ $icon|} $text.pango-str() ",
162        "{ $icon|} $short_text.pango-str() |",
163    )?;
164
165    let mut timer = config.interval.timer();
166
167    type FileStream = Pin<Box<dyn Stream<Item = io::Result<inotify::EventOwned>> + Send + Sync>>;
168    let mut file_updates: FileStream = match config.watch_files.as_slice() {
169        [] => Box::pin(futures::stream::pending()),
170        files => {
171            let notify = Inotify::init().error("Failed to start inotify")?;
172            let mut watches = notify.watches();
173            for file in files {
174                let file = file.expand()?;
175                watches
176                    .add(
177                        &*file,
178                        WatchMask::MODIFY
179                            | WatchMask::CLOSE_WRITE
180                            | WatchMask::DELETE
181                            | WatchMask::MOVE,
182                    )
183                    .error("Failed to add file watch")?;
184            }
185            Box::pin(
186                notify
187                    .into_event_stream([0; 1024])
188                    .error("Failed to create event stream")?,
189            )
190        }
191    };
192
193    let shell = config
194        .shell
195        .clone()
196        .or_else(|| std::env::var("SHELL").ok())
197        .unwrap_or_else(|| "sh".to_string());
198
199    if config.persistent {
200        let mut process = Command::new(&shell)
201            .args([
202                "-c",
203                config
204                    .command
205                    .as_deref()
206                    .error("'command' must be specified when 'persistent' is set")?,
207            ])
208            .stdout(Stdio::piped())
209            .stdin(Stdio::null())
210            .kill_on_drop(true)
211            .spawn()
212            .error("failed to run command")?;
213
214        let stdout = process
215            .stdout
216            .take()
217            .expect("child did not have a handle to stdout");
218        let mut reader = BufReader::new(stdout).lines();
219
220        tokio::spawn(async move {
221            let _ = process.wait().await;
222        });
223
224        loop {
225            let line = reader
226                .next_line()
227                .await
228                .error("error reading line from child process")?
229                .error("child process exited unexpectedly")?;
230            update_bar(
231                &line,
232                config.hide_when_empty,
233                config.json,
234                api,
235                format.clone(),
236            )
237            .await?;
238        }
239    } else {
240        let mut actions = api.get_actions()?;
241
242        let mut cycle = config
243            .cycle
244            .clone()
245            .or_else(|| config.command.clone().map(|cmd| vec![cmd]))
246            .error("either 'command' or 'cycle' must be specified")?
247            .into_iter()
248            .cycle();
249        let mut cmd = cycle.next().unwrap();
250
251        loop {
252            // Run command
253            let output = Command::new(&shell)
254                .args(["-c", &cmd])
255                .stdin(Stdio::null())
256                .output()
257                .await
258                .error("failed to run command")?;
259            let stdout = std::str::from_utf8(&output.stdout)
260                .error("the output of command is invalid UTF-8")?
261                .trim();
262
263            update_bar(
264                stdout,
265                config.hide_when_empty,
266                config.json,
267                api,
268                format.clone(),
269            )
270            .await?;
271
272            loop {
273                select! {
274                    _ = timer.tick() => break,
275                    _ = file_updates.next() => break,
276                    _ = api.wait_for_update_request() => break,
277                    Some(action) = actions.recv() => match action.as_ref() {
278                        "cycle" => {
279                            cmd = cycle.next().unwrap();
280                            break;
281                        }
282                        _ => (),
283                    }
284                }
285            }
286        }
287    }
288}
289
290#[derive(Deserialize, Debug, Default)]
291#[serde(default)]
292struct Input {
293    icon: String,
294    state: State,
295    text: String,
296    short_text: Option<String>,
297}