Skip to main content

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(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
61    BlockPlan::new(vec![
62        OutputPlan::new(
63            "main",
64            config
65                .format
66                .with_default(" $icon $display $brightness_icon $brightness ")?,
67        )
68        .icon("icon", IconChoices::one(icons::XRANDR))
69        .icon("brightness_icon", IconChoices::one(icons::BACKLIGHT))
70        .icon("res_icon", IconChoices::one(icons::RESOLUTION)),
71    ])
72}
73
74pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
75    let mut actions = api.get_actions()?;
76    api.set_default_actions(&[
77        (MouseButton::Left, None, "cycle_outputs"),
78        (MouseButton::WheelUp, None, "brightness_up"),
79        (MouseButton::WheelDown, None, "brightness_down"),
80    ])?;
81
82    let output_main = plan.output("main")?;
83
84    let mut cur_index = 0;
85    let mut timer = config.interval.timer();
86
87    loop {
88        let mut monitors = get_monitors().await?;
89        if cur_index > monitors.len() {
90            cur_index = 0;
91        }
92
93        loop {
94            let mut widget = output_main.new_widget();
95
96            if let Some(mon) = monitors.get(cur_index) {
97                let mut icon_value = mon.brightness as f64;
98                if config.invert_icons {
99                    icon_value = 1.0 - icon_value;
100                }
101                widget.set_values(map! {
102                    "icon" => Value::icon(icons::XRANDR),
103                    "display" => Value::text(mon.name.clone()),
104                    "brightness" => Value::percents(mon.brightness_percent()),
105                    // xrandr gamma "brightness" can exceed 1.0; clamp into the
106                    // progression's 0..=1 range.
107                    "brightness_icon" => Value::icon_progression_bound(
108                        icons::BACKLIGHT,
109                        icon_value,
110                        0.0,
111                        1.0),
112                    "resolution" => Value::text(mon.resolution()),
113                    "res_icon" => Value::icon(icons::RESOLUTION),
114                    "refresh_rate" => Value::hertz(mon.refresh_hz),
115                });
116            }
117            api.set_widget(widget)?;
118
119            select! {
120                _ = timer.tick() => break,
121                _ = api.wait_for_update_request() => break,
122                Some(action) = actions.recv() => match action.as_ref() {
123                    "cycle_outputs" => {
124                        cur_index = (cur_index + 1) % monitors.len();
125                    }
126                    "brightness_up" => {
127                        if let Some(monitor) = monitors.get_mut(cur_index) {
128                            let bright = (monitor.brightness_percent() + config.step_width).min(100);
129                            monitor.set_brightness_percent(bright)?;
130                        }
131                    }
132                    "brightness_down" => {
133                        if let Some(monitor) = monitors.get_mut(cur_index) {
134                            let bright = monitor.brightness_percent().saturating_sub(config.step_width);
135                            monitor.set_brightness_percent(bright)?;
136                        }
137                    }
138                    _ => (),
139                }
140            }
141        }
142    }
143}
144
145#[derive(Debug, PartialEq)]
146struct Monitor {
147    pub name: String,
148    pub width: u32,
149    pub height: u32,
150    pub x: i32,
151    pub y: i32,
152    pub brightness: f32,
153    pub refresh_hz: f64,
154}
155
156impl Monitor {
157    fn set_brightness_percent(&mut self, percent: u32) -> Result<()> {
158        let brightness = percent as f32 / 100.0;
159        spawn_shell(&format!(
160            "xrandr --output {} --brightness {}",
161            self.name, brightness
162        ))
163        .error(format!(
164            "Failed to set brightness {} for output {}",
165            brightness, self.name
166        ))?;
167        self.brightness = brightness;
168        Ok(())
169    }
170
171    #[inline]
172    fn resolution(&self) -> String {
173        format!("{}x{}", self.width, self.height)
174    }
175
176    #[inline]
177    fn brightness_percent(&self) -> u32 {
178        (self.brightness * 100.0) as u32
179    }
180}
181
182async fn get_monitors() -> Result<Vec<Monitor>> {
183    let monitors_info = Command::new("xrandr")
184        .arg("--verbose")
185        .output()
186        .await
187        .error("Failed to collect xrandr monitors info")?
188        .stdout;
189    let monitors_info =
190        String::from_utf8(monitors_info).error("xrandr produced non-UTF8 output")?;
191
192    Ok(parser::extract_outputs(&monitors_info))
193}
194
195mod parser {
196    use super::*;
197    use nom::branch::alt;
198    use nom::bytes::complete::{tag, take_until, take_while1};
199    use nom::character::complete::{i32, space0, space1, u32};
200    use nom::combinator::opt;
201    use nom::number::complete::{double, float};
202    use nom::sequence::preceded;
203    use nom::{IResult, Parser as _};
204
205    /// Parses an output name, e.g. "HDMI-0", "eDP-1", etc.
206    fn name(input: &str) -> IResult<&str, &str> {
207        take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')(input)
208    }
209
210    /// Parses "1920x1080+0+0"
211    /// Returns (width, height, x, y)
212    fn parse_mode_position(input: &str) -> IResult<&str, (u32, u32, i32, i32)> {
213        let (input, width) = u32(input)?;
214        let (input, _) = tag("x")(input)?;
215        let (input, height) = u32(input)?;
216        let (input, _) = tag("+")(input)?;
217        let (input, x) = i32(input)?;
218        let (input, _) = tag("+")(input)?;
219        let (input, y) = i32(input)?;
220        Ok((input, (width, height, x, y)))
221    }
222
223    /// Parses "HDMI-1 connected primary 2560x1440+1920+0 ..."
224    /// Returns (name, width, height, x, y)
225    fn parse_output_header(input: &str) -> IResult<&str, (String, u32, u32, i32, i32)> {
226        let (input, name) = name(input)?;
227        let (input, _) = space1(input)?;
228        let (input, _) = alt((tag("connected"), tag("disconnected"))).parse(input)?;
229        let (input, _) = opt(preceded(space1, tag("primary"))).parse(input)?;
230        let (input, _) = space1(input)?;
231        let (input, (width, height, x, y)) = parse_mode_position(input)?;
232        Ok((input, (name.to_owned(), width, height, x, y)))
233    }
234
235    /// Parses "    Brightness: 1.0"
236    fn parse_brightness(input: &str) -> IResult<&str, f32> {
237        let (input, _) = space0(input)?;
238        let (input, _) = tag("Brightness: ")(input)?;
239        let (input, brightness) = float(input)?;
240        Ok((input, brightness))
241    }
242
243    /// Parses "    v: ... clock  74.97Hz"
244    fn parse_v_clock_hz(input: &str) -> IResult<&str, f64> {
245        let (input, _) = space0(input)?;
246        let (input, _) = tag("v:")(input)?;
247        let (input, _) = take_until("clock")(input)?;
248        let (input, _) = tag("clock")(input)?;
249        let (input, _) = space1(input)?;
250        let (input, hz) = double(input)?;
251        let (input, _) = tag("Hz")(input)?;
252        Ok((input, hz))
253    }
254
255    /// Returns `true` if this is the starting line for the current mode.
256    ///
257    /// Examples:
258    /// - "  2560x1440 (0x1d6) ... *current"
259    /// - "  2560x1440 (0x4b)  144.00*+ 120.00 ..."
260    #[inline]
261    fn is_current_mode(line: &str) -> bool {
262        line.starts_with("  ")
263            && (line.contains("*current") || (line.contains("(0x") && line.contains("*")))
264    }
265
266    /// Parse the outputs from `xrandr --verbose` output.
267    pub fn extract_outputs(input: &str) -> Vec<Monitor> {
268        let mut outputs = Vec::new();
269
270        let lines = input.lines().collect::<Vec<_>>();
271        let mut i = 0;
272        while i < lines.len() {
273            // Find header
274            let Ok((_, (name, width, height, x, y))) = parse_output_header(lines[i]) else {
275                i += 1;
276                continue;
277            };
278
279            // Scan for brightness/refresh_hz until the next header
280            let mut brightness = None;
281            let mut refresh_hz = None;
282
283            i += 1;
284            while i < lines.len() {
285                if parse_output_header(lines[i]).is_ok() {
286                    // found the next header
287                    break;
288                }
289
290                if brightness.is_none() {
291                    brightness = parse_brightness(lines[i]).ok().map(|(_, b)| b);
292                }
293
294                if refresh_hz.is_none() && is_current_mode(lines[i]) {
295                    // find the next v-clock line
296                    i += 1;
297                    while i < lines.len() {
298                        if parse_output_header(lines[i]).is_ok() {
299                            // found the next header
300                            i -= 1;
301                            break;
302                        }
303
304                        if let Ok((_, hz)) = parse_v_clock_hz(lines[i]) {
305                            refresh_hz = Some(hz);
306                            break;
307                        }
308
309                        i += 1;
310                    }
311                }
312
313                i += 1;
314            }
315
316            outputs.push(Monitor {
317                name,
318                width,
319                height,
320                x,
321                y,
322                brightness: brightness.unwrap_or_default(),
323                refresh_hz: refresh_hz.unwrap_or_default(),
324            });
325        }
326
327        outputs
328    }
329
330    #[cfg(test)]
331    mod tests {
332        use super::*;
333
334        #[test]
335        fn test_extract_outputs() {
336            let xrandr_output = include_str!("../../testdata/xrandr-verbose.txt");
337            let outputs = extract_outputs(xrandr_output);
338            assert_eq!(outputs.len(), 2);
339            assert_eq!(
340                outputs[0],
341                Monitor {
342                    name: "eDP-1".to_owned(),
343                    width: 1920,
344                    height: 1080,
345                    x: 0,
346                    y: 1080,
347                    brightness: 1.0,
348                    refresh_hz: 59.96,
349                }
350            );
351            assert_eq!(
352                outputs[1],
353                Monitor {
354                    name: "HDMI-1".to_owned(),
355                    width: 1920,
356                    height: 1080,
357                    x: 0,
358                    y: 0,
359                    brightness: 0.8,
360                    refresh_hz: 59.99,
361                }
362            );
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn plan_declares_single_output_with_static_icons() {
373        let plan = prepare(&Config::default()).unwrap();
374        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
375        assert_eq!(ids, ["main"]);
376
377        let main = plan.output("main").unwrap();
378        assert_eq!(main.single_icon("icon").unwrap(), "xrandr");
379        assert_eq!(main.single_icon("brightness_icon").unwrap(), "backlight");
380        assert_eq!(main.single_icon("res_icon").unwrap(), "resolution");
381    }
382
383    #[test]
384    fn plan_uses_configured_format() {
385        let config = Config {
386            format: " $icon $resolution ".parse().unwrap(),
387            ..Config::default()
388        };
389        let plan = prepare(&config).unwrap();
390        let main = plan.output("main").unwrap();
391        assert!(main.format().contains_key("resolution"));
392        assert!(!main.format().contains_key("brightness"));
393    }
394}