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

commit765ba4a62d39687b75861fe2a4bbf63010773775
parent27f616889f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 03:58
Place the volume/brightness slider beside the stats segment

The slider now defaults to `osd { position "status" }`: centered on the
bar's `stats` segment (else the lone volume/brightness one), one module
spacing away — below it on a top bar, above it on a bottom one, clamped on
screen. The segment rect comes from `ccectl windows --json` and is made
output-relative with `ccectl outputs --json`. "top"/"bottom"/"center" keep
the screen-centered placements; an unlisted segment falls back to bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md  |  18 +++++++--
 src/osd.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 139 insertions(+), 13 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0e58a2a..cf476c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks),
 
 ```sh
 cargo build --release                 # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test                            # 42 tests: main.rs (parsers, droplet geometry), config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
+cargo test                            # 46 tests: main.rs (parsers, droplet geometry), config.rs, tray.rs, osd.rs, the tray bridge's x11.rs and title.rs
 make install                          # release build, then `ccebuild install --no-build cce-status-interface`
 ```
 
@@ -161,11 +161,21 @@ through).
   keeps a fullscreen window off direct scanout. It gives up its socket
   BEFORE the close fade, so a change during the fade starts a fresh slider
   instead of being answered and dropped.
+- **Placement** is worked out once, at startup (`placement`,
+  `beside_segment`): by default (`osd { position "status" }`) it sits beside
+  the `stats` segment — else the lone `volume`/`brightness` one — read from
+  `ccectl windows --json`, centered on it and `module { spacing }` away:
+  below a segment in the top half of its output, above one in the bottom
+  half, clamped on screen. The rect is converted to margins on the output
+  it lies in (`ccectl outputs --json`), but the layer surface is created
+  with no output, so on a multi-monitor setup the compositor's choice of
+  output must be the segment's for it to line up. `"top"`/`"bottom"`/
+  `"center"` center it on the screen instead, `osd { margin }` (96) from
+  that edge; an unlisted segment falls back to `"bottom"`.
 - Looks: the bar's `module { }` box (droplet, bevel or plain), colors, font
   and glyphs, scaled by `osd { height }` (default 1.5 × bar height) over
-  the bar height. `osd { width position margin }` place it (`"bottom"`
-  default, `"top"`, `"center"`; margin from that edge, default 96);
-  `osd { enabled false }` turns it off. Muted reads in `disabled_color`.
+  the bar height; `osd { width }` (260). `osd { enabled false }` turns it
+  off. Muted reads in `disabled_color`.
 - Verify in a shadow by running `--osd volume 55 0` directly (export
   `CCE_ICONS_DIR`), or the launcher plus a real level change — the watchers
   read the machine's real backlight and sink, which the shadow shares.
diff --git a/src/osd.rs b/src/osd.rs
index c88dd44..4c9bbf7 100644
--- a/src/osd.rs
+++ b/src/osd.rs
@@ -23,7 +23,7 @@
 //!   The compositor's close fade dissolves it on the way out.
 //!
 //! Config, in the app's own `config.kdl` beside `module { }`:
-//! `osd { timeout_ms 1500 width 260 height <1.5 × bar> position "bottom" margin 96 }`
+//! `osd { timeout_ms 1500 width 260 height <1.5 × bar> position "status" margin 96 }`
 //! and `osd { enabled false }` to turn it off. The box, colors, font, glyphs
 //! and droplet style are the bar's `module { }` keys, so the slider reads as
 //! one of its readouts grown up.
@@ -107,6 +107,14 @@ impl Level {
         matches!(self, Level::Volume { muted: true, .. })
     }
 
+    /// The bar module that reads this level out on its own.
+    fn module(self) -> &'static str {
+        match self {
+            Level::Brightness(_) => "brightness",
+            Level::Volume { .. } => "volume",
+        }
+    }
+
     fn icon(self) -> &'static str {
         match self {
             Level::Brightness(_) => "brightness",
@@ -147,20 +155,83 @@ fn size() -> (u32, u32) {
     (w.round() as u32, h.round() as u32)
 }
 
-/// `osd { position "bottom"|"top"|"center" margin 96 }` — horizontally
-/// centered either way; `margin` is the gap from the chosen screen edge.
-fn placement() -> (LayerAnchor, (i32, i32, i32, i32)) {
+/// A layer surface's anchor plus its (top, right, bottom, left) margins.
+type Placement = (LayerAnchor, (i32, i32, i32, i32));
+
+/// `osd { position "status"|"bottom"|"top"|"center" margin 96 }`.
+///
+/// `"status"`, the default, puts the slider beside the bar segment showing
+/// the level — `stats`, else the lone `volume`/`brightness` readout — one
+/// `module { spacing }` away: below it on a top bar, above it on a bottom
+/// one, centered on it and kept on screen. The others center the slider
+/// horizontally, `margin` from the chosen screen edge; a segment the
+/// compositor does not list falls back to `"bottom"`.
+fn placement(level: Level, (w, h): (u32, u32)) -> Placement {
     let margin = cfg_f32("/osd/margin").unwrap_or(96.0).max(0.0) as i32;
     let position = crate::get_cached_config()
         .pointer("/osd/position")
         .and_then(|v| v.as_str())
-        .unwrap_or("bottom")
+        .unwrap_or("status")
         .to_string();
     match position.as_str() {
-        "top" => (LayerAnchor::TOP, (margin, 0, 0, 0)),
-        "center" => (LayerAnchor::empty(), (0, 0, 0, 0)),
-        _ => (LayerAnchor::BOTTOM, (0, 0, margin, 0)),
+        "top" => return (LayerAnchor::TOP, (margin, 0, 0, 0)),
+        "center" => return (LayerAnchor::empty(), (0, 0, 0, 0)),
+        "bottom" => return (LayerAnchor::BOTTOM, (0, 0, margin, 0)),
+        _ => {}
     }
+    let ccectl = |args: &[&str]| {
+        std::process::Command::new(crate::get_ccectl_cmd())
+            .args(args)
+            .output()
+            .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
+            .unwrap_or_default()
+    };
+    let gap = crate::read_status_module_spacing_from_config().round() as i32;
+    beside_segment(
+        &ccectl(&["windows", "--json"]),
+        &ccectl(&["outputs", "--json"]),
+        &["stats", level.module()],
+        (w as i32, h as i32),
+        gap,
+    )
+    .unwrap_or_else(|| {
+        log::warn!("[osd] no {} segment listed — placing the slider at the bottom", level.module());
+        (LayerAnchor::BOTTOM, (0, 0, margin, 0))
+    })
+}
+
+/// [`placement`]'s `"status"` geometry, from `ccectl windows --json` and
+/// `ccectl outputs --json` (one JSON object per line each). The segment is
+/// the first of `modules` the compositor lists, matched by the app_id's
+/// `-<module>` suffix so both prefixes and either side match. Its rect is in
+/// layout coordinates and a layer surface's margins are output-relative, so
+/// it is taken relative to the output it sits on.
+fn beside_segment(windows: &str, outputs: &str, modules: &[&str], (w, h): (i32, i32), gap: i32) -> Option<Placement> {
+    let objects = |text: &str| -> Vec<serde_json::Value> {
+        text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
+    };
+    let int = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_i64()).map(|x| x as i32);
+    let windows = objects(windows);
+    let segment = modules.iter().find_map(|module| {
+        let suffix = format!("-{module}");
+        windows.iter().find(|v| {
+            v.get("app_id")
+                .and_then(|a| a.as_str())
+                .is_some_and(|a| a.starts_with("cce-status") && a.ends_with(&suffix))
+        })
+    })?;
+    let (sx, sy, sw, sh) = (int(segment, "x")?, int(segment, "y")?, int(segment, "w")?, int(segment, "h")?);
+    let (cx, cy) = (sx + sw / 2, sy + sh / 2);
+    let output = objects(outputs).into_iter().find(|o| {
+        let (Some(ox), Some(oy), Some(ow), Some(oh)) = (int(o, "x"), int(o, "y"), int(o, "logical_w"), int(o, "logical_h")) else {
+            return false;
+        };
+        (ox..ox + ow).contains(&cx) && (oy..oy + oh).contains(&cy)
+    })?;
+    let (ox, oy, ow, oh) = (int(&output, "x")?, int(&output, "y")?, int(&output, "logical_w")?, int(&output, "logical_h")?);
+    let left = (cx - ox - w / 2).clamp(0, (ow - w).max(0));
+    let top = if cy - oy < oh / 2 { sy - oy + sh + gap } else { sy - oy - gap - h };
+    Some((LayerAnchor::TOP | LayerAnchor::LEFT, (top.clamp(0, (oh - h).max(0)), 0, 0, left)))
 }
 
 // ---------------------------------------------------------------- trigger
@@ -256,6 +327,9 @@ pub(crate) struct OsdApp {
     seen_renderer: bool,
     width: u32,
     height: u32,
+    /// Where the surface goes, worked out once at startup: a slider that
+    /// is already up stays put while its level changes.
+    placement: Placement,
 }
 
 impl OsdApp {
@@ -290,6 +364,7 @@ impl cce_ui::engine::Application for OsdApp {
             Some("ok".into())
         });
         let (width, height) = size();
+        let placement = placement(level, (width, height));
         let mut app = Self {
             level,
             // Opens already at the level: the slider is news only once it is
@@ -301,6 +376,7 @@ impl cce_ui::engine::Application for OsdApp {
             seen_renderer: false,
             width,
             height,
+            placement,
         };
         app.arm_hide();
         app
@@ -318,7 +394,7 @@ impl cce_ui::engine::Application for OsdApp {
     }
 
     fn layer(&self) -> Option<LayerSettings> {
-        let (anchor, margin) = placement();
+        let (anchor, margin) = self.placement;
         Some(LayerSettings {
             layer: LayerKind::Overlay,
             anchor,
@@ -527,6 +603,46 @@ mod tests {
         assert_eq!(Level::from_change(LevelChange::Brightness(Some(40))), Some(Level::Brightness(40)));
     }
 
+    const OUTPUT: &str = r#"{"logical_h":1200,"logical_w":1920,"name":"eDP-1","x":0,"y":0}"#;
+
+    #[test]
+    fn the_slider_hangs_centered_under_a_top_bar_stats_segment() {
+        let windows = concat!(
+            r#"{"app_id":"cce-status-interface-right-tray","x":1087,"y":0,"w":116,"h":27}"#, "\n",
+            r#"{"app_id":"cce-status-interface-right-stats","x":1215,"y":0,"w":359,"h":27}"#, "\n",
+            r#"{"app_id":"cce-terminal","x":0,"y":0,"w":1920,"h":1200}"#, "\n",
+        );
+        let (anchor, margin) = beside_segment(windows, OUTPUT, &["stats", "volume"], (260, 40), 12).unwrap();
+        assert_eq!(anchor, LayerAnchor::TOP | LayerAnchor::LEFT);
+        // Centered on 1215 + 359/2 = 1394; 12 below the 27px strip.
+        assert_eq!(margin, (39, 0, 0, 1394 - 130));
+    }
+
+    #[test]
+    fn on_a_bottom_bar_it_sits_above_and_stays_on_screen() {
+        let windows = r#"{"app_id":"cce-status-left-volume","x":1880,"y":1173,"w":40,"h":27}"#;
+        let (_, (top, _, _, left)) = beside_segment(windows, OUTPUT, &["stats", "volume"], (260, 40), 12).unwrap();
+        assert_eq!(top, 1173 - 12 - 40);
+        assert_eq!(left, 1920 - 260);
+    }
+
+    #[test]
+    fn margins_are_relative_to_the_segments_output() {
+        let outputs = concat!(
+            r#"{"logical_h":1200,"logical_w":1920,"x":0,"y":0}"#, "\n",
+            r#"{"logical_h":1080,"logical_w":1920,"x":1920,"y":0}"#, "\n",
+        );
+        let windows = r#"{"app_id":"cce-status-right-stats","x":3000,"y":0,"w":200,"h":27}"#;
+        let (_, (_, _, _, left)) = beside_segment(windows, outputs, &["stats"], (260, 40), 12).unwrap();
+        assert_eq!(left, 3100 - 1920 - 130);
+    }
+
+    #[test]
+    fn no_listed_segment_means_no_placement() {
+        let windows = r#"{"app_id":"cce-status-right-clock","x":1586,"y":0,"w":322,"h":27}"#;
+        assert_eq!(beside_segment(windows, OUTPUT, &["stats", "volume"], (260, 40), 12), None);
+    }
+
     #[test]
     fn a_boosted_sink_fills_the_track_and_no_more() {
         assert_eq!(Level::Volume { pct: Some(150), muted: false }.fraction(), 1.0);