1use 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 fn name(input: &str) -> IResult<&str, &str> {
189 take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')(input)
190 }
191
192 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 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 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 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 #[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 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 let Ok((_, (name, width, height, x, y))) = parse_output_header(lines[i]) else {
257 i += 1;
258 continue;
259 };
260
261 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 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 i += 1;
279 while i < lines.len() {
280 if parse_output_header(lines[i]).is_ok() {
281 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}