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}