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(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 "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 fn name(input: &str) -> IResult<&str, &str> {
207 take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')(input)
208 }
209
210 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 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 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 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 #[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 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 let Ok((_, (name, width, height, x, y))) = parse_output_header(lines[i]) else {
275 i += 1;
276 continue;
277 };
278
279 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 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 i += 1;
297 while i < lines.len() {
298 if parse_output_header(lines[i]).is_ok() {
299 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}