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}