i3status_rs/blocks/
xrandr.rs

1//! X11 screen information
2//!
3//! X11 screen information (name, brightness, resolution, refresh rate). With a click you can toggle through your active screens, and scrolling the wheel up/down, you can adjust the selected screens' brightness. Regarding brightness control, xrandr changes the brightness of the display using gamma rather than changing the brightness in hardware, so if that is not desirable, then consider using the `backlight` block instead.
4//!
5//! NOTE: Some users report issues (e.g. [here](https://github.com/greshake/i3status-rust/issues/274) and [here](https://github.com/greshake/i3status-rust/issues/668) when using this block. The cause is currently unknown, however, setting a higher update interval may help.
6//!
7//! # Configuration
8//!
9//! Key | Values | Default
10//! ----|--------|--------
11//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $display $brightness_icon $brightness "`
12//! `step_width` | The steps brightness is in/decreased for the selected screen (When greater than 50 it gets limited to 50). | `5`
13//! `interval` | Update interval in seconds. | `5`
14//! `invert_icons` | Invert icons' ordering, useful if you have colourful emoji | `false`
15//!
16//! Placeholder       | Value                        | Type   | Unit
17//! ------------------|------------------------------|--------|---------------
18//! `icon`            | A static icon                | Icon   | -
19//! `display`         | The monitor name             | Text   | -
20//! `brightness`      | The monitor brightness       | Number | %
21//! `brightness_icon` | A static icon                | Icon   | -
22//! `resolution`      | The monitor resolution       | Text   | -
23//! `res_icon`        | A static icon                | Icon   | -
24//! `refresh_rate`    | The monitor refresh rate     | Number | Hertz
25//!
26//! Action            | Default button
27//! ------------------|---------------
28//! `cycle_outputs`   | Left
29//! `brightness_up`   | Wheel Up
30//! `brightness_down` | Wheel Down
31//!
32//! # Example
33//!
34//! ```toml
35//! [[block]]
36//! block = "xrandr"
37//! format = " $icon $brightness $resolution $refresh_rate "
38//! ```
39//!
40//! # Used Icons
41//! - `xrandr`
42//! - `backlight`
43//! - `resolution`
44
45use super::prelude::*;
46use crate::subprocess::spawn_shell;
47use tokio::process::Command;
48
49#[derive(Deserialize, Debug, SmartDefault)]
50#[serde(deny_unknown_fields, default)]
51pub struct Config {
52    #[default(5.into())]
53    pub interval: Seconds,
54    pub format: FormatConfig,
55    #[default(5)]
56    pub step_width: u32,
57    pub invert_icons: bool,
58}
59
60pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
61    let mut actions = api.get_actions()?;
62    api.set_default_actions(&[
63        (MouseButton::Left, None, "cycle_outputs"),
64        (MouseButton::WheelUp, None, "brightness_up"),
65        (MouseButton::WheelDown, None, "brightness_down"),
66    ])?;
67
68    let format = config
69        .format
70        .with_default(" $icon $display $brightness_icon $brightness ")?;
71
72    let mut cur_index = 0;
73    let mut timer = config.interval.timer();
74
75    loop {
76        let mut monitors = get_monitors().await?;
77        if cur_index > monitors.len() {
78            cur_index = 0;
79        }
80
81        loop {
82            let mut widget = Widget::new().with_format(format.clone());
83
84            if let Some(mon) = monitors.get(cur_index) {
85                let mut icon_value = mon.brightness as f64;
86                if config.invert_icons {
87                    icon_value = 1.0 - icon_value;
88                }
89                widget.set_values(map! {
90                    "icon" => Value::icon("xrandr"),
91                    "display" => Value::text(mon.name.clone()),
92                    "brightness" => Value::percents(mon.brightness_percent()),
93                    "brightness_icon" => Value::icon_progression("backlight", icon_value),
94                    "resolution" => Value::text(mon.resolution()),
95                    "res_icon" => Value::icon("resolution"),
96                    "refresh_rate" => Value::hertz(mon.refresh_hz),
97                });
98            }
99            api.set_widget(widget)?;
100
101            select! {
102                _ = timer.tick() => break,
103                _ = api.wait_for_update_request() => break,
104                Some(action) = actions.recv() => match action.as_ref() {
105                    "cycle_outputs" => {
106                        cur_index = (cur_index + 1) % monitors.len();
107                    }
108                    "brightness_up" => {
109                        if let Some(monitor) = monitors.get_mut(cur_index) {
110                            let bright = (monitor.brightness_percent() + config.step_width).min(100);
111                            monitor.set_brightness_percent(bright)?;
112                        }
113                    }
114                    "brightness_down" => {
115                        if let Some(monitor) = monitors.get_mut(cur_index) {
116                            let bright = monitor.brightness_percent().saturating_sub(config.step_width);
117                            monitor.set_brightness_percent(bright)?;
118                        }
119                    }
120                    _ => (),
121                }
122            }
123        }
124    }
125}
126
127#[derive(Debug, PartialEq)]
128struct Monitor {
129    pub name: String,
130    pub width: u32,
131    pub height: u32,
132    pub x: i32,
133    pub y: i32,
134    pub brightness: f32,
135    pub refresh_hz: f64,
136}
137
138impl Monitor {
139    fn set_brightness_percent(&mut self, percent: u32) -> Result<()> {
140        let brightness = percent as f32 / 100.0;
141        spawn_shell(&format!(
142            "xrandr --output {} --brightness {}",
143            self.name, brightness
144        ))
145        .error(format!(
146            "Failed to set brightness {} for output {}",
147            brightness, self.name
148        ))?;
149        self.brightness = brightness;
150        Ok(())
151    }
152
153    #[inline]
154    fn resolution(&self) -> String {
155        format!("{}x{}", self.width, self.height)
156    }
157
158    #[inline]
159    fn brightness_percent(&self) -> u32 {
160        (self.brightness * 100.0) as u32
161    }
162}
163
164async fn get_monitors() -> Result<Vec<Monitor>> {
165    let monitors_info = Command::new("xrandr")
166        .arg("--verbose")
167        .output()
168        .await
169        .error("Failed to collect xrandr monitors info")?
170        .stdout;
171    let monitors_info =
172        String::from_utf8(monitors_info).error("xrandr produced non-UTF8 output")?;
173
174    Ok(parser::extract_outputs(&monitors_info))
175}
176
177mod parser {
178    use super::*;
179    use nom::branch::alt;
180    use nom::bytes::complete::{tag, take_until, take_while1};
181    use nom::character::complete::{i32, space0, space1, u32};
182    use nom::combinator::opt;
183    use nom::number::complete::{double, float};
184    use nom::sequence::preceded;
185    use nom::{IResult, Parser as _};
186
187    /// Parses an output name, e.g. "HDMI-0", "eDP-1", etc.
188    fn name(input: &str) -> IResult<&str, &str> {
189        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')(input)
190    }
191
192    /// Parses "1920x1080+0+0"
193    /// Returns (width, height, x, y)
194    fn parse_mode_position(input: &str) -> IResult<&str, (u32, u32, i32, i32)> {
195        let (input, width) = u32(input)?;
196        let (input, _) = tag("x")(input)?;
197        let (input, height) = u32(input)?;
198        let (input, _) = tag("+")(input)?;
199        let (input, x) = i32(input)?;
200        let (input, _) = tag("+")(input)?;
201        let (input, y) = i32(input)?;
202        Ok((input, (width, height, x, y)))
203    }
204
205    /// Parses "HDMI-1 connected primary 2560x1440+1920+0 ..."
206    /// Returns (name, width, height, x, y)
207    fn parse_output_header(input: &str) -> IResult<&str, (String, u32, u32, i32, i32)> {
208        let (input, name) = name(input)?;
209        let (input, _) = space1(input)?;
210        let (input, _) = alt((tag("connected"), tag("disconnected"))).parse(input)?;
211        let (input, _) = opt(preceded(space1, tag("primary"))).parse(input)?;
212        let (input, _) = space1(input)?;
213        let (input, (width, height, x, y)) = parse_mode_position(input)?;
214        Ok((input, (name.to_owned(), width, height, x, y)))
215    }
216
217    /// Parses "    Brightness: 1.0"
218    fn parse_brightness(input: &str) -> IResult<&str, f32> {
219        let (input, _) = space0(input)?;
220        let (input, _) = tag("Brightness: ")(input)?;
221        let (input, brightness) = float(input)?;
222        Ok((input, brightness))
223    }
224
225    /// Parses "    v: ... clock  74.97Hz"
226    fn parse_v_clock_hz(input: &str) -> IResult<&str, f64> {
227        let (input, _) = space0(input)?;
228        let (input, _) = tag("v:")(input)?;
229        let (input, _) = take_until("clock")(input)?;
230        let (input, _) = tag("clock")(input)?;
231        let (input, _) = space1(input)?;
232        let (input, hz) = double(input)?;
233        let (input, _) = tag("Hz")(input)?;
234        Ok((input, hz))
235    }
236
237    /// Returns `true` if this is the starting line for the current mode.
238    ///
239    /// Examples:
240    /// - "  2560x1440 (0x1d6) ... *current"
241    /// - "  2560x1440 (0x4b)  144.00*+ 120.00 ..."
242    #[inline]
243    fn is_current_mode(line: &str) -> bool {
244        line.starts_with("  ")
245            && (line.contains("*current") || (line.contains("(0x") && line.contains("*")))
246    }
247
248    /// Parse the outputs from `xrandr --verbose` output.
249    pub fn extract_outputs(input: &str) -> Vec<Monitor> {
250        let mut outputs = Vec::new();
251
252        let lines = input.lines().collect::<Vec<_>>();
253        let mut i = 0;
254        while i < lines.len() {
255            // Find header
256            let Ok((_, (name, width, height, x, y))) = parse_output_header(lines[i]) else {
257                i += 1;
258                continue;
259            };
260
261            // Scan for brightness/refresh_hz until the next header
262            let mut brightness = None;
263            let mut refresh_hz = None;
264
265            i += 1;
266            while i < lines.len() {
267                if parse_output_header(lines[i]).is_ok() {
268                    // found the next header
269                    break;
270                }
271
272                if brightness.is_none() {
273                    brightness = parse_brightness(lines[i]).ok().map(|(_, b)| b);
274                }
275
276                if refresh_hz.is_none() && is_current_mode(lines[i]) {
277                    // find the next v-clock line
278                    i += 1;
279                    while i < lines.len() {
280                        if parse_output_header(lines[i]).is_ok() {
281                            // found the next header
282                            i -= 1;
283                            break;
284                        }
285
286                        if let Ok((_, hz)) = parse_v_clock_hz(lines[i]) {
287                            refresh_hz = Some(hz);
288                            break;
289                        }
290
291                        i += 1;
292                    }
293                }
294
295                i += 1;
296            }
297
298            outputs.push(Monitor {
299                name,
300                width,
301                height,
302                x,
303                y,
304                brightness: brightness.unwrap_or_default(),
305                refresh_hz: refresh_hz.unwrap_or_default(),
306            });
307        }
308
309        outputs
310    }
311
312    #[cfg(test)]
313    mod tests {
314        use super::*;
315
316        #[test]
317        fn test_extract_outputs() {
318            let xrandr_output = include_str!("../../testdata/xrandr-verbose.txt");
319            let outputs = extract_outputs(xrandr_output);
320            assert_eq!(outputs.len(), 2);
321            assert_eq!(
322                outputs[0],
323                Monitor {
324                    name: "eDP-1".to_owned(),
325                    width: 1920,
326                    height: 1080,
327                    x: 0,
328                    y: 1080,
329                    brightness: 1.0,
330                    refresh_hz: 59.96,
331                }
332            );
333            assert_eq!(
334                outputs[1],
335                Monitor {
336                    name: "HDMI-1".to_owned(),
337                    width: 1920,
338                    height: 1080,
339                    x: 0,
340                    y: 0,
341                    brightness: 0.8,
342                    refresh_hz: 59.99,
343                }
344            );
345        }
346    }
347}