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 }