git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

src/osd.rs (26K)

  1 //! The volume/brightness slider: a transient on-screen display that appears
  2 //! when either level moves and fades a moment after the last change.
  3 //!
  4 //! It is a layer-shell surface on the OVERLAY layer, which the compositor
  5 //! stacks above `layers.fullscreen` (cce-compositor `scene.rs`) — so it shows
  6 //! over a fullscreen game or video, where the bar's own readouts are hidden.
  7 //! It takes no keyboard focus (a fullscreen window must never yield to it,
  8 //! see the compositor's `fullscreen_yields`) and has an empty input region,
  9 //! so a click on it lands on whatever is underneath.
 10 //!
 11 //! Two halves, both in this binary:
 12 //!
 13 //! - **The trigger** ([`spawn_trigger`]) runs in the launcher daemon. It
 14 //!   drives the same fast-path watchers the bar's readouts use
 15 //!   ([`crate::spawn_level_watchers`] — the backlight's sysfs change
 16 //!   notification, the sink followed through `pactl subscribe`), so the slider moves for a
 17 //!   keypress, `brightnessctl` in a terminal or a mixer app alike. Each change
 18 //!   is forwarded as one line to the running slider, or starts one.
 19 //! - **The slider** (`--osd <line>`, [`main`]) is a single-instance `cce-ui`
 20 //!   app on [`SOCKET_PREFIX`]'s socket. It EXITS when its timeout runs out
 21 //!   rather than hiding: a surface that stays mapped, even fully
 22 //!   transparent, keeps a fullscreen window off direct scanout for good.
 23 //!   The compositor's close fade dissolves it on the way out.
 24 //!
 25 //! Config, in the app's own `config.kdl` beside `module { }`:
 26 //! `osd { timeout_ms 1500 width 260 height <1.5 × bar> position "status" margin 96 }`
 27 //! and `osd { enabled false }` to turn it off. The box, colors, font, glyphs
 28 //! and droplet style are the bar's `module { }` keys, so the slider reads as
 29 //! one of its readouts grown up.
 30 
 31 use cce_ui::scene::paint::DropletFinish;
 32 use cce_ui::cosmic_text::FontSystem;
 33 use cce_ui::engine::{
 34     LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings, LogicalPosition, LogicalSize,
 35     WindowSettings,
 36 };
 37 use cce_ui::scene::layout::Rect;
 38 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 39 
 40 use crate::{LevelChange, StatusBoxBevel};
 41 
 42 /// The slider's instance socket: `/tmp/cce-status-osd-<WAYLAND_DISPLAY>.sock`.
 43 pub(crate) const SOCKET_PREFIX: &str = "cce-status-osd";
 44 
 45 /// What the slider shows. The wire form is one line — see [`Level::line`].
 46 #[derive(Debug, Clone, Copy, PartialEq)]
 47 pub(crate) enum Level {
 48     Brightness(u32),
 49     /// `pct` is `None` when pactl answered without a percentage.
 50     Volume { pct: Option<u32>, muted: bool },
 51 }
 52 
 53 impl Level {
 54     /// The level a watcher change shows, or `None` when the source went away
 55     /// (no backlight, no pactl) — there is nothing to slide then.
 56     pub(crate) fn from_change(change: LevelChange) -> Option<Self> {
 57         match change {
 58             LevelChange::Brightness(b) => Some(Level::Brightness(b?.max(0) as u32)),
 59             LevelChange::Volume(v) => {
 60                 let (pct, muted) = v?;
 61                 Some(Level::Volume { pct, muted })
 62             }
 63         }
 64     }
 65 
 66     /// `brightness 40` / `volume 55 0` / `volume - 1` (muted, no level).
 67     pub(crate) fn line(self) -> String {
 68         match self {
 69             Level::Brightness(p) => format!("brightness {p}"),
 70             Level::Volume { pct, muted } => format!(
 71                 "volume {} {}",
 72                 pct.map_or("-".to_string(), |p| p.to_string()),
 73                 u8::from(muted)
 74             ),
 75         }
 76     }
 77 
 78     pub(crate) fn parse(line: &str) -> Option<Self> {
 79         let mut words = line.split_whitespace();
 80         let level = match words.next()? {
 81             "brightness" => Level::Brightness(words.next()?.parse().ok()?),
 82             "volume" => {
 83                 let pct = match words.next()? {
 84                     "-" => None,
 85                     p => Some(p.parse().ok()?),
 86                 };
 87                 let muted = match words.next()? {
 88                     "0" => false,
 89                     "1" => true,
 90                     _ => return None,
 91                 };
 92                 Level::Volume { pct, muted }
 93             }
 94             _ => return None,
 95         };
 96         words.next().is_none().then_some(level)
 97     }
 98 
 99     fn pct(self) -> Option<u32> {
100         match self {
101             Level::Brightness(p) => Some(p),
102             Level::Volume { pct, .. } => pct,
103         }
104     }
105 
106     fn muted(self) -> bool {
107         matches!(self, Level::Volume { muted: true, .. })
108     }
109 
110     /// The bar module that reads this level out on its own.
111     fn module(self) -> &'static str {
112         match self {
113             Level::Brightness(_) => "brightness",
114             Level::Volume { .. } => "volume",
115         }
116     }
117 
118     fn icon(self) -> &'static str {
119         match self {
120             Level::Brightness(_) => "brightness",
121             Level::Volume { muted: true, .. } => "volume-muted",
122             Level::Volume { .. } => "volume",
123         }
124     }
125 
126     /// The share of the track filled, 0..=1 (a sink boosted past 100% fills it).
127     fn fraction(self) -> f32 {
128         self.pct().map_or(0.0, |p| (p as f32 / 100.0).clamp(0.0, 1.0))
129     }
130 }
131 
132 // ---------------------------------------------------------------- config
133 
134 fn cfg_f32(pointer: &str) -> Option<f32> {
135     crate::get_cached_config().pointer(pointer).and_then(|v| v.as_f64()).map(|n| n as f32)
136 }
137 
138 /// `osd { enabled false }` turns the slider off; read per change, so it
139 /// takes effect without restarting the bar.
140 fn enabled() -> bool {
141     crate::get_cached_config().pointer("/osd/enabled").and_then(|v| v.as_bool()).unwrap_or(true)
142 }
143 
144 /// `osd { timeout_ms }` — how long the slider stays after the LAST change.
145 fn timeout() -> std::time::Duration {
146     std::time::Duration::from_millis(cfg_f32("/osd/timeout_ms").unwrap_or(1500.0).clamp(200.0, 10_000.0) as u64)
147 }
148 
149 /// `osd { width height }`, logical px. The height defaults to one and a half
150 /// bar heights, and the glyph and number scale with it.
151 fn size() -> (u32, u32) {
152     let bar_h = crate::read_status_height_from_config();
153     let h = cfg_f32("/osd/height").unwrap_or((bar_h * 1.5).round()).max(16.0);
154     let w = cfg_f32("/osd/width").unwrap_or(260.0).max(h * 3.0);
155     (w.round() as u32, h.round() as u32)
156 }
157 
158 /// A layer surface's anchor plus its (top, right, bottom, left) margins.
159 type Placement = (LayerAnchor, (i32, i32, i32, i32));
160 
161 /// `osd { position "status"|"bottom"|"top"|"center" margin 96 }`.
162 ///
163 /// `"status"`, the default, puts the slider beside the bar segment showing
164 /// the level — `stats`, else the lone `volume`/`brightness` readout — one
165 /// `module { spacing }` away: below it on a top bar, above it on a bottom
166 /// one, centered on it and kept on screen. The others center the slider
167 /// horizontally, `margin` from the chosen screen edge; a segment the
168 /// compositor does not list falls back to `"bottom"`.
169 fn placement(level: Level, (w, h): (u32, u32)) -> Placement {
170     let margin = cfg_f32("/osd/margin").unwrap_or(96.0).max(0.0) as i32;
171     let position = crate::get_cached_config()
172         .pointer("/osd/position")
173         .and_then(|v| v.as_str())
174         .unwrap_or("status")
175         .to_string();
176     match position.as_str() {
177         "top" => return (LayerAnchor::TOP, (margin, 0, 0, 0)),
178         "center" => return (LayerAnchor::empty(), (0, 0, 0, 0)),
179         "bottom" => return (LayerAnchor::BOTTOM, (0, 0, margin, 0)),
180         _ => {}
181     }
182     let ccectl = |args: &[&str]| {
183         std::process::Command::new(crate::get_ccectl_cmd())
184             .args(args)
185             .output()
186             .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
187             .unwrap_or_default()
188     };
189     let gap = crate::read_status_module_spacing_from_config().round() as i32;
190     beside_segment(
191         &ccectl(&["windows", "--json"]),
192         &ccectl(&["outputs", "--json"]),
193         &["stats", level.module()],
194         (w as i32, h as i32),
195         gap,
196     )
197     .unwrap_or_else(|| {
198         log::warn!("[osd] no {} segment listed — placing the slider at the bottom", level.module());
199         (LayerAnchor::BOTTOM, (0, 0, margin, 0))
200     })
201 }
202 
203 /// [`placement`]'s `"status"` geometry, from `ccectl windows --json` and
204 /// `ccectl outputs --json` (one JSON object per line each). The segment is
205 /// the first of `modules` the compositor lists, matched by the app_id's
206 /// `-<module>` suffix so both prefixes and either side match. Its rect is in
207 /// layout coordinates and a layer surface's margins are output-relative, so
208 /// it is taken relative to the output it sits on.
209 fn beside_segment(windows: &str, outputs: &str, modules: &[&str], (w, h): (i32, i32), gap: i32) -> Option<Placement> {
210     let objects = |text: &str| -> Vec<serde_json::Value> {
211         text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
212     };
213     let int = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_i64()).map(|x| x as i32);
214     let windows = objects(windows);
215     let segment = modules.iter().find_map(|module| {
216         let suffix = format!("-{module}");
217         windows.iter().find(|v| {
218             v.get("app_id")
219                 .and_then(|a| a.as_str())
220                 .is_some_and(|a| a.starts_with("cce-status") && a.ends_with(&suffix))
221         })
222     })?;
223     let (sx, sy, sw, sh) = (int(segment, "x")?, int(segment, "y")?, int(segment, "w")?, int(segment, "h")?);
224     let (cx, cy) = (sx + sw / 2, sy + sh / 2);
225     let output = objects(outputs).into_iter().find(|o| {
226         let (Some(ox), Some(oy), Some(ow), Some(oh)) = (int(o, "x"), int(o, "y"), int(o, "logical_w"), int(o, "logical_h")) else {
227             return false;
228         };
229         (ox..ox + ow).contains(&cx) && (oy..oy + oh).contains(&cy)
230     })?;
231     let (ox, oy, ow, oh) = (int(&output, "x")?, int(&output, "y")?, int(&output, "logical_w")?, int(&output, "logical_h")?);
232     let left = (cx - ox - w / 2).clamp(0, (ow - w).max(0));
233     let top = if cy - oy < oh / 2 { sy - oy + sh + gap } else { sy - oy - gap - h };
234     Some((LayerAnchor::TOP | LayerAnchor::LEFT, (top.clamp(0, (oh - h).max(0)), 0, 0, left)))
235 }
236 
237 // ---------------------------------------------------------------- trigger
238 
239 /// Watch both levels and show the slider for every change. Runs for the life
240 /// of the launcher daemon; `exe` is this binary, re-run as `--osd`.
241 pub(crate) async fn spawn_trigger(exe: std::path::PathBuf) {
242     let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
243     crate::spawn_level_watchers(move |change| {
244         let _ = tx.send(change);
245     })
246     .await;
247     tokio::spawn(async move {
248         while let Some(mut change) = rx.recv().await {
249             // Forwarding blocks until the slider answers, which a slider
250             // still starting up does only once its loop is running; a held
251             // key queues changes meanwhile, and only the newest matters.
252             while let Ok(newer) = rx.try_recv() {
253                 change = newer;
254             }
255             let Some(level) = Level::from_change(change) else { continue };
256             if !enabled() {
257                 continue;
258             }
259             show(&exe, level).await;
260         }
261     });
262 }
263 
264 /// Hand `level` to the running slider, or start one showing it.
265 async fn show(exe: &std::path::Path, level: Level) {
266     let line = level.line();
267     let forwarded = {
268         let line = line.clone();
269         tokio::task::spawn_blocking(move || cce_ui::ipc::instance::forward(SOCKET_PREFIX, &line).is_some())
270             .await
271             .unwrap_or(false)
272     };
273     if forwarded {
274         return;
275     }
276     // Two of these racing (a second change before the first slider has bound
277     // its socket) is settled by `forward_or_claim` in the children.
278     match tokio::process::Command::new(exe).arg("--osd").args(line.split_whitespace()).spawn() {
279         Ok(mut child) => {
280             tokio::spawn(async move {
281                 let _ = child.wait().await;
282             });
283         }
284         Err(e) => log::error!("[osd] could not start the slider: {e}"),
285     }
286 }
287 
288 // ---------------------------------------------------------------- slider
289 
290 /// The level the slider was started with, parked by [`main`] for
291 /// `OsdApp::new` (`engine::run` takes no arguments).
292 static INITIAL: std::sync::OnceLock<Level> = std::sync::OnceLock::new();
293 
294 /// `cce-status-interface --osd <line>`: forward to a running slider, or
295 /// become it.
296 pub(crate) fn main(args: &[String]) {
297     let line = args.join(" ");
298     let Some(level) = Level::parse(&line) else {
299         log::error!("[osd] bad level {line:?} — expected `brightness <pct>` or `volume <pct|-> <0|1>`");
300         return;
301     };
302     if cce_ui::ipc::instance::forward_or_claim(SOCKET_PREFIX, &line) {
303         return;
304     }
305     let _ = INITIAL.set(level);
306     cce_ui::engine::run::<OsdApp>();
307     cce_ui::ipc::instance::cleanup();
308 }
309 
310 #[derive(Debug, Clone)]
311 pub(crate) enum OsdEvent {
312     Show(Level),
313     /// The timeout armed by the `Show` with this generation ran out.
314     Hide(u64),
315 }
316 
317 pub(crate) struct OsdApp {
318     level: Level,
319     /// The track fill drawn, eased toward `level.fraction()`.
320     fill_now: f32,
321     /// Bumped by every `Show`, so only the newest timeout hides the slider.
322     generation: u64,
323     sender: calloop::channel::Sender<OsdEvent>,
324     font_system: FontSystem,
325     width: u32,
326     height: u32,
327     /// Where the surface goes, worked out once at startup: a slider that
328     /// is already up stays put while its level changes.
329     placement: Placement,
330 }
331 
332 impl OsdApp {
333     /// (Re)start the countdown to hiding.
334     fn arm_hide(&mut self) {
335         self.generation += 1;
336         let generation = self.generation;
337         let sender = self.sender.clone();
338         let wait = timeout();
339         std::thread::spawn(move || {
340             std::thread::sleep(wait);
341             let _ = sender.send(OsdEvent::Hide(generation));
342         });
343     }
344 }
345 
346 /// A raw-sRGB text color as a linear quad color with alpha `a`.
347 fn quad_of(text: [f32; 4], a: f32) -> [f32; 4] {
348     let l = cce_ui::color::srgb_to_linear;
349     [l(text[0]), l(text[1]), l(text[2]), a]
350 }
351 
352 impl cce_ui::engine::Application for OsdApp {
353     type Message = OsdEvent;
354 
355     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
356         // The app keeps calloop's sender; `AppSender` converts into it.
357         let sender: calloop::channel::Sender<Self::Message> = sender.into();
358         let level = INITIAL.get().copied().unwrap_or(Level::Brightness(0));
359         let forward = sender.clone();
360         cce_ui::ipc::instance::serve(move |line| {
361             let level = Level::parse(line)?;
362             forward.send(OsdEvent::Show(level)).ok()?;
363             Some("ok".into())
364         });
365         let (width, height) = size();
366         let placement = placement(level, (width, height));
367         let mut app = Self {
368             level,
369             // Opens already at the level: the slider is news only once it is
370             // up, so the first value does not sweep in from empty.
371             fill_now: level.fraction(),
372             generation: 0,
373             sender,
374             font_system: cce_ui::create_font_system(),
375             width,
376             height,
377             placement,
378         };
379         app.arm_hide();
380         app
381     }
382 
383     /// An overlay, not a window: no client-side move, resize or resize
384     /// cursors. (cce-ui used to infer this from the app id.)
385     fn standard_csd(&self) -> bool {
386         false
387     }
388 
389     fn settings(&self) -> WindowSettings {
390         WindowSettings {
391             title: "Level".to_string(),
392             app_id: SOCKET_PREFIX.to_string(),
393             width: self.width,
394             height: self.height,
395             fullscreen: false,
396             min_size: None,
397         }
398     }
399 
400     fn layer(&self) -> Option<LayerSettings> {
401         let (anchor, margin) = self.placement;
402         Some(LayerSettings {
403             layer: LayerKind::Overlay,
404             anchor,
405             exclusive_zone: 0,
406             keyboard_interactivity: LayerKeyboardInteractivity::None,
407             margin,
408             namespace: SOCKET_PREFIX.to_string(),
409         })
410     }
411 
412     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
413         match msg {
414             OsdEvent::Show(level) => {
415                 self.level = level;
416                 self.arm_hide();
417                 *needs_rebuild = true;
418             }
419             OsdEvent::Hide(generation) if generation == self.generation => {
420                 // Give up the socket BEFORE the close fade: a change during
421                 // the fade must start a fresh slider, not be answered by this
422                 // one and then dropped with it.
423                 cce_ui::ipc::instance::cleanup();
424                 *exit = true;
425             }
426             OsdEvent::Hide(_) => {}
427         }
428     }
429 
430     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
431         const FILL_EASE_S: f32 = 0.08;
432         let target = self.level.fraction();
433         if self.fill_now == target {
434             return;
435         }
436         if !cce_ui::motion::enabled() || (target - self.fill_now).abs() < 0.002 {
437             self.fill_now = target;
438         } else {
439             self.fill_now += (target - self.fill_now) * (dt / FILL_EASE_S).clamp(0.0, 1.0);
440         }
441         *needs_rebuild = true;
442     }
443 
444     // style-audit: opt-out a transparent surface; the slider's box is the bar's module plate
445     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
446         cce_ui::scale::set_scale_factor(scale as f32);
447         let (w, h) = (size.width, size.height);
448         let bar_h = crate::read_status_height_from_config();
449         let k = h / bar_h.max(1.0);
450         let padding = crate::read_status_padding_from_config() * k;
451         let (font_family, _) = cce_ui::layout::parse_font_string(&crate::read_status_font_from_config());
452         let font_size = crate::read_icon_font_size_from_config(crate::read_status_font_size_from_config()) * k;
453         let weight = crate::read_icon_weight_from_config();
454         let gap = crate::read_icon_gap_from_config().max(4.0) * k;
455 
456         let normal = crate::read_normal_color_from_config().unwrap_or(cce_ui::color::TEXT_FG);
457         let color = if self.level.muted() {
458             crate::read_disabled_color_from_config().unwrap_or(cce_ui::color::TEXT_DIM)
459         } else {
460             normal
461         };
462 
463         let mut pc = cce_ui::scene::paint::PaintCtx::new();
464 
465         // The box: the bar's module plate, in whichever style the bar wears.
466         if let Some(bg) = crate::read_status_box_background_color_from_config() {
467             let radius = crate::read_status_box_corner_radius_from_config().min(h / 2.0);
468             let radii = (radius, radius, radius, radius);
469             let rect = Rect { x: 0.0, y: 0.0, width: w, height: h };
470             let material = cce_ui::scene::Material::from_fill(bg);
471             if let Some(spec) = crate::read_droplet_from_config() {
472                 // Same inset the bar's drops take: the AA feather plus the
473                 // contact shadow's gap.
474                 let inset = 1.0 + spec.shadow_gap(h);
475                 pc.droplet(Rect { height: h - inset, ..rect }, &material.with_finish(spec.finish()), spec);
476             } else {
477                 match crate::read_status_box_bevel_from_config() {
478                     Some(StatusBoxBevel::Raised) => {
479                         pc.bevel(rect, radii, &material, crate::read_status_box_bevel_depth_from_config());
480                     }
481                     Some(StatusBoxBevel::Inset) => {
482                         pc.rounded_rect(rect, radius, (true, true, true, true), bg);
483                         pc.recess(rect, radii, crate::read_status_box_bevel_depth_from_config());
484                     }
485                     None => pc.rounded_rect(rect, radius, (true, true, true, true), bg),
486                 }
487             }
488         }
489 
490         // The glyph at the left, as in the bar's readouts but scaled with
491         // the box. Without the icon set it is simply left out: the slider
492         // still says which level it is by moving when that key is pressed.
493         let raise = crate::read_text_raise_from_config() * k;
494         let mut x = padding;
495         // The glyph is looked up every frame: the cache is keyed on the
496         // renderer epoch, so a reconnect's new renderer gets its own upload.
497         if let Some((image, gw, gh)) = crate::icons::glyph(self.level.icon(), crate::read_icon_size_from_config() * k, cce_ui::icon_tint(color)) {
498             pc.image(
499                 image,
500                 Rect { x, y: (h - gh) / 2.0 - raise, width: gw, height: gh },
501                 crate::read_icon_alpha_from_config(),
502             );
503             x += gw + gap;
504         }
505 
506         // The number at the right, right-aligned in a "100"-wide column so
507         // the track keeps its length as the value changes digit count.
508         let measure = |fs: &mut FontSystem, text: &str| {
509             let buf = crate::make_text_buffer_weighted(fs, text, font_size, &font_family, weight);
510             buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / scale as f32
511         };
512         let column = measure(&mut self.font_system, "100");
513         let right = w - padding;
514         if let Some(pct) = self.level.pct() {
515             let text = pct.to_string();
516             let tw = measure(&mut self.font_system, &text);
517             pc.text_attrs(
518                 text,
519                 right - tw,
520                 (h - font_size) / 2.0 - raise,
521                 font_size,
522                 cce_ui::icon_tint(color),
523                 Some(font_family.clone()),
524                 None,
525                 cce_ui::scene::paint::TextAttrs { italic: false, weight, ..Default::default() },
526             );
527         }
528 
529         // The track between them, and its fill.
530         let track_w = (right - column - gap - x).max(0.0);
531         let thickness = (h * 0.16).clamp(3.0, 10.0);
532         let track = Rect { x, y: (h - thickness) / 2.0 - raise, width: track_w, height: thickness };
533         let ends = (true, true, true, true);
534         pc.rounded_rect(track, thickness / 2.0, ends, quad_of(normal, 0.22));
535         if self.fill_now > 0.0 {
536             // Never shorter than its own rounded ends, so 1% is a dot, not a sliver.
537             let fill_w = (track_w * self.fill_now).max(thickness).min(track_w);
538             pc.rounded_rect(Rect { width: fill_w, ..track }, thickness / 2.0, ends, quad_of(color, 1.0));
539         }
540 
541         Some(pc.finish())
542     }
543 
544     fn display_list_text(&self) -> bool {
545         true
546     }
547 
548     fn clear_color(&self) -> [f32; 4] {
549         [0.0, 0.0, 0.0, 0.0]
550     }
551 
552     /// Empty: the slider is never in the way of a click.
553     fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
554         Some(Vec::new())
555     }
556 
557     fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
558     fn handle_mouse_input(
559         &mut self,
560         _button: MouseButton,
561         _state: ElementState,
562         _pos: LogicalPosition,
563         _needs_rebuild: &mut bool,
564     ) -> Option<Self::Message> {
565         None
566     }
567     fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
568     fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
569         None
570     }
571 }
572 
573 #[cfg(test)]
574 mod tests {
575     use super::*;
576 
577     #[test]
578     fn levels_survive_the_wire() {
579         for level in [
580             Level::Brightness(0),
581             Level::Brightness(100),
582             Level::Volume { pct: Some(55), muted: false },
583             Level::Volume { pct: Some(130), muted: true },
584             Level::Volume { pct: None, muted: true },
585         ] {
586             assert_eq!(Level::parse(&level.line()), Some(level), "{}", level.line());
587         }
588     }
589 
590     #[test]
591     fn malformed_lines_are_refused() {
592         for line in ["", "volume", "volume 50", "volume 50 2", "volume x 0", "brightness", "brightness -3", "brightness 5 6", "mic 50"] {
593             assert_eq!(Level::parse(line), None, "{line:?}");
594         }
595     }
596 
597     #[test]
598     fn a_vanished_source_shows_nothing() {
599         assert_eq!(Level::from_change(LevelChange::Brightness(None)), None);
600         assert_eq!(Level::from_change(LevelChange::Volume(None)), None);
601         assert_eq!(Level::from_change(LevelChange::Brightness(Some(40))), Some(Level::Brightness(40)));
602     }
603 
604     const OUTPUT: &str = r#"{"logical_h":1200,"logical_w":1920,"name":"eDP-1","x":0,"y":0}"#;
605 
606     #[test]
607     fn the_slider_hangs_centered_under_a_top_bar_stats_segment() {
608         let windows = concat!(
609             r#"{"app_id":"cce-status-interface-right-tray","x":1087,"y":0,"w":116,"h":27}"#, "\n",
610             r#"{"app_id":"cce-status-interface-right-stats","x":1215,"y":0,"w":359,"h":27}"#, "\n",
611             r#"{"app_id":"cce-terminal","x":0,"y":0,"w":1920,"h":1200}"#, "\n",
612         );
613         let (anchor, margin) = beside_segment(windows, OUTPUT, &["stats", "volume"], (260, 40), 12).unwrap();
614         assert_eq!(anchor, LayerAnchor::TOP | LayerAnchor::LEFT);
615         // Centered on 1215 + 359/2 = 1394; 12 below the 27px strip.
616         assert_eq!(margin, (39, 0, 0, 1394 - 130));
617     }
618 
619     #[test]
620     fn on_a_bottom_bar_it_sits_above_and_stays_on_screen() {
621         let windows = r#"{"app_id":"cce-status-left-volume","x":1880,"y":1173,"w":40,"h":27}"#;
622         let (_, (top, _, _, left)) = beside_segment(windows, OUTPUT, &["stats", "volume"], (260, 40), 12).unwrap();
623         assert_eq!(top, 1173 - 12 - 40);
624         assert_eq!(left, 1920 - 260);
625     }
626 
627     #[test]
628     fn margins_are_relative_to_the_segments_output() {
629         let outputs = concat!(
630             r#"{"logical_h":1200,"logical_w":1920,"x":0,"y":0}"#, "\n",
631             r#"{"logical_h":1080,"logical_w":1920,"x":1920,"y":0}"#, "\n",
632         );
633         let windows = r#"{"app_id":"cce-status-right-stats","x":3000,"y":0,"w":200,"h":27}"#;
634         let (_, (_, _, _, left)) = beside_segment(windows, outputs, &["stats"], (260, 40), 12).unwrap();
635         assert_eq!(left, 3100 - 1920 - 130);
636     }
637 
638     #[test]
639     fn no_listed_segment_means_no_placement() {
640         let windows = r#"{"app_id":"cce-status-right-clock","x":1586,"y":0,"w":322,"h":27}"#;
641         assert_eq!(beside_segment(windows, OUTPUT, &["stats", "volume"], (260, 40), 12), None);
642     }
643 
644     #[test]
645     fn a_boosted_sink_fills_the_track_and_no_more() {
646         assert_eq!(Level::Volume { pct: Some(150), muted: false }.fraction(), 1.0);
647         assert_eq!(Level::Volume { pct: None, muted: false }.fraction(), 0.0);
648         assert_eq!(Level::Brightness(25).fraction(), 0.25);
649     }
650 }