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

src/main.rs (135.5K)

   1 mod cloud;
   2 mod config;
   3 mod icons;
   4 mod listeners;
   5 mod modules;
   6 mod stats;
   7 mod tray;
   8 
   9 // Re-export at the crate root so call sites (here and in modules.rs) keep
  10 // their pre-split names.
  11 pub(crate) use cloud::*;
  12 pub(crate) use config::*;
  13 pub(crate) use listeners::*;
  14 pub(crate) use stats::*;
  15 pub(crate) use tray::*;
  16 
  17 use modules::{StatusModule, WindowModule, ClockModule, BatteryModule, VolumeModule, BrightnessModule, MemoryModule, CpuModule, StatsModule, TrayModule, LightSourceModule};
  18 
  19 use std::collections::HashMap;
  20 use cce_ui::cosmic_text::{
  21     Attrs, Buffer, FontSystem, Metrics,
  22 };
  23 use cce_ui::color;
  24 use cce_ui::widget::{
  25     WidgetHost,
  26     MouseButton, ElementState, MouseScrollDelta, KeyEvent,
  27 };
  28 
  29 #[derive(Debug, Clone)]
  30 pub struct TrayPixmap {
  31     pub width: i32,
  32     pub height: i32,
  33     pub pixels: Vec<u8>,
  34 }
  35 
  36 #[allow(dead_code)]
  37 #[derive(Debug, Clone)]
  38 pub struct TrayItem {
  39     pub id: String,
  40     pub icon_name: Option<String>,
  41     pub icon_theme_path: Option<String>,
  42     pub pixmaps: Option<Vec<TrayPixmap>>,
  43     pub title: Option<String>,
  44     pub dbus_id: Option<String>,
  45 }
  46 
  47 #[derive(Debug, Clone)]
  48 pub struct TrayIconBounds {
  49     pub id: String,
  50     pub x: f32,
  51     pub y: f32,
  52     pub w: f32,
  53     pub h: f32,
  54     pub title: Option<String>,
  55     pub dbus_id: Option<String>,
  56 }
  57 
  58 #[derive(Debug, Clone)]
  59 pub struct SystemStats {
  60     pub clock: String,
  61     /// Memory in use as a whole percentage of the total; `None` when
  62     /// /proc/meminfo is unreadable.
  63     pub memory: Option<u8>,
  64     /// CPU busy share as a whole percentage; `None` when /proc/stat is
  65     /// unreadable.
  66     pub cpu_pct: Option<u8>,
  67     /// `(capacity %, charging)`; `None` on a machine without a battery.
  68     pub battery: Option<(i32, bool)>,
  69     /// `(level %, muted)`; `None` when pactl is unavailable. The level is
  70     /// `None` when pactl answered but reported no percentage.
  71     pub volume: Option<(Option<u32>, bool)>,
  72     /// Backlight level as a whole percentage; `None` without a backlight.
  73     pub brightness: Option<i32>,
  74 }
  75 
  76 /// A bundled cce-icons glyph placed in the bar: `image` is the tinted
  77 /// texture from `icons::tinted_icon`, the rect is logical px, `alpha` the
  78 /// ghosting under a superimposed readout. Retained like `text_prims` and
  79 /// replayed by `display_list`, drawn after the scrim and before the text so
  80 /// the number sits on the glyph.
  81 #[derive(Debug, Clone, Copy)]
  82 pub struct IconPrim {
  83     pub image: u32,
  84     pub x: f32,
  85     pub y: f32,
  86     pub w: f32,
  87     pub h: f32,
  88     pub alpha: f32,
  89 }
  90 
  91 /// The subset of [`SystemStats`] a given module actually paints, as a comparable
  92 /// signature. The stats poller pushes a full `SystemStatsUpdated` every second
  93 /// regardless of change; deduping on this signature lets a module skip the redraw
  94 /// (and the compositor's whole-backdrop blur re-bake it triggers) when its own value
  95 /// is unchanged — e.g. the clock shows HH:MM and only changes once a minute.
  96 /// `None` means the module ignores stats entirely (window/tray/light_source), so a
  97 /// stats push never redraws it. Unknown / combined-bar modules compare everything.
  98 fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
  99     match module {
 100         Some("clock") => Some(s.clock.clone()),
 101         Some("cpu") => Some(format!("{:?}", s.cpu_pct)),
 102         Some("memory") => Some(format!("{:?}", s.memory)),
 103         Some("battery") => Some(format!("{:?}", s.battery)),
 104         Some("volume") => Some(format!("{:?}", s.volume)),
 105         Some("brightness") => Some(format!("{:?}", s.brightness)),
 106         Some("window") | Some("tray") | Some("light_source") => None,
 107         // `stats` paints every reader's value; so does an unknown name.
 108         _ => Some(format!(
 109             "{}|{:?}|{:?}|{:?}|{:?}|{:?}",
 110             s.clock, s.memory, s.cpu_pct, s.battery, s.volume, s.brightness
 111         )),
 112     }
 113 }
 114 
 115 /// Does this module paint `field` (`"brightness"` or `"volume"`)? The
 116 /// fast-path pushes carry one value each, so they ask this where a full stats
 117 /// push compares a [`stats_signature`]; the two must agree about which
 118 /// modules are blind to a stat, or a segment would redraw for a number it
 119 /// does not show.
 120 fn paints_stat(module: Option<&str>, field: &str) -> bool {
 121     match module {
 122         // The other single-stat modules; stats-blind modules.
 123         Some("clock") | Some("cpu") | Some("memory") | Some("battery") => false,
 124         Some("window") | Some("tray") | Some("light_source") => false,
 125         Some("brightness") => field == "brightness",
 126         Some("volume") => field == "volume",
 127         // `stats` paints every reader's value; so does an unknown name.
 128         _ => true,
 129     }
 130 }
 131 
 132 #[derive(Debug, Clone)]
 133 pub(crate) enum CustomEvent {
 134     LayoutUpdated(String),
 135     TitleUpdated(String),
 136     SystemStatsUpdated(SystemStats),
 137     /// The backlight moved, pushed by the fast path (`watch_brightness`) the
 138     /// moment it did rather than at the next one-second stats poll.
 139     BrightnessUpdated(Option<i32>),
 140     /// The default sink's `(level %, muted)` moved, pushed by the fast path
 141     /// (`watch_volume`) on the sound server's own event.
 142     VolumeUpdated(Option<(Option<u32>, bool)>),
 143     TrayUpdated(TrayItem),
 144     TrayRemoved(String),
 145     /// A bar-built in-surface menu (window picker), fetched off-thread.
 146     MenuReady { title: String, pages: Vec<MenuPage>, min_w: f32 },
 147     SwitcherTriggered,
 148     /// Compositor click-away-close: a pointer press landed somewhere other
 149     /// than this expanded segment. Payload = the pressed segment's app_id
 150     /// ("-" for none); a segment ignores a dismiss naming itself.
 151     MenuDismiss(String),
 152     /// A tray icon's DBusMenu, fetched and flattened for the in-surface menu.
 153     TrayMenuFetched { destination: String, menu_path: String, pages: Vec<MenuPage> },
 154     /// What this segment is composited over, measured by the compositor:
 155     /// (luma, spread), both 0-100. The bar cannot see behind its own
 156     /// translucent box, so this is the only source of that fact — see
 157     /// `module { text_contrast }`.
 158     BackdropUpdated((u8, u8)),
 159     ToggleHideModules,
 160     ToggleAdjustPositionMode,
 161 }
 162 
 163 /// One sRGB channel to linear light (the WCAG transfer function).
 164 fn to_linear(c: f32) -> f32 {
 165     let c = c.clamp(0.0, 1.0);
 166     if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
 167 }
 168 
 169 /// WCAG relative luminance of a raw-sRGB color, 0-1. Must agree with the
 170 /// compositor's `backdrop::relative_luminance` — the two are the halves of
 171 /// one contrast comparison, and weighting them differently would make the
 172 /// ratio meaningless.
 173 fn relative_luminance(rgba: [f32; 4]) -> f32 {
 174     0.2126 * to_linear(rgba[0]) + 0.7152 * to_linear(rgba[1]) + 0.0722 * to_linear(rgba[2])
 175 }
 176 
 177 /// WCAG contrast ratio between two relative luminances, 1.0 (identical) to
 178 /// 21.0 (black on white).
 179 fn contrast_ratio(a: f32, b: f32) -> f32 {
 180     let (hi, lo) = if a > b { (a, b) } else { (b, a) };
 181     (hi + 0.05) / (lo + 0.05)
 182 }
 183 
 184 /// How badly `text` fails to read against a backdrop of luminance `bg`:
 185 /// 0 once the pair clears WCAG AA for normal text (4.5:1), rising to 1 as
 186 /// the two converge on invisible.
 187 fn contrast_deficit(text: f32, bg: f32) -> f32 {
 188     const AA: f32 = 4.5;
 189     ((AA - contrast_ratio(text, bg)) / (AA - 1.0)).clamp(0.0, 1.0)
 190 }
 191 
 192 /// How much help text of luminance `text_luma` needs over a backdrop
 193 /// measured as `(luma, spread)`, 0 (none) to 1 (as much as the knob allows).
 194 ///
 195 /// Contrast is checked at BOTH ends of the spread as well as at the mean, and
 196 /// the worst answer wins: a segment lying half on a black cell and half on a
 197 /// light gap averages to a perfectly comfortable mid-gray while the text is
 198 /// unreadable over one of the two halves. Checking only the mean is the
 199 /// mistake that would make an adaptive scheme look broken exactly where the
 200 /// fixed one already worked.
 201 fn contrast_demand(text_luma: f32, (luma, spread): (u8, u8)) -> f32 {
 202     let mid = luma as f32 / 100.0;
 203     let half = (spread as f32 / 100.0) / 2.0;
 204     let lo = (mid - half).clamp(0.0, 1.0);
 205     let hi = (mid + half).clamp(0.0, 1.0);
 206     contrast_deficit(text_luma, mid)
 207         .max(contrast_deficit(text_luma, lo))
 208         .max(contrast_deficit(text_luma, hi))
 209 }
 210 
 211 /// A droplet spec whose shape knobs resolve against `reference_h` instead of
 212 /// the box they are given, for a box that is actually `box_h` tall.
 213 ///
 214 /// `DropletSpec`'s shape knobs are fractions OF THE BOX HEIGHT, which is what
 215 /// makes one spec survive a change to `module { height }` — the drop looks the
 216 /// same on a 24px bar and a 40px one. The expanded context menu breaks that
 217 /// assumption: it keeps the module's width and grows ten times taller, so the
 218 /// same fractions resolve to a bottom radius that hits the half-width clamp
 219 /// (a literal semicircle under a 10-row window picker) and a top taper eating
 220 /// 145px of a 345px box, while the rows are laid out as a plain rectangle
 221 /// inside it and overhang the silhouette at both ends.
 222 ///
 223 /// Scaling every height-fraction knob by `reference_h / box_h` makes them
 224 /// resolve to the SAME pixel values they would at `reference_h`, so the drop
 225 /// keeps exactly the silhouette it has collapsed and the body extends straight
 226 /// down. At the start of the expansion animation the factor is 1 and this is
 227 /// the identity, so there is nothing to pop.
 228 ///
 229 /// Only the knobs documented as fractions of height are touched. `belly_w` is
 230 /// a fraction of the remaining half-width, and the rest (`clarity`, `dome`,
 231 /// `gleam`, `shine`, `rim`, `curve`, `core`, `refr`, `ghost`, `shadow`) are
 232 /// strengths or exponents with no length in them.
 233 fn spec_at_reference_height(
 234     spec: cce_ui::scene::paint::DropletSpec,
 235     reference_h: f32,
 236     box_h: f32,
 237 ) -> cce_ui::scene::paint::DropletSpec {
 238     if box_h <= reference_h || reference_h <= 0.0 {
 239         return spec;
 240     }
 241     let k = reference_h / box_h;
 242     let mut out = spec;
 243     out.sag *= k;
 244     out.belly *= k;
 245     out.blend *= k;
 246     out.sheet_r *= k;
 247     out.attach *= k;
 248     out.bow *= k;
 249     out.band *= k;
 250     // The dome and its gleam FADE OUT as the box grows past the bar strip:
 251     // dome shading follows the rounded-rect SDF gradient, and on a box with
 252     // long straight sides that field creases along the corner diagonals —
 253     // full strength draws a blocky lit picture-frame (band pinned) or
 254     // envelope folds across the body (band grown); both were tried and read
 255     // as broken lighting rather than water. A tall panel is not a bead: the
 256     // expanded menu settles into a flat glass sheet that keeps the drop's
 257     // OTHER water terms — the thin-edge clarity falloff, the fresnel rim
 258     // crest along the lower arc, the core tint, the contact shadow — which
 259     // are all silhouette-hugging and crease-free. The ramp is continuous in
 260     // k — full lighting collapsed, gone once the box passes twice the bar
 261     // height — so the fade rides the expansion animation with nothing to
 262     // pop, and a real menu (k ≈ 0.1–0.2) lands at exactly zero.
 263     let lit = ((k - 0.5) * 2.0).clamp(0.0, 1.0);
 264     out.dome *= lit;
 265     out.gleam *= lit;
 266     out
 267 }
 268 
 269 /// The color a treatment behind or around `rgb` text should be drawn in:
 270 /// whichever of black/white that text reads against.
 271 ///
 272 /// Used by the scrim, and applied PER RUN rather than from the configured
 273 /// module color, because a module may paint a run in something else entirely
 274 /// — the volume module's muted state uses the shared `disabled_color`. A
 275 /// black pool behind black text is not a weaker treatment, it is an eraser.
 276 pub(crate) fn treatment_rgb(rgb: [u8; 3]) -> [f32; 3] {
 277     let luma = relative_luminance([rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0, 1.0]);
 278     if contrast_ratio(luma, 0.0) >= contrast_ratio(luma, 1.0) {
 279         [0.0, 0.0, 0.0]
 280     } else {
 281         [1.0, 1.0, 1.0]
 282     }
 283 }
 284 
 285 /// The scrim's opacity: it rests at the configured `base` and deepens toward
 286 /// opaque as the measured backdrop demands more. `demand` is the eased
 287 /// `contrast_now`, which is already zero when `module { text_contrast }` is
 288 /// off — so without that knob the scrim is a constant, which is the point of
 289 /// having it.
 290 fn scrim_alpha(base: f32, demand: f32) -> f32 {
 291     (base + (1.0 - base) * demand.clamp(0.0, 1.0)).clamp(0.0, 1.0)
 292 }
 293 
 294 /// How far the pool fades out, logical px. Defaults to a quarter of the
 295 /// bubble's height so the gradient scales with the bar, and is capped at half
 296 /// of each axis: the feather is drawn OUTSIDE the solid core, so the core is
 297 /// inset by this much, and a larger one would invert it and the pool would
 298 /// vanish — exactly where a narrow module (a lone icon) lands.
 299 fn scrim_feather(w: f32, h: f32, configured: Option<f32>) -> f32 {
 300     configured.unwrap_or(h * 0.25).max(0.0).min(w / 2.0).min(h / 2.0)
 301 }
 302 
 303 /// The color of the widest measured text run inside a box, which is the run a
 304 /// box-sized pool is really there to protect. None when the box holds no
 305 /// measured run at all.
 306 ///
 307 /// Width is the tiebreak rather than, say, the first run, because a module
 308 /// that mixes colors (a value in an accent beside its label) is led by its
 309 /// longest label, and that is the one whose legibility carries the segment.
 310 fn dominant_run_color(runs: &[TextPrim], bx: f32, by: f32, bw: f32, bh: f32) -> Option<[u8; 3]> {
 311     let mut best: Option<(f32, [u8; 3])> = None;
 312     for (_, tsize, x, y, color, _, _, _, run_w, _) in runs {
 313         let Some(rw) = *run_w else { continue };
 314         // Runs belong to the box they sit in; a segment with an expanded menu
 315         // has text in both.
 316         let (cx, cy) = (x + rw * 0.5, y + tsize * 0.5);
 317         if cx < bx || cx > bx + bw || cy < by || cy > by + bh {
 318             continue;
 319         }
 320         if best.map_or(true, |(w, _)| rw > w) {
 321             best = Some((rw, *color));
 322         }
 323     }
 324     best.map(|(_, c)| c)
 325 }
 326 
 327 /// The pool color for a box that holds tray icons rather than text. The
 328 /// tray is the one module whose content is not a measured run, and the
 329 /// text-keyed lookup finding nothing used to leave its bubble bare — the
 330 /// only one in the strip painted without the pool, visibly lighter than its
 331 /// neighbors and the only one that never answered the backdrop. The icons
 332 /// read as light glyphs (a dark pixmap is recolored toward white in
 333 /// `TrayModule::render`), so the box gets what a white run would get: a
 334 /// black pool. None when no icon sits in the box.
 335 fn dominant_icon_color(icons: &[TrayIconBounds], bx: f32, by: f32, bw: f32, bh: f32) -> Option<[u8; 3]> {
 336     icons
 337         .iter()
 338         .any(|b| {
 339             let (cx, cy) = (b.x + b.w * 0.5, b.y + b.h * 0.5);
 340             cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh
 341         })
 342         .then_some([255, 255, 255])
 343 }
 344 
 345 /// The in-surface right-click menu: instead of spawning a popup process, the
 346 /// module's own surface EXPANDS below the bar strip to contain the menu. The
 347 /// compositor treats a status segment thicker than the bar as expanded — it
 348 /// keeps the segment's frozen slot, stops enforcing its size, and raises it
 349 /// above the windows the menu overlaps. Pages support DBusMenu submenus:
 350 /// tray icon menus navigate in place (`Submenu`/`Back` rows).
 351 struct ModuleContextMenu {
 352     pages: Vec<MenuPage>,
 353     page: usize,
 354     /// (destination, menu_path) — the DBusMenu owner `Item` rows dispatch
 355     /// to; None for the bar's own module menu.
 356     tray_target: Option<(String, String)>,
 357     min_w: f32,
 358     hovered: Option<usize>,
 359     /// Menu box in surface-local logical coords, set by `rebuild_layout`.
 360     rect: (f32, f32, f32, f32),
 361     /// Per-row (y offset from the menu top, height), parallel to the current
 362     /// page's rows; rebuilt with the layout (rows have mixed heights).
 363     row_bounds: Vec<(f32, f32)>,
 364 }
 365 
 366 impl ModuleContextMenu {
 367     const PAD: f32 = 6.0;
 368     const HEADER_H: f32 = 26.0;
 369     const ITEM_H: f32 = 28.0;
 370     const SEP_H: f32 = 9.0;
 371 
 372     fn rows(&self) -> &[MenuRow] {
 373         self.pages.get(self.page).map(|p| p.rows.as_slice()).unwrap_or(&[])
 374     }
 375 
 376     fn title(&self) -> &str {
 377         self.pages.get(self.page).map(|p| p.title.as_str()).unwrap_or("")
 378     }
 379 
 380     fn height(&self) -> f32 {
 381         let rows: f32 = self
 382             .rows()
 383             .iter()
 384             .map(|r| if r.separator { Self::SEP_H } else { Self::ITEM_H })
 385             .sum();
 386         2.0 * Self::PAD + Self::HEADER_H + rows
 387     }
 388 
 389     fn contains(&self, x: f32, y: f32) -> bool {
 390         let (mx, my, mw, mh) = self.rect;
 391         x >= mx && x <= mx + mw && y >= my && y <= my + mh
 392     }
 393 
 394     /// The interactive row under the pointer (separators, disabled and inert
 395     /// rows never match).
 396     fn item_at(&self, x: f32, y: f32) -> Option<usize> {
 397         if !self.contains(x, y) {
 398             return None;
 399         }
 400         let rel = y - self.rect.1;
 401         self.row_bounds
 402             .iter()
 403             .position(|&(off, h)| rel >= off && rel < off + h)
 404             .filter(|&i| {
 405                 self.rows().get(i).is_some_and(|r| {
 406                     !r.separator && r.enabled && !matches!(r.action, MenuRowAction::Inert)
 407                 })
 408             })
 409     }
 410 }
 411 
 412 pub(crate) fn make_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font_family: &str) -> Buffer {
 413     make_text_buffer_weighted(fs, text, size, font_family, None)
 414 }
 415 
 416 /// `make_text_buffer` at an OpenType weight (`Some(700)` = bold) — the
 417 /// measurement the icon readouts' numbers need, since the engine shapes a
 418 /// weighted run with that face and its advances are the face's, not the
 419 /// regular's.
 420 pub(crate) fn make_text_buffer_weighted(fs: &mut FontSystem, text: &str, size: f32, font_family: &str, weight: Option<u16>) -> Buffer {
 421     let scale = cce_ui::scale::scale_factor();
 422     let mut font_size = size;
 423 
 424     let (parsed_family, parsed_size) = cce_ui::layout::parse_font_string(font_family);
 425     if let Some(ps) = parsed_size {
 426         font_size = ps;
 427     }
 428     let family_name = Some(parsed_family);
 429 
 430     let physical_size = font_size * scale;
 431     let metrics = Metrics::new(physical_size, physical_size * 1.4);
 432     let mut buf = Buffer::new(fs, metrics);
 433     let mut attrs = Attrs::new();
 434     if let Some(w) = weight {
 435         attrs = attrs.weight(cce_ui::cosmic_text::Weight(w));
 436     }
 437     // Same shaping rule as cce-ui's buffer path (ASCII in a mono face →
 438     // Basic, no ligatures), so this measurement agrees with what the engine
 439     // draws — an fi ligature applied on one side only would skew widths by a
 440     // full advance cell.
 441     let mut shaping = cce_ui::cosmic_text::Shaping::Advanced;
 442     if let Some(ref font_name) = family_name {
 443         let family = match font_name.as_str() {
 444             "monospace" => cce_ui::cosmic_text::Family::Name(cce_ui::layout::get_system_monospace_font()),
 445             "sans-serif" => cce_ui::cosmic_text::Family::SansSerif,
 446             "serif" => cce_ui::cosmic_text::Family::Serif,
 447             name => cce_ui::cosmic_text::Family::Name(name),
 448         };
 449         attrs = attrs.family(family);
 450         shaping = cce_ui::engine::shaping_for(fs, text, &family);
 451     }
 452     buf.set_text(fs, text, attrs, shaping);
 453     buf.shape_until_scroll(fs, true);
 454     buf
 455 }
 456 
 457 
 458 
 459 pub struct RectWidget {
 460     pub x: f32, pub y: f32, pub w: f32, pub h: f32,
 461     pub color: [f32; 4],
 462 }
 463 
 464 pub struct RoundedBox {
 465     pub x: f32, pub y: f32, pub w: f32, pub h: f32,
 466     pub radius: f32,
 467     pub color: [f32; 4],
 468     pub corners: (bool, bool, bool, bool),
 469     /// Some((color, thickness)): draw as an outlined shape — `color` fills
 470     /// (pass transparent for an empty ring) and the stroke uses this color
 471     /// and thickness. Skips the box bevel treatment.
 472     pub border: Option<([f32; 4], f32)>,
 473 }
 474 
 475 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 476 pub enum Side {
 477     Left,
 478     Right,
 479 }
 480 
 481 #[derive(Debug, Clone)]
 482 pub struct ModuleBounds {
 483     pub name: String,
 484     pub side: Side,
 485     pub x: f32,
 486     pub w: f32,
 487 }
 488 
 489 
 490 struct StatusApp {
 491     // Status State
 492     layout: String,
 493     title: String,
 494     stats: Option<SystemStats>,
 495     tray_items: HashMap<String, TrayItem>,
 496     cursor_pos: (f64, f64),
 497     hovered_tray_item: Option<String>,
 498     tray_item_bounds: Vec<TrayIconBounds>,
 499 
 500     font_system: FontSystem,
 501     status_bar: cce_ui::widget::Adapted<cce_ui::widget::StatusBar>,
 502 
 503     rects: Vec<RectWidget>,
 504     overlay_rects: Vec<RectWidget>,
 505     rounded_boxes: Vec<RoundedBox>,
 506     /// Module boxes drawn as water droplets instead of `rounded_boxes` entries
 507     /// when `module { droplet }` is configured — (x, y, w, h, color, spec),
 508     /// painted first so module content sits on the drop.
 509     ///
 510     /// The spec rides each box rather than being read from `self.droplet` at
 511     /// paint time because the expanded menu box needs a DIFFERENT one — see
 512     /// `spec_at_reference_height` — and the scrim has to be handed the same
 513     /// spec the drop was drawn with or the two silhouettes disagree.
 514     droplet_boxes: Vec<(f32, f32, f32, f32, [f32; 4], cce_ui::scene::paint::DropletSpec)>,
 515     droplet: Option<cce_ui::scene::paint::DropletSpec>,
 516     /// Adaptive-contrast strength (0 = off) — see
 517     /// `read_text_contrast_from_config`. Deepens the scrim as the measured
 518     /// backdrop demands more; on its own (no `text_scrim`) it makes the scrim
 519     /// appear only when it is needed.
 520     text_contrast: f32,
 521     /// The compositor's last `backdrop` push for this segment: (luma,
 522     /// spread), both 0-100. Starts at the worst case, so a segment that
 523     /// never hears from the compositor errs toward legible rather than
 524     /// toward bare.
 525     backdrop: (u8, u8),
 526     /// Relative luminance of the configured module text color, 0-1. Cached
 527     /// at config-reload time because the contrast decision needs it every
 528     /// frame and the color changes about never.
 529     text_luma: f32,
 530     /// Dark feathered pool behind each module's content (0 = off) — see
 531     /// `read_text_scrim_from_config`. The DE's one text-contrast treatment.
 532     text_scrim: f32,
 533     /// Feather distance for that pool, logical px; None derives it from the
 534     /// box height.
 535     text_scrim_feather: Option<f32>,
 536     /// The contrast demand actually in effect, eased toward the backdrop's
 537     /// in `tick`. Stepping straight to the target makes the scrim pulse as
 538     /// the desktop pans under a segment, which reads as a flicker rather than
 539     /// as an adaptation.
 540     contrast_now: f32,
 541     text_prims: Vec<TextPrim>,
 542     /// The glyphs the stat modules paint their readouts on — see `IconPrim`.
 543     icon_prims: Vec<IconPrim>,
 544 
 545     scale_factor: f64,
 546     width: u32,
 547     height: u32,
 548     needs_rebuild: bool,
 549     box_bevel: Option<StatusBoxBevel>,
 550     box_bevel_depth: f32,
 551     context_menu: Option<ModuleContextMenu>,
 552     /// Expansion progress of the in-surface menu, 0 (strip) → 1 (fully
 553     /// open). Advanced/reversed in `tick`; `rebuild_layout` eases it into
 554     /// the box size, and `desired_size` grows the surface with it.
 555     menu_anim: f32,
 556     /// True while the menu is animating shut; `context_menu` is dropped
 557     /// only when the contraction lands back at the strip.
 558     menu_closing: bool,
 559     /// The drawn bubble's width, eased in `tick` toward `bubble_w_target`
 560     /// (the module's live `content_width`). The slot and the surface hold the
 561     /// stable `width()` — templates and title quantization keep the
 562     /// compositor from ever seeing a resize — while the bubble inside hugs
 563     /// the content, centered on the difference, so side padding stays the
 564     /// configured padding. Easing is what keeps a flapping window title from
 565     /// snapping the bubble edge on every change. 0 = not yet measured (the
 566     /// first rebuild snaps straight to the target). Single-module by
 567     /// construction: a `StatusApp` always hosts exactly one module, so one
 568     /// pair of fields covers "the" bubble.
 569     bubble_w_now: f32,
 570     bubble_w_target: f32,
 571     /// The collapsed bubble actually drawn this rebuild (x, w), slot coords —
 572     /// what the in-surface menu expansion grows out of and contracts back to.
 573     collapsed_box: Option<(f32, f32)>,
 574     /// The hovered menu row's highlight pill (x, y, w, h), set by the menu
 575     /// branch of `rebuild_layout` and drawn in `display_list` AFTER the scrim
 576     /// (so the pool does not darken it) and before the text.
 577     menu_hover_rect: Option<(f32, f32, f32, f32)>,
 578     input_regions: Vec<(i32, i32, i32, i32)>,
 579     module_bounds: Vec<ModuleBounds>,
 580     left_modules: Vec<Box<dyn StatusModule>>,
 581     right_modules: Vec<Box<dyn StatusModule>>,
 582     sender: calloop::channel::Sender<CustomEvent>,
 583     last_config_modified: Option<std::time::SystemTime>,
 584     selected_module_name: Option<String>,
 585     selected_module_side: Option<Side>,
 586     status_hide_mode: bool,
 587     adjust_position_mode: bool,
 588     /// Whether a renderer has been handed to this app yet. The first one is
 589     /// the one `new()`'s glyph uploads are queued for; every later one is a
 590     /// reconnect, and the ids cached in `icons` name images that died with
 591     /// the renderer being replaced — see `renderer_init`.
 592     seen_renderer: bool,
 593 }
 594 
 595 /// The Wayland `app_id` a segment presents — the compositor places segments
 596 /// by it, and it is also how this process names itself to the `backdrop`
 597 /// subscription. A free function because `new()` must be able to spell it
 598 /// before there is a `StatusApp` to ask, and the two spellings below are
 599 /// exactly the kind of thing that drifts when copied.
 600 fn status_app_id(selected: Option<(&str, Side)>) -> String {
 601     let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
 602     let use_interface_prefix = std::path::Path::new(&format!("/tmp/cce-status-interface-{}.sock", display)).exists();
 603     let prefix = if use_interface_prefix { "cce-status-interface" } else { "cce-status" };
 604     match selected {
 605         Some((name, side)) => format!("{}-{:?}-{}", prefix, side, name).to_lowercase(),
 606         None => prefix.to_string(),
 607     }
 608 }
 609 
 610 impl StatusApp {
 611     fn get_app_id(&self) -> String {
 612         let selected = match (&self.selected_module_name, &self.selected_module_side) {
 613             (Some(name), Some(side)) => Some((name.as_str(), *side)),
 614             _ => None,
 615         };
 616         status_app_id(selected)
 617     }
 618 
 619     /// The contrast help this segment's measured backdrop calls for, 0-1.
 620     ///
 621     /// The compositor reports a mean luminance and a spread. Contrast is
 622     /// checked at BOTH ends of that spread as well as at the mean, and the
 623     /// worst answer wins: a segment lying half on a black cell and half on a
 624     /// light gap averages to a perfectly comfortable mid-gray, and the text
 625     /// is still unreadable over one of the two halves. Checking only the mean
 626     /// is the mistake that makes an adaptive scheme look broken exactly where
 627     /// a fixed one already worked.
 628     fn backdrop_contrast_demand(&self) -> f32 {
 629         if self.text_contrast <= 0.0 {
 630             return 0.0;
 631         }
 632         contrast_demand(self.text_luma, self.backdrop) * self.text_contrast
 633     }
 634 
 635     fn is_vertical(&self) -> bool {
 636         // An open in-surface menu makes the surface taller than wide; that
 637         // must not read as a vertical bar (menus only open on horizontal
 638         // segments).
 639         if self.context_menu.is_some() {
 640             return false;
 641         }
 642         let bar_thickness = read_status_height_from_config() as u32;
 643         if self.width == bar_thickness && self.height != bar_thickness {
 644             true
 645         } else if self.height == bar_thickness && self.width != bar_thickness {
 646             false
 647         } else if self.width < self.height {
 648             true
 649         } else if self.width > self.height {
 650             false
 651         } else {
 652             cce_ui::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed)
 653         }
 654     }
 655 
 656     #[allow(unused_assignments)]
 657     fn rebuild_layout(&mut self) {
 658         log::info!("[cce-status-interface] rebuild_layout module={:?} size={}x{}", self.selected_module_name, self.width, self.height);
 659         let is_vertical = self.is_vertical();
 660         cce_ui::IS_VERTICAL.store(is_vertical, std::sync::atomic::Ordering::Relaxed);
 661         let bar_thickness = read_status_height_from_config() as u32;
 662         cce_ui::BAR_THICKNESS.store(bar_thickness, std::sync::atomic::Ordering::Relaxed);
 663 
 664         // Family WITHOUT the embedded size: the toolkit's text pipeline lets
 665         // a size inside the font string ("Chivo Mono 14") override the
 666         // explicit font-size parameter, which would dead-end
 667         // `module { font_size }`. Size is resolved separately below.
 668         let (font_family, _) =
 669             cce_ui::layout::parse_font_string(&read_status_font_from_config());
 670         let font_size = read_status_font_size_from_config();
 671         let padding = read_status_padding_from_config();
 672         let spacing = read_status_module_spacing_from_config();
 673         let normal_color = read_normal_color_from_config().unwrap_or(color::TEXT_FG);
 674         self.text_luma = relative_luminance(normal_color);
 675         let sw_logical = if is_vertical { self.height as f32 } else { self.width as f32 };
 676         let bar_h = if is_vertical { self.width as f32 } else { read_status_height_from_config() };
 677 
 678         self.rects.clear();
 679         self.overlay_rects.clear();
 680         self.rounded_boxes.clear();
 681         self.text_prims.clear();
 682         self.icon_prims.clear();
 683         self.input_regions.clear();
 684         self.module_bounds.clear();
 685         self.tray_item_bounds.clear();
 686         self.collapsed_box = None;
 687         self.menu_hover_rect = None;
 688 
 689         let left_modules = std::mem::take(&mut self.left_modules);
 690         let right_modules = std::mem::take(&mut self.right_modules);
 691 
 692         let box_bg_color = read_status_box_background_color_from_config();
 693         let status_box_radius = read_status_box_corner_radius_from_config();
 694         self.box_bevel = read_status_box_bevel_from_config();
 695         self.box_bevel_depth = read_status_box_bevel_depth_from_config();
 696         self.droplet = read_droplet_from_config();
 697         self.droplet_boxes.clear();
 698         self.text_contrast = read_text_contrast_from_config();
 699         self.text_scrim = read_text_scrim_from_config();
 700         self.text_scrim_feather = read_text_scrim_feather_from_config();
 701 
 702         self.status_bar.set_rect(0.0, 0.0, self.width as f32, self.height as f32);
 703         // The surface itself is transparent: every StatusApp is a single
 704         // `--module` segment (the no-arg form is the launcher daemon and
 705         // never creates a surface), so the only painted background is each
 706         // module's own rounded box.
 707         self.status_bar.set_bg_color([0.0, 0.0, 0.0, 0.0]);
 708 
 709 
 710         let is_single = self.selected_module_name.is_some();
 711         let margin_padding = if is_single { 6.0 } else { 12.0 };
 712 
 713         let mut left_x = margin_padding;
 714         let mut is_first_left = true;
 715         for module in &left_modules {
 716             let w = module.width(
 717                 &self.stats,
 718                 &self.title,
 719                 &mut self.font_system,
 720                 &font_family,
 721                 font_size,
 722                 &self.tray_items,
 723                 padding,
 724             );
 725             if w > 0.0 {
 726                 if !is_first_left {
 727                     left_x += spacing;
 728                 }
 729                 is_first_left = false;
 730 
 731                 // The bubble hugs the LIVE content, centered in the stable
 732                 // slot: `width()` keeps the surface from resizing (the
 733                 // configure-echo jitter), while the drawn box shrinks so the
 734                 // padding on each side of the text is the configured padding
 735                 // rather than padding-plus-template-surplus. The drawn width
 736                 // is `bubble_w_now`, eased toward the live measure in `tick`.
 737                 let cw = module
 738                     .content_width(&self.stats, &self.title, &mut self.font_system, &font_family, font_size, &self.tray_items, padding)
 739                     .min(w);
 740                 self.bubble_w_target = cw;
 741                 if self.bubble_w_now <= 0.0 {
 742                     self.bubble_w_now = cw;
 743                 }
 744                 let bw = self.bubble_w_now.min(w);
 745                 let bx = left_x + (w - bw) / 2.0;
 746                 self.collapsed_box = Some((bx, bw));
 747 
 748                 // With the in-surface menu open the module box is replaced by
 749                 // the unified expanded box drawn in the menu branch below —
 750                 // the module box GROWS into the menu, it doesn't sit atop it.
 751                 if !module.has_custom_background(&self.title) && self.context_menu.is_none() {
 752                     if let Some(color) = box_bg_color {
 753                         if let Some(spec) = self.droplet {
 754                             // Inset from the surface bottom: 1px for the
 755                             // belly silhouette's AA feather, plus the
 756                             // contact shadow's reserved gap.
 757                             let inset = 1.0 + spec.shadow_gap(bar_h);
 758                             self.droplet_boxes.push((bx, 0.0, bw, bar_h - inset, color, spec));
 759                         } else {
 760                             self.rounded_boxes.push(RoundedBox {
 761                                 x: bx,
 762                                 y: 0.0,
 763                                 w: bw,
 764                                 h: bar_h,
 765                                 radius: status_box_radius,
 766                                 color,
 767                                 corners: if self.selected_module_name.is_some() { (true, true, true, true) } else { (false, false, true, true) },
 768                                 border: None,
 769                             });
 770                         }
 771                     }
 772                 }
 773 
 774                 module.render(
 775                     bx,
 776                     bw,
 777                     &self.stats,
 778                     &self.title,
 779                     &mut self.font_system,
 780                     &font_family,
 781                     font_size,
 782                     normal_color,
 783                     bar_h,
 784                     self.scale_factor,
 785                     &mut self.text_prims,
 786                     &mut self.icon_prims,
 787                     &mut self.rects,
 788                     &mut self.overlay_rects,
 789                     &self.tray_items,
 790                     &mut self.tray_item_bounds,
 791                     box_bg_color,
 792                     status_box_radius,
 793                     &mut self.rounded_boxes,
 794                     padding,
 795                 );
 796 
 797                 self.module_bounds.push(ModuleBounds {
 798                     name: module.name().to_string(),
 799                     side: Side::Left,
 800                     x: left_x,
 801                     w,
 802                 });
 803                 self.input_regions.push((left_x.round() as i32, 0, w.round() as i32, bar_h.round() as i32));
 804                 left_x += w;
 805             }
 806         }
 807 
 808         let mut right_x = sw_logical - 12.0;
 809         let mut is_first_right = true;
 810         
 811         for module in right_modules.iter().rev() {
 812             let w = module.width(
 813                 &self.stats,
 814                 &self.title,
 815                 &mut self.font_system,
 816                 &font_family,
 817                 font_size,
 818                 &self.tray_items,
 819                 padding,
 820             );
 821             if w > 0.0 {
 822                 if !is_first_right {
 823                     right_x -= spacing;
 824                 }
 825                 is_first_right = false;
 826 
 827                 right_x -= w;
 828                 self.module_bounds.push(ModuleBounds {
 829                     name: module.name().to_string(),
 830                     side: Side::Right,
 831                     x: right_x,
 832                     w,
 833                 });
 834                 self.input_regions.push((right_x.round() as i32, 0, w.round() as i32, bar_h.round() as i32));
 835 
 836                 // See the left loop: the bubble hugs the eased live content
 837                 // width, centered in the stable slot.
 838                 let cw = module
 839                     .content_width(&self.stats, &self.title, &mut self.font_system, &font_family, font_size, &self.tray_items, padding)
 840                     .min(w);
 841                 self.bubble_w_target = cw;
 842                 if self.bubble_w_now <= 0.0 {
 843                     self.bubble_w_now = cw;
 844                 }
 845                 let bw = self.bubble_w_now.min(w);
 846                 let bx = right_x + (w - bw) / 2.0;
 847                 self.collapsed_box = Some((bx, bw));
 848 
 849                 // See the left loop: an open in-surface menu swaps the module
 850                 // box for the unified expanded box.
 851                 if !module.has_custom_background(&self.title) && self.context_menu.is_none() {
 852                     if let Some(color) = box_bg_color {
 853                         if let Some(spec) = self.droplet {
 854                             // Same bottom inset as the left loop.
 855                             let inset = 1.0 + spec.shadow_gap(bar_h);
 856                             self.droplet_boxes.push((bx, 0.0, bw, bar_h - inset, color, spec));
 857                         } else {
 858                             self.rounded_boxes.push(RoundedBox {
 859                                 x: bx,
 860                                 y: 0.0,
 861                                 w: bw,
 862                                 h: bar_h,
 863                                 radius: status_box_radius,
 864                                 color,
 865                                 corners: if self.selected_module_name.is_some() { (true, true, true, true) } else { (false, false, true, true) },
 866                                 border: None,
 867                             });
 868                         }
 869                     }
 870                 }
 871 
 872                 module.render(
 873                     bx,
 874                     bw,
 875                     &self.stats,
 876                     &self.title,
 877                     &mut self.font_system,
 878                     &font_family,
 879                     font_size,
 880                     normal_color,
 881                     bar_h,
 882                     self.scale_factor,
 883                     &mut self.text_prims,
 884                     &mut self.icon_prims,
 885                     &mut self.rects,
 886                     &mut self.overlay_rects,
 887                     &self.tray_items,
 888                     &mut self.tray_item_bounds,
 889                     box_bg_color,
 890                     status_box_radius,
 891                     &mut self.rounded_boxes,
 892                     padding,
 893                 );
 894 
 895                 if module.name() == "tray" {
 896                     right_x -= padding;
 897                 }
 898             }
 899         }
 900 
 901         // 5c. Tooltip Rendering (if hovered) — suppressed while the
 902         // in-surface menu is open (the tooltip would overlap the menu header).
 903         if let Some(ref hovered_id) = self.hovered_tray_item.clone().filter(|_| self.context_menu.is_none()) {
 904             if let Some(bound) = self.tray_item_bounds.iter().find(|b| &b.id == hovered_id) {
 905                 let clean_tooltip = |title: Option<&str>, dbus_id: Option<&str>, fallback_id: &str| -> String {
 906                     if let Some(t) = title {
 907                         let trimmed = t.trim();
 908                         if !trimmed.is_empty() {
 909                             return trimmed.to_string();
 910                         }
 911                     }
 912                     if let Some(id) = dbus_id {
 913                         let trimmed = id.trim();
 914                         if !trimmed.is_empty() {
 915                             let cleaned = trimmed
 916                                 .split('_')
 917                                 .next()
 918                                 .unwrap_or(trimmed)
 919                                 .split('-')
 920                                 .next()
 921                                 .unwrap_or(trimmed);
 922                             if !cleaned.is_empty() && cleaned.chars().any(|c| c.is_alphabetic()) {
 923                                 let mut chars = cleaned.chars();
 924                                 if let Some(first) = chars.next() {
 925                                     return first.to_uppercase().collect::<String>() + chars.as_str();
 926                                 }
 927                                 return cleaned.to_string();
 928                             }
 929                             return trimmed.to_string();
 930                         }
 931                     }
 932                     let last_segment = fallback_id.split('/').last().unwrap_or(fallback_id);
 933                     let cleaned = last_segment
 934                         .split('_')
 935                         .next()
 936                         .unwrap_or(last_segment)
 937                         .split('-')
 938                         .next()
 939                         .unwrap_or(last_segment);
 940                     if !cleaned.is_empty() && cleaned.chars().any(|c| c.is_alphabetic()) {
 941                         let mut chars = cleaned.chars();
 942                         if let Some(first) = chars.next() {
 943                             return first.to_uppercase().collect::<String>() + chars.as_str();
 944                         }
 945                         return cleaned.to_string();
 946                     }
 947                     last_segment.to_string()
 948                 };
 949 
 950                 let tooltip_text = clean_tooltip(
 951                     bound.title.as_deref(),
 952                     bound.dbus_id.as_deref(),
 953                     bound.id.as_str(),
 954                 );
 955                 let tooltip_font_size = font_size - 1.0;
 956                 let buf = make_text_buffer(&mut self.font_system, &tooltip_text, tooltip_font_size, &font_family);
 957                 let scale = cce_ui::scale::scale_factor();
 958                 let text_w = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / scale;
 959                 let padding = 6.0;
 960 
 961                 let tooltip_w = text_w + padding * 2.0;
 962                 let tooltip_w_h = tooltip_font_size * 1.4 + padding * 2.0;
 963                 let tx = bound.x + (bound.w - tooltip_w) / 2.0;
 964                 let ty = bar_h + 4.0;
 965 
 966                 // Tooltip background
 967                 self.rects.push(RectWidget {
 968                     x: tx,
 969                     y: ty,
 970                     w: tooltip_w,
 971                     h: tooltip_w_h,
 972                     color: [0.08, 0.08, 0.12, 0.95],
 973                 });
 974 
 975                 // Tooltip text
 976                 let _ = buf; // shaped only to measure `text_w` above
 977                 self.text_prims.push((
 978                     tooltip_text.clone(),
 979                     tooltip_font_size,
 980                     tx + padding,
 981                     ty + padding,
 982                     [
 983                         (color::TEXT_FG[0] * 255.0) as u8,
 984                         (color::TEXT_FG[1] * 255.0) as u8,
 985                         (color::TEXT_FG[2] * 255.0) as u8,
 986                     ],
 987                     Some(font_family.clone()),
 988                     None,
 989                     None,
 990                     // No scrim: the tooltip already sits on its own box.
 991                     None,
 992                     None,
 993                 ));
 994             }
 995         }
 996 
 997         if self.selected_module_name.is_some() {
 998             if is_vertical {
 999                 let old_h = self.height;
1000                 self.height = (left_x + margin_padding).round() as u32;
1001                 log::debug!("[module-{}] rebuild_layout vertical: height calculated as {} (was {})", self.selected_module_name.as_deref().unwrap_or("none"), self.height, old_h);
1002                 self.input_regions.clear();
1003                 self.input_regions.push((0, 0, self.width as i32, self.height as i32));
1004             } else {
1005                 let old_w = self.width;
1006                 self.width = (left_x + margin_padding).round() as u32;
1007                 log::debug!("[module-{}] rebuild_layout horizontal: width calculated as {} (was {})", self.selected_module_name.as_deref().unwrap_or("none"), self.width, old_w);
1008                 self.input_regions.clear();
1009                 self.input_regions.push((0, 0, self.width as i32, bar_h.round() as i32));
1010 
1011                 // No open menu: the surface is exactly the bar strip. This
1012                 // reset is what COLLAPSES an expanded surface after the menu
1013                 // closes — the compositor deliberately stops enforcing size
1014                 // while we are thicker than the bar, so nobody else will.
1015                 if self.context_menu.is_none() {
1016                     self.height = bar_h.round() as u32;
1017                 }
1018 
1019                 // In-surface context menu: grow the surface below the bar
1020                 // strip and draw the current page into the retained buffers.
1021                 // The panel reuses the module box pipeline (so box_bevel
1022                 // applies) with rows, separators and a hover highlight on top.
1023                 if let Some(menu) = &mut self.context_menu {
1024                     // The unified box keeps the module box's own margin so the
1025                     // strip band reads as the module box, grown.
1026                     let plate_x = margin_padding;
1027                     let module_box_w = self.width as f32 - 2.0 * plate_x;
1028                     // Wide enough for the longest row label — fixed minimums
1029                     // truncated window titles in the picker.
1030                     let tx_probe = ModuleContextMenu::PAD + 8.0;
1031                     let mut label_w: f32 = 0.0;
1032                     for page_row in menu.rows().to_vec() {
1033                         if !page_row.separator {
1034                             let l = cce_ui::widget::StyledLabel::new_with_family(&mut self.font_system, &page_row.label, font_size, [0.0, 0.0, 0.0, 1.0], &font_family);
1035                             label_w = label_w.max(l.w);
1036                         }
1037                     }
1038                     let menu_w = module_box_w.max(menu.min_w).max(label_w + 2.0 * tx_probe);
1039                     let menu_h = menu.height();
1040                     // Expansion animation: ease `menu_anim` (stepped in tick)
1041                     // into the box, growing width and revealing height from
1042                     // the collapsed module box. Rows keep their final
1043                     // positions and slide into view as the surface bottom
1044                     // edge (which clips them) travels down.
1045                     let t = {
1046                         let a = self.menu_anim.clamp(0.0, 1.0);
1047                         1.0 - (1.0 - a) * (1.0 - a) * (1.0 - a)
1048                     };
1049                     // The expansion grows out of the bubble actually drawn on
1050                     // the strip — which may sit inset in its slot, hugging
1051                     // the live content — not out of the slot itself, so the
1052                     // "module box grows into the menu" continuity holds with
1053                     // content-hugging bubbles. Contraction reverses back to
1054                     // the same collapsed box.
1055                     let (start_x, start_w) = self.collapsed_box.unwrap_or((plate_x, module_box_w));
1056                     let anim_w = start_w + (menu_w - start_w) * t;
1057                     let anim_x = start_x + (plate_x - start_x) * t;
1058                     let reveal_h = menu_h * t;
1059                     menu.rect = (anim_x, bar_h, anim_w, reveal_h);
1060                     self.width = self.width.max((anim_x + anim_w + plate_x).round() as u32);
1061                     self.height = (bar_h + reveal_h).round() as u32;
1062 
1063                     // ONE continuous box in the module's own fill, spanning
1064                     // the strip band and the menu — the module box literally
1065                     // grows into the menu. Inserted at the front so the
1066                     // module's strip content (tray icons, labels)
1067                     // renders on top of its band. In droplet style the drop
1068                     // grows DOWNWARD without growing its curvature: the knobs
1069                     // are re-resolved against the collapsed height, so the
1070                     // taper and the bottom corners stay the size they are on
1071                     // the bar and the sides run straight between them. Letting
1072                     // them scale with the box is what stopped the backdrop
1073                     // conforming to the rows it is behind.
1074                     let menu_color = box_bg_color.unwrap_or([0.055, 0.055, 0.075, 0.97]);
1075                     if let Some(spec) = self.droplet {
1076                         let inset = 1.0 + spec.shadow_gap(bar_h);
1077                         let collapsed_h = bar_h - inset;
1078                         let box_h = bar_h + reveal_h - inset;
1079                         let menu_spec = spec_at_reference_height(spec, collapsed_h, box_h);
1080                         self.droplet_boxes.push((anim_x, 0.0, anim_w, box_h, menu_color, menu_spec));
1081                     } else {
1082                         self.rounded_boxes.insert(0, RoundedBox {
1083                             x: anim_x,
1084                             y: 0.0,
1085                             w: anim_w,
1086                             h: bar_h + reveal_h,
1087                             radius: status_box_radius.max(4.0),
1088                             color: menu_color,
1089                             corners: (true, true, true, true),
1090                             border: None,
1091                         });
1092                     }
1093 
1094                     let text_u8 = [
1095                         (normal_color[0] * 255.0) as u8,
1096                         (normal_color[1] * 255.0) as u8,
1097                         (normal_color[2] * 255.0) as u8,
1098                     ];
1099                     let dim_u8 = [
1100                         (normal_color[0] * 150.0) as u8,
1101                         (normal_color[1] * 150.0) as u8,
1102                         (normal_color[2] * 150.0) as u8,
1103                     ];
1104                     let tx = plate_x + ModuleContextMenu::PAD + 8.0;
1105                     self.text_prims.push((
1106                         menu.title().to_string(),
1107                         font_size,
1108                         tx,
1109                         bar_h + ModuleContextMenu::PAD
1110                             + (ModuleContextMenu::HEADER_H - font_size) / 2.0,
1111                         dim_u8,
1112                         Some(font_family.clone()),
1113                         None,
1114                         None,
1115                         // Menu text sits on the expanded box; no scrim.
1116                         None,
1117                         None,
1118                     ));
1119 
1120                     let rows = menu.rows().to_vec();
1121                     let hovered = menu.hovered;
1122                     let mut bounds = Vec::with_capacity(rows.len());
1123                     let mut off = ModuleContextMenu::PAD + ModuleContextMenu::HEADER_H;
1124                     for (i, row) in rows.iter().enumerate() {
1125                         let h = if row.separator {
1126                             ModuleContextMenu::SEP_H
1127                         } else {
1128                             ModuleContextMenu::ITEM_H
1129                         };
1130                         let iy = bar_h + off;
1131                         if row.separator {
1132                             self.rects.push(RectWidget {
1133                                 x: tx,
1134                                 y: iy + h / 2.0,
1135                                 w: anim_w - 2.0 * (ModuleContextMenu::PAD + 8.0),
1136                                 h: 1.0,
1137                                 color: [0.35, 0.35, 0.42, 0.8],
1138                             });
1139                         } else {
1140                             if hovered == Some(i) && row.enabled {
1141                                 // A rounded pill inset from the panel edge,
1142                                 // drawn post-scrim in display_list — a
1143                                 // square-cornered full-width rect butting
1144                                 // into the rounded silhouette was the last
1145                                 // blocky element of the expanded menu.
1146                                 self.menu_hover_rect =
1147                                     Some((anim_x + 6.0, iy + 1.0, anim_w - 12.0, h - 2.0));
1148                             }
1149                             self.text_prims.push((
1150                                 row.label.clone(),
1151                                 font_size,
1152                                 tx,
1153                                 iy + (h - font_size) / 2.0,
1154                                 if row.enabled { text_u8 } else { dim_u8 },
1155                                 Some(font_family.clone()),
1156                                 None,
1157                                 None,
1158                                 None,
1159                                 None,
1160                             ));
1161                         }
1162                         bounds.push((off, h));
1163                         off += h;
1164                     }
1165                     menu.row_bounds = bounds;
1166 
1167                     self.input_regions.clear();
1168                     self.input_regions.push((0, 0, self.width as i32, self.height as i32));
1169                 }
1170             }
1171         }
1172 
1173         if is_vertical {
1174             // Rotate rounded_boxes
1175             for rb in &mut self.rounded_boxes {
1176                 let old_x = rb.x;
1177                 let old_y = rb.y;
1178                 let old_w = rb.w;
1179                 let old_h = rb.h;
1180                 rb.x = old_y;
1181                 rb.y = old_x;
1182                 rb.w = old_h;
1183                 rb.h = old_w;
1184             }
1185             // Rotate rects
1186             for r in &mut self.rects {
1187                 let old_x = r.x;
1188                 let old_y = r.y;
1189                 let old_w = r.w;
1190                 let old_h = r.h;
1191                 r.x = old_y;
1192                 r.y = old_x;
1193                 r.w = old_h;
1194                 r.h = old_w;
1195             }
1196             // Rotate text prims (swap x/y — tuple fields .2 and .3)
1197             for tp in &mut self.text_prims {
1198                 let old_x = tp.2;
1199                 let old_y = tp.3;
1200                 tp.2 = old_y;
1201                 tp.3 = old_x;
1202             }
1203             // Rotate icon prims (square glyphs, so only the corner moves)
1204             for ip in &mut self.icon_prims {
1205                 let (old_x, old_y, old_w, old_h) = (ip.x, ip.y, ip.w, ip.h);
1206                 ip.x = old_y;
1207                 ip.y = old_x;
1208                 ip.w = old_h;
1209                 ip.h = old_w;
1210             }
1211             // Rotate tray_item_bounds
1212             for tib in &mut self.tray_item_bounds {
1213                 let old_x = tib.x;
1214                 let old_y = tib.y;
1215                 let old_w = tib.w;
1216                 let old_h = tib.h;
1217                 tib.x = old_y;
1218                 tib.y = old_x;
1219                 tib.w = old_h;
1220                 tib.h = old_w;
1221             }
1222         }
1223 
1224         self.left_modules = left_modules;
1225         self.right_modules = right_modules;
1226         self.needs_rebuild = false;
1227     }
1228 
1229     fn trigger_switcher(&mut self, is_switcher_mode: bool) {
1230         // Keyboard alt-tab switching is implemented natively by the compositor
1231         // (bound to super+tab via the window_manager.window_switcher config key).
1232         // Delegate to it so there is a single window-switcher implementation.
1233         if is_switcher_mode {
1234             std::thread::spawn(|| {
1235                 let _ = std::process::Command::new(get_ccectl_cmd())
1236                     .arg("window-switcher")
1237                     .spawn();
1238             });
1239             return;
1240         }
1241 
1242         // Otherwise this is a click on the status-bar "window" module: open
1243         // the click-to-pick window list as an IN-SURFACE menu (the window
1244         // module's own surface expands below the strip).
1245         let thread_sender = self.sender.clone();
1246         std::thread::spawn(move || {
1247             let output = std::process::Command::new(get_ccectl_cmd())
1248                 .args(["windows", "--json"])
1249                 .output();
1250             let windows = if let Ok(out) = output {
1251                 parse_ccectl_windows(&String::from_utf8_lossy(&out.stdout))
1252             } else {
1253                 Vec::new()
1254             };
1255             if windows.is_empty() {
1256                 return;
1257             }
1258             let rows = windows
1259                 .into_iter()
1260                 // The bar's own segments are noise in a window picker.
1261                 .filter(|(_, app_id, _, _)| !app_id.starts_with("cce-status"))
1262                 .map(|(id, app_id, title, _)| {
1263                     let display = if title.is_empty() {
1264                         app_id.clone()
1265                     } else {
1266                         format!("{} ({})", title, app_id)
1267                     };
1268                     MenuRow {
1269                         label: display,
1270                         enabled: true,
1271                         separator: false,
1272                         action: MenuRowAction::Ccectl(vec!["focus-window".to_string(), id]),
1273                     }
1274                 })
1275                 .collect();
1276             let _ = thread_sender.send(CustomEvent::MenuReady {
1277                 title: "Windows".to_string(),
1278                 pages: vec![MenuPage { title: "Windows".to_string(), rows }],
1279                 min_w: 260.0,
1280             });
1281         });
1282     }
1283 }
1284 
1285 
1286 fn get_module_side(name: &str) -> Side {
1287     module_side_from_json(&get_cached_config(), name)
1288 }
1289 
1290 fn module_side_from_json(val: &serde_json::Value, name: &str) -> Side {
1291     if name == "light_source" {
1292         // The light module ignores any status_bar side entry: it sits on
1293         // whichever side the configured light angle points at.
1294         let light_pos = crate::config::light_source_position_from(val);
1295 
1296         let two_pi = 2.0 * std::f32::consts::PI;
1297         let mut angle = light_pos % two_pi;
1298         if angle < 0.0 {
1299             angle += two_pi;
1300         }
1301 
1302         let pi = std::f32::consts::PI;
1303         // Side mapping: Left side is roughly [5pi/8, 11pi/8)
1304         if angle >= 5.0 * pi / 8.0 && angle < 11.0 * pi / 8.0 {
1305             return Side::Left;
1306         } else {
1307             return Side::Right;
1308         }
1309     }
1310 
1311     // Canonical: `layout { status_bar <name>="top-left" }` — the same key the
1312     // compositor persists a super+drag snap into.
1313     let pointer = format!("/layout/status_bar/{}", name);
1314     if let Some(side_val) = val.pointer(&pointer) {
1315         if let Some(side_str) = side_val.as_str() {
1316             match side_str.to_lowercase().as_str() {
1317                 "left" | "top-left" | "bottom-left" | "top-center" | "bottom-center" => return Side::Left,
1318                 "right" | "top-right" | "bottom-right" => return Side::Right,
1319                 _ => {}
1320             }
1321         }
1322     }
1323     match name {
1324         "window" => Side::Left,
1325         _ => Side::Right,
1326     }
1327 }
1328 
1329 fn parse_selected_module_from_args() -> Option<(String, Side)> {
1330     let args: Vec<String> = std::env::args().collect();
1331     for i in 0..args.len() {
1332         if args[i] == "--module" && i + 1 < args.len() {
1333             let name = args[i + 1].clone();
1334             let side = get_module_side(&name);
1335             return Some((name, side));
1336         }
1337     }
1338     None
1339 }
1340 
1341 /// Ask the compositor for the current adjust-position-mode state
1342 /// (`ccectl adjust-position-mode query` → `ok true|false`). `None` when the
1343 /// query fails or the reply is unrecognized. Note: a pre-query compositor
1344 /// treats the `query` argument as a toggle — the two repos ship together.
1345 pub(crate) fn query_adjust_position_mode() -> Option<bool> {
1346     let out = std::process::Command::new(get_ccectl_cmd())
1347         .args(["adjust-position-mode", "query"])
1348         .output()
1349         .ok()?;
1350     match String::from_utf8_lossy(&out.stdout).trim() {
1351         "ok true" => Some(true),
1352         "ok false" => Some(false),
1353         _ => None,
1354     }
1355 }
1356 
1357 /// One window from `ccectl windows` output: (id, app_id, title, focused).
1358 pub(crate) type CcectlWindow = (String, String, String, bool);
1359 
1360 /// Parse one line of `ccectl windows` output. `window id=` and `app_id=` are
1361 /// required; `title="…"` (truncated at the first inner quote — the wire format
1362 /// does not escape) and `focused=` are optional.
1363 pub(crate) fn parse_ccectl_window_line(line: &str) -> Option<CcectlWindow> {
1364     let app_id = {
1365         let idx = line.find("app_id=")?;
1366         let rest = &line[idx + 7..];
1367         let end = rest.find(' ').unwrap_or(rest.len());
1368         rest[..end].to_string()
1369     };
1370 
1371     let title = if let Some(idx) = line.find("title=\"") {
1372         let rest = &line[idx + 7..];
1373         let end = rest.find('"').unwrap_or(rest.len());
1374         rest[..end].to_string()
1375     } else {
1376         "".to_string()
1377     };
1378 
1379     let focused = if let Some(idx) = line.find("focused=") {
1380         let rest = &line[idx + 8..];
1381         let end = rest.find(' ').unwrap_or(rest.len());
1382         rest[..end].trim() == "true"
1383     } else {
1384         false
1385     };
1386 
1387     let id = {
1388         let idx = line.find("window id=")?;
1389         let rest = &line[idx + 10..];
1390         let end = rest.find(' ').unwrap_or(rest.len());
1391         rest[..end].to_string()
1392     };
1393 
1394     Some((id, app_id, title, focused))
1395 }
1396 
1397 /// Parse one line of `ccectl windows --json` output.
1398 pub(crate) fn parse_ccectl_window_json_line(line: &str) -> Option<CcectlWindow> {
1399     let v: serde_json::Value = serde_json::from_str(line).ok()?;
1400     let id = v.get("id")?.as_u64()?.to_string();
1401     let app_id = v.get("app_id")?.as_str()?.to_string();
1402     let title = v.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
1403     let focused = v.get("focused").and_then(|f| f.as_bool()).unwrap_or(false);
1404     Some((id, app_id, title, focused))
1405 }
1406 
1407 /// Parse one `ccectl windows` line in either format — JSON (`--json`) when the
1408 /// compositor supports it, otherwise the legacy text format (an older
1409 /// compositor ignores the `--json` flag and answers in text; titles containing
1410 /// `"` are then truncated at the quote).
1411 pub(crate) fn parse_ccectl_window_any_line(line: &str) -> Option<CcectlWindow> {
1412     if line.trim_start().starts_with('{') {
1413         parse_ccectl_window_json_line(line)
1414     } else {
1415         parse_ccectl_window_line(line)
1416     }
1417 }
1418 
1419 /// Parse `ccectl windows [--json]` output, dropping this app's own surfaces and
1420 /// cce-cloud popups (they should never appear in the window picker).
1421 pub(crate) fn parse_ccectl_windows(output: &str) -> Vec<CcectlWindow> {
1422     output
1423         .lines()
1424         .filter_map(parse_ccectl_window_any_line)
1425         .filter(|(_, app_id, _, _)| {
1426             app_id != "cce-status" && app_id != "cce-status-interface" && app_id != "cce-cloud"
1427         })
1428         .collect()
1429 }
1430 
1431 /// The frame's text as prim data: (text, size, x, y, color_u8, font, bounds, box-layout).
1432 /// The last field is the run's MEASURED width in logical px, when the emitter
1433 /// knew it — `draw_label` always does, since the label was built for its
1434 /// width. It is what lets the text scrim hug the run instead of the whole
1435 /// module box; `None` simply gets no scrim, which is right for the menu and
1436 /// tooltip text that sits on an opaque box already.
1437 /// The field after that is the run's OpenType weight (`Some(700)` = bold),
1438 /// `None` for the face's regular; only the icon readouts' numbers set it.
1439 pub(crate) type TextPrim = (String, f32, f32, f32, [u8; 3], Option<String>, Option<[f32; 4]>, Option<cce_ui::scene::paint::TextLayout>, Option<f32>, Option<u16>);
1440 
1441 /// Emit a measured `StyledLabel` as a text-prim tuple, returning its width (like the legacy
1442 /// `StyledLabel::draw`). The label was built for its width; `into_prim` carries the source
1443 /// text/size/family/box-layout so the engine reshapes it through the shared cache.
1444 pub(crate) fn draw_label(prims: &mut Vec<TextPrim>, label: cce_ui::widget::StyledLabel, x: f32, y: f32) -> f32 {
1445     let w = label.w;
1446     let p = label.into_prim(x, y);
1447     prims.push((p.text, p.size, p.x, p.y, p.color, p.font, None, p.layout, Some(w), None));
1448     w
1449 }
1450 
1451 impl cce_ui::engine::Application for StatusApp {
1452     type Message = CustomEvent;
1453 
1454     fn new(_qh: &wayland_client::QueueHandle<cce_ui::engine::EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
1455         let selected_module = parse_selected_module_from_args();
1456 
1457         let mut left_modules: Vec<Box<dyn StatusModule>> = Vec::new();
1458         let right_modules: Vec<Box<dyn StatusModule>> = Vec::new();
1459 
1460         let mut has_window = false;
1461 
1462         let (ref name, _) = selected_module
1463             .as_ref()
1464             .expect("StatusApp requires --module <name>; the no-arg form runs the launcher daemon");
1465         let module: Box<dyn StatusModule> = match name.as_str() {
1466             "window" => {
1467                 has_window = true;
1468                 Box::new(WindowModule)
1469             }
1470             "tray" => Box::new(TrayModule),
1471             "cpu" => Box::new(CpuModule),
1472             "memory" => Box::new(MemoryModule),
1473             "brightness" => Box::new(BrightnessModule),
1474             "volume" => Box::new(VolumeModule),
1475             "battery" => Box::new(BatteryModule),
1476             "stats" => Box::new(StatsModule),
1477             "clock" => Box::new(ClockModule),
1478             "light_source" => Box::new(LightSourceModule),
1479             _ => panic!("Unknown module: {}", name),
1480         };
1481         left_modules.push(module);
1482 
1483         if has_window {
1484             tokio::spawn(spawn_status_listener("layout".to_string(), sender.clone()));
1485             tokio::spawn(spawn_status_listener("title".to_string(), sender.clone()));
1486         }
1487         // Every module can host an in-surface menu, so every process listens
1488         // for the compositor's click-away dismiss pushes.
1489         tokio::spawn(spawn_status_listener("dismiss".to_string(), sender.clone()));
1490         // ...and every module has text over a backdrop it cannot see, so
1491         // every one asks the compositor what it is sitting on. Subscribed
1492         // unconditionally rather than behind `module { text_contrast }`: the
1493         // knob is re-read live from the config file, and a task spawned once
1494         // in `new()` could not follow it being switched on.
1495         {
1496             let app_id = status_app_id(
1497                 selected_module.as_ref().map(|(name, side)| (name.as_str(), *side)),
1498             );
1499             tokio::spawn(spawn_status_listener(format!("backdrop {}", app_id), sender.clone()));
1500         }
1501         let is_primary_for_switcher = selected_module.as_ref().map_or(true, |(name, _)| name == "window");
1502         if is_primary_for_switcher {
1503             tokio::spawn(spawn_switcher_listener(sender.clone()));
1504         }
1505 
1506         let has_tray = selected_module.as_ref().map_or(true, |(name, _)| name == "tray");
1507         if has_tray {
1508             tokio::spawn(spawn_status_tray(sender.clone()));
1509         }
1510         let has_stats = selected_module.as_ref().map_or(true, |(name, _)| {
1511             name == "stats" || name == "cpu" || name == "memory" || name == "brightness" || name == "volume" || name == "battery" || name == "clock"
1512         });
1513         if has_stats {
1514             tokio::spawn(spawn_system_stats(sender.clone()));
1515         }
1516         // The backlight and the sink are what a keypress moves, so they get a
1517         // fast path alongside the one-second poll — only where a module
1518         // actually paints one of them.
1519         if paints_stat(selected_module.as_ref().map(|(n, _)| n.as_str()), "brightness")
1520             || paints_stat(selected_module.as_ref().map(|(n, _)| n.as_str()), "volume")
1521         {
1522             tokio::spawn(spawn_level_watchers(sender.clone()));
1523         }
1524 
1525         let font_system = cce_ui::create_font_system();
1526 
1527         let mut app = Self {
1528             layout: String::new(),
1529             title: String::new(),
1530             stats: if has_stats { Some(get_initial_stats()) } else { None },
1531             tray_items: HashMap::new(),
1532             cursor_pos: (0.0, 0.0),
1533             hovered_tray_item: None,
1534             tray_item_bounds: Vec::new(),
1535             font_system,
1536             status_bar: cce_ui::widget::StatusBar::new(),
1537             rects: Vec::new(),
1538             overlay_rects: Vec::new(),
1539             rounded_boxes: Vec::new(),
1540             droplet_boxes: Vec::new(),
1541             droplet: None,
1542             text_contrast: 0.0,
1543             backdrop: (50, 100),
1544             text_luma: 0.0,
1545             text_scrim: 0.0,
1546             text_scrim_feather: None,
1547             contrast_now: 0.0,
1548             text_prims: Vec::new(),
1549             icon_prims: Vec::new(),
1550             scale_factor: 1.0,
1551             width: if selected_module.is_some() { 120 } else { 1920 },
1552             height: read_status_height_from_config() as u32,
1553             needs_rebuild: true,
1554             box_bevel: None,
1555             box_bevel_depth: 3.0,
1556             context_menu: None,
1557             menu_anim: 0.0,
1558             menu_closing: false,
1559             menu_hover_rect: None,
1560             bubble_w_now: 0.0,
1561             bubble_w_target: 0.0,
1562             collapsed_box: None,
1563             input_regions: Vec::new(),
1564             module_bounds: Vec::new(),
1565             left_modules,
1566             right_modules,
1567             sender,
1568             last_config_modified: cce_ui::config::config_files_modified(),
1569             selected_module_name: selected_module.as_ref().map(|(n, _)| n.clone()),
1570             selected_module_side: selected_module.as_ref().map(|(_, s)| s.clone()),
1571             status_hide_mode: false,
1572             adjust_position_mode: false,
1573             seen_renderer: false,
1574         };
1575 
1576         app.rebuild_layout();
1577         app
1578     }
1579 
1580 
1581 
1582     fn settings(&self) -> cce_ui::engine::WindowSettings {
1583         let app_id = self.get_app_id();
1584         cce_ui::engine::WindowSettings {
1585             title: "Status Interface".to_string(),
1586             app_id,
1587             width: self.width,
1588             height: self.height,
1589             fullscreen: false,
1590             min_size: None,
1591         }
1592     }
1593 
1594     fn desired_size(&self) -> Option<(u32, u32)> {
1595         if self.selected_module_name.is_some() {
1596             Some((self.width, self.height))
1597         } else {
1598             None
1599         }
1600     }
1601 
1602     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
1603         // Default to redrawing; the high-frequency push events below clear this when
1604         // their value is unchanged, so a once-a-second stats poll (or a repeated
1605         // title push) no longer forces a redraw — and the compositor's
1606         // whole-backdrop blur re-bake — every time.
1607         let mut changed = true;
1608         match msg {
1609             CustomEvent::LayoutUpdated(l) => {
1610                 // Nothing renders the mode in the strip anymore; it is read
1611                 // at menu-open time for the window module's menu row.
1612                 changed = false;
1613                 self.layout = l;
1614             }
1615             CustomEvent::TitleUpdated(t) => {
1616                 changed = self.title != t;
1617                 self.title = t;
1618             }
1619             CustomEvent::SystemStatsUpdated(s) => {
1620                 log::debug!("[module-{}] stats updated, current width={}", self.selected_module_name.as_deref().unwrap_or("none"), self.width);
1621                 let module = self.selected_module_name.as_deref();
1622                 match stats_signature(module, &s) {
1623                     // Module ignores stats (window/tray/light_source): never redraw here.
1624                     None => changed = false,
1625                     Some(new_sig) => {
1626                         let old_sig = self.stats.as_ref().and_then(|o| stats_signature(module, o));
1627                         changed = old_sig.as_deref() != Some(new_sig.as_str());
1628                     }
1629                 }
1630                 self.stats = Some(s);
1631             }
1632             CustomEvent::BrightnessUpdated(b) => {
1633                 log::debug!("[module-{}] fast-path brightness {:?}", self.selected_module_name.as_deref().unwrap_or("none"), b);
1634                 changed = paints_stat(self.selected_module_name.as_deref(), "brightness")
1635                     && self.stats.as_ref().is_some_and(|s| s.brightness != b);
1636                 if let Some(s) = self.stats.as_mut() {
1637                     s.brightness = b;
1638                 }
1639             }
1640             CustomEvent::VolumeUpdated(v) => {
1641                 log::debug!("[module-{}] fast-path volume {:?}", self.selected_module_name.as_deref().unwrap_or("none"), v);
1642                 changed = paints_stat(self.selected_module_name.as_deref(), "volume")
1643                     && self.stats.as_ref().is_some_and(|s| s.volume != v);
1644                 if let Some(s) = self.stats.as_mut() {
1645                     s.volume = v;
1646                 }
1647             }
1648             CustomEvent::TrayUpdated(item) => {
1649                 self.tray_items.insert(item.id.clone(), item);
1650             }
1651             CustomEvent::TrayRemoved(id) => {
1652                 self.tray_items.remove(&id);
1653             }
1654             CustomEvent::MenuReady { title, pages, min_w } => {
1655                 if !pages.is_empty() && !self.is_vertical() {
1656                     let _ = title;
1657                     if self.context_menu.is_none() {
1658                         self.menu_anim = 0.0;
1659                     }
1660                     self.menu_closing = false;
1661                     self.context_menu = Some(ModuleContextMenu {
1662                         pages,
1663                         page: 0,
1664                         tray_target: None,
1665                         min_w,
1666                         hovered: None,
1667                         rect: (0.0, 0.0, 0.0, 0.0),
1668                         row_bounds: Vec::new(),
1669                     });
1670                 } else {
1671                     changed = false;
1672                 }
1673             }
1674             CustomEvent::TrayMenuFetched { destination, menu_path, pages } => {
1675                 if !pages.is_empty() && !self.is_vertical() {
1676                     if self.context_menu.is_none() {
1677                         self.menu_anim = 0.0;
1678                     }
1679                     self.menu_closing = false;
1680                     self.context_menu = Some(ModuleContextMenu {
1681                         pages,
1682                         page: 0,
1683                         tray_target: Some((destination, menu_path)),
1684                         min_w: 260.0,
1685                         hovered: None,
1686                         rect: (0.0, 0.0, 0.0, 0.0),
1687                         row_bounds: Vec::new(),
1688                     });
1689                 } else {
1690                     changed = false;
1691                 }
1692             }
1693             CustomEvent::BackdropUpdated(sample) => {
1694                 if self.backdrop != sample {
1695                     self.backdrop = sample;
1696                     // Only the paint changes, but something has to ask for a
1697                     // frame: `tick` eases toward the new target and nothing
1698                     // else on this segment is animating.
1699                     self.needs_rebuild = true;
1700                 }
1701             }
1702             CustomEvent::SwitcherTriggered => {
1703                 log::debug!("[switcher] SwitcherTriggered event received, calling trigger_switcher");
1704                 self.trigger_switcher(true);
1705             }
1706             CustomEvent::MenuDismiss(pressed_app_id) => {
1707                 // Close-on-click-away, unless the press was on THIS segment
1708                 // (then handle_mouse_input already decided what to do).
1709                 if self.context_menu.is_some()
1710                     && !self.menu_closing
1711                     && pressed_app_id != self.get_app_id()
1712                 {
1713                     self.menu_closing = true;
1714                 } else {
1715                     changed = false;
1716                 }
1717             }
1718             CustomEvent::ToggleHideModules => {
1719                 self.status_hide_mode = !self.status_hide_mode;
1720                 let cmd = if self.status_hide_mode { "true" } else { "false" };
1721                 if let Err(e) = std::process::Command::new(get_ccectl_cmd())
1722                     .args(["status-hide-mode", cmd])
1723                     .status()
1724                 {
1725                     log::warn!("[hide-mode] ccectl status-hide-mode failed: {:?}", e);
1726                 }
1727                 *needs_rebuild = true;
1728             }
1729             CustomEvent::ToggleAdjustPositionMode => {
1730                 // The compositor is the source of truth: sync to its state,
1731                 // then send the flipped value.
1732                 self.adjust_position_mode =
1733                     !query_adjust_position_mode().unwrap_or(self.adjust_position_mode);
1734                 let cmd = if self.adjust_position_mode { "true" } else { "false" };
1735                 if let Err(e) = std::process::Command::new(get_ccectl_cmd())
1736                     .args(["adjust-position-mode", cmd])
1737                     .status()
1738                 {
1739                     log::warn!("[adjust-mode] ccectl adjust-position-mode failed: {:?}", e);
1740                 }
1741                 *needs_rebuild = true;
1742             }
1743         }
1744         if changed {
1745             self.needs_rebuild = true;
1746             *needs_rebuild = true;
1747         }
1748     }
1749 
1750     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1751         // Ease toward what the backdrop currently demands. The measurement
1752         // itself is quantized and only pushed on change, so this is the only
1753         // thing standing between a camera pan and the scrim pulsing on the
1754         // cell edges it crosses.
1755         if self.text_contrast > 0.0 {
1756             const CONTRAST_EASE_S: f32 = 0.12;
1757             let target = self.backdrop_contrast_demand();
1758             if (target - self.contrast_now).abs() > 0.002 {
1759                 let step = (dt / CONTRAST_EASE_S).clamp(0.0, 1.0);
1760                 self.contrast_now += (target - self.contrast_now) * step;
1761                 *needs_rebuild = true;
1762                 self.needs_rebuild = true;
1763             } else if self.contrast_now != target {
1764                 self.contrast_now = target;
1765                 *needs_rebuild = true;
1766                 self.needs_rebuild = true;
1767             }
1768         }
1769 
1770         // Ease the drawn bubble toward its live-content width — the same
1771         // treatment the scrim gets: stepping straight there would snap the
1772         // bubble edges on every stat update or title change, and a flapping
1773         // browser title would make the box twitch instead of breathe.
1774         {
1775             const BUBBLE_EASE_S: f32 = 0.12;
1776             let target = self.bubble_w_target;
1777             if (target - self.bubble_w_now).abs() > 0.5 {
1778                 let step = (dt / BUBBLE_EASE_S).clamp(0.0, 1.0);
1779                 self.bubble_w_now += (target - self.bubble_w_now) * step;
1780                 *needs_rebuild = true;
1781                 self.needs_rebuild = true;
1782             } else if self.bubble_w_now != target && target > 0.0 {
1783                 self.bubble_w_now = target;
1784                 *needs_rebuild = true;
1785                 self.needs_rebuild = true;
1786             }
1787         }
1788 
1789         // In-surface menu expansion/contraction: the surface grows into the
1790         // menu and shrinks back over MENU_ANIM_S, one resize+repaint per
1791         // tick. The menu object is dropped only when the contraction lands.
1792         if self.context_menu.is_some() {
1793             const MENU_ANIM_S: f32 = 0.14;
1794             if self.menu_closing {
1795                 self.menu_anim -= dt / MENU_ANIM_S;
1796                 if self.menu_anim <= 0.0 {
1797                     self.menu_anim = 0.0;
1798                     self.menu_closing = false;
1799                     self.context_menu = None;
1800                 }
1801                 *needs_rebuild = true;
1802                 self.needs_rebuild = true;
1803             } else if self.menu_anim < 1.0 {
1804                 self.menu_anim = (self.menu_anim + dt / MENU_ANIM_S).min(1.0);
1805                 *needs_rebuild = true;
1806                 self.needs_rebuild = true;
1807             }
1808         }
1809 
1810         // Watches the shared config AND the app's own override file (the
1811         // newest mtime of the pair) — same key cce-ui's config cache uses.
1812         let modified = cce_ui::config::config_files_modified();
1813         if modified.is_some() && modified != self.last_config_modified {
1814             self.last_config_modified = modified;
1815             *needs_rebuild = true;
1816             self.needs_rebuild = true;
1817         }
1818     }
1819 
1820     // style-audit: opt-out the bar is a transparent surface; each module box is its own plate
1821 
1822     fn display_list(&mut self, size: cce_ui::engine::LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
1823         // Phase 6ak single paint path: the rounded boxes, the status-bar bg / module rects
1824         // (the legacy view_rounded_quads then view() bodies, in the wrapper's
1825         // order), and the module text (prims, reshaped by the engine cache). overlay_quads
1826         // stays a separate on-top pass. The status bar's own text is never set in this app,
1827         // so it contributes only its background quad.
1828         use cce_ui::scene::layout::Rect;
1829         if self.needs_rebuild || self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale {
1830             self.width = size.width as u32;
1831             self.height = size.height as u32;
1832             self.scale_factor = scale;
1833             cce_ui::scale::set_scale_factor(scale as f32);
1834             self.rebuild_layout();
1835         }
1836         let mut pc = cce_ui::scene::paint::PaintCtx::new();
1837 
1838         // Droplet-style module boxes paint first, so any remaining rounded
1839         // boxes (module-internal chips) and all content sit on the drops.
1840         for &(x, y, w, h, color, spec) in &self.droplet_boxes {
1841             pc.droplet(Rect { x, y, width: w, height: h }, &cce_ui::scene::Material::from_fill(color).with_finish(spec.finish()), spec);
1842         }
1843 
1844         for rb in &self.rounded_boxes {
1845             let rect = Rect { x: rb.x, y: rb.y, width: rb.w, height: rb.h };
1846             // Same positional corner→radius mapping the RoundedRect prim uses.
1847             let radii = (
1848                 if rb.corners.0 { rb.radius } else { 0.0 },
1849                 if rb.corners.1 { rb.radius } else { 0.0 },
1850                 if rb.corners.2 { rb.radius } else { 0.0 },
1851                 if rb.corners.3 { rb.radius } else { 0.0 },
1852             );
1853             // True-shape boxes (the light module's circle): no bevel
1854             // treatment. A full circle draws through the Circle/Arc prims —
1855             // the rounded-rect corner family is the squircle (corner_shape),
1856             // which reads as a rounded SQUARE at half-extent radius — with
1857             // the fill and stroke each optional. Anything else outlined goes
1858             // through the Border prim (fill + stroke).
1859             if let Some((border_color, thickness)) = rb.border {
1860                 if (rb.w - rb.h).abs() < 0.5 && (rb.radius - rb.w / 2.0).abs() < 0.5 {
1861                     let r = rb.w / 2.0;
1862                     if rb.color[3] > 0.001 {
1863                         // With raised module boxes (lit plates), the circle
1864                         // takes the sphere-lit disc — the circular sibling of
1865                         // the plate treatment, same light and material.
1866                         if matches!(self.box_bevel, Some(StatusBoxBevel::Raised)) {
1867                             pc.sphere(rb.x + r, rb.y + r, r, &cce_ui::scene::Material::from_fill(rb.color));
1868                         } else {
1869                             pc.circle(rb.x + r, rb.y + r, r, rb.color);
1870                         }
1871                     }
1872                     if thickness > 0.05 {
1873                         pc.arc(rb.x + r, rb.y + r, r, thickness, 0.0, std::f32::consts::TAU, border_color);
1874                     }
1875                 } else {
1876                     pc.border(rect, radii, rb.color, border_color, thickness);
1877                 }
1878                 continue;
1879             }
1880             match self.box_bevel {
1881                 Some(StatusBoxBevel::Raised) => {
1882                     // A lit plate: fill + rolled lip in one prim.
1883                     pc.bevel(rect, radii, &cce_ui::scene::Material::from_fill(rb.color), self.box_bevel_depth);
1884                 }
1885                 Some(StatusBoxBevel::Inset) => {
1886                     // Recess shades only the rim, so keep the flat fill under it.
1887                     if rb.radius > 0.1 {
1888                         pc.rounded_rect(rect, rb.radius, rb.corners, rb.color);
1889                     } else {
1890                         pc.quad(rect, rb.color);
1891                     }
1892                     pc.recess(rect, radii, self.box_bevel_depth);
1893                 }
1894                 None => {
1895                     if rb.radius > 0.1 {
1896                         pc.rounded_rect(rect, rb.radius, rb.corners, rb.color);
1897                     } else {
1898                         pc.quad(rect, rb.color);
1899                     }
1900                 }
1901             }
1902         }
1903 
1904         // The pool, one per module box, filling the bubble rather than
1905         // hugging the run inside it, so a segment reads as one darkened
1906         // lozenge instead of a pill within a pill.
1907         //
1908         // Two shapes, because "the bubble" is two different things: a droplet
1909         // module gets a pool of the droplet's own silhouette (below), while a
1910         // plain rounded box gets `Prim::Glow` — a feathered aura, solid
1911         // through its core rect and falling off across `reach`, so insetting
1912         // the core by exactly the feather lands the gradient's outer edge on
1913         // the box's own edge.
1914         if self.text_scrim > 0.0 || self.text_contrast > 0.0 {
1915             // Rests at the configured opacity and deepens with the measured
1916             // demand. Either knob alone is meaningful: `text_scrim` with no
1917             // `text_contrast` is a constant ground (the demand stays zero),
1918             // and `text_contrast` with no `text_scrim` is a ground that
1919             // appears only when the backdrop earns it.
1920             let alpha = scrim_alpha(self.text_scrim, self.contrast_now);
1921             let feather_cfg = self.text_scrim_feather;
1922             let runs = &self.text_prims;
1923             let icons = &self.tray_item_bounds;
1924             let pool_in = |pc: &mut cce_ui::scene::paint::PaintCtx, bx: f32, by: f32, bw: f32, bh: f32, radius: f32| {
1925                 // Colored for the text it is protecting — the widest run
1926                 // inside this box, since a box with mixed colors is being
1927                 // led by its longest label. A box holding no measured run
1928                 // but tray icons is grounded for those (light glyphs, so a
1929                 // black pool); a box holding neither gets no pool at all.
1930                 let Some(color) = dominant_run_color(runs, bx, by, bw, bh)
1931                     .or_else(|| dominant_icon_color(icons, bx, by, bw, bh))
1932                 else {
1933                     return;
1934                 };
1935                 let feather = scrim_feather(bw, bh, feather_cfg);
1936                 let core = Rect {
1937                     x: bx + feather,
1938                     y: by + feather,
1939                     width: (bw - feather * 2.0).max(0.0),
1940                     height: (bh - feather * 2.0).max(0.0),
1941                 };
1942                 if core.width <= 0.0 || core.height <= 0.0 {
1943                     return;
1944                 }
1945                 let c = treatment_rgb(color);
1946                 let box_rect = Rect { x: bx, y: by, width: bw, height: bh };
1947                 pc.clip_rounded(box_rect, radius, |pc| {
1948                     pc.glow(core, (radius - feather).max(0.0), feather, [c[0], c[1], c[2], alpha]);
1949                 });
1950             };
1951             // A droplet bubble gets a pool of its OWN silhouette, not a
1952             // rounded-rect stand-in: cce-ui's `Prim::DropletScrim` runs the
1953             // droplet's shader path with the same spec, filled flat and
1954             // feathered inward, so the vignette's edge is the drop's edge by
1955             // construction rather than by approximation.
1956             for &(x, y, w, h, _, spec) in &self.droplet_boxes {
1957                 let rect = Rect { x, y, width: w, height: h };
1958                 let Some(color) = dominant_run_color(runs, x, y, w, h)
1959                     .or_else(|| dominant_icon_color(icons, x, y, w, h))
1960                 else {
1961                     continue;
1962                 };
1963                 let c = treatment_rgb(color);
1964                 let feather = scrim_feather(w, h, feather_cfg);
1965                 pc.droplet_scrim(rect, &cce_ui::scene::Material::from_fill([c[0], c[1], c[2], alpha]), spec, feather);
1966             }
1967             // Everything else is genuinely a rounded rect, so a rounded-rect
1968             // pool IS its exact shape.
1969             for rb in &self.rounded_boxes {
1970                 pool_in(&mut pc, rb.x, rb.y, rb.w, rb.h, rb.radius);
1971             }
1972         }
1973 
1974         let (sb_x, sb_y, sb_w, sb_h) = self.status_bar.rect();
1975         pc.quad(Rect { x: sb_x, y: sb_y, width: sb_w, height: sb_h }, self.status_bar.color());
1976         for r in &self.rects {
1977             pc.quad(Rect { x: r.x, y: r.y, width: r.w, height: r.h }, r.color);
1978         }
1979 
1980         // The hovered menu row's pill: post-scrim so the pool cannot darken
1981         // it, pre-text so the label sits on it.
1982         if let Some((hx, hy, hw, hh)) = self.menu_hover_rect {
1983             pc.rounded_rect(
1984                 Rect { x: hx, y: hy, width: hw, height: hh },
1985                 (hh / 2.0).min(8.0),
1986                 (true, true, true, true),
1987                 [0.23, 0.35, 0.50, 0.55],
1988             );
1989         }
1990 
1991         // Readout glyphs: post-scrim so the pool grounds them like any run.
1992         for ip in &self.icon_prims {
1993             pc.image(ip.image, Rect { x: ip.x, y: ip.y, width: ip.w, height: ip.h }, ip.alpha);
1994         }
1995 
1996         for (text, tsize, x, y, color, font, bounds, layout, _run_w, weight) in &self.text_prims {
1997             let attrs = cce_ui::scene::paint::TextAttrs { italic: false, weight: *weight };
1998             match layout {
1999                 Some(l) => pc.text_boxed(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds, attrs, *l),
2000                 // Glyphs are drawn plain. Contrast is the scrim's job now —
2001                 // it darkens the ground rather than decorating the
2002                 // letterforms, and the two together were always one treatment
2003                 // too many.
2004                 None => pc.text_attrs(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds, attrs),
2005             }
2006         }
2007 
2008         Some(pc.finish())
2009     }
2010 
2011     /// A reconnect is a new session around the SAME app (cce-ui's
2012     /// `window_runner` repairs a lost transport rather than restarting the
2013     /// process), and the renderer is rebuilt with it — so the glyph textures
2014     /// `icons::tinted_icon` cached ids for no longer exist. A draw for an
2015     /// unknown image id is skipped silently, which is why a reconnected bar
2016     /// kept its numbers and lost every glyph. Drop the cache and rebuild, so
2017     /// the next layout uploads into the renderer just created.
2018     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
2019         if std::mem::replace(&mut self.seen_renderer, true) {
2020             crate::icons::drop_textures();
2021             self.needs_rebuild = true;
2022         }
2023     }
2024 
2025     fn display_list_text(&self) -> bool {
2026         true
2027     }
2028 
2029     fn overlay_quads(&mut self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: cce_ui::engine::LogicalSize, _scale: f64) {
2030         for r in &self.overlay_rects {
2031             quads.push((r.x, r.y, r.w, r.h, r.color));
2032         }
2033     }
2034 
2035     fn clear_color(&self) -> [f32; 4] {
2036         [0.0, 0.0, 0.0, 0.0]
2037     }
2038 
2039     fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
2040         Some(self.input_regions.clone())
2041     }
2042 
2043     fn handle_pointer_move(&mut self, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) {
2044         if let Some(menu) = &mut self.context_menu {
2045             let hovered = menu.item_at(pos.x, pos.y);
2046             if hovered != menu.hovered {
2047                 menu.hovered = hovered;
2048                 *needs_rebuild = true;
2049                 self.needs_rebuild = true;
2050             }
2051         }
2052         let (lx, ly) = (pos.x, pos.y);
2053         self.cursor_pos = (lx as f64, ly as f64);
2054 
2055         let mut newly_hovered = None;
2056         for bound in &self.tray_item_bounds {
2057             if lx >= bound.x && lx <= (bound.x + bound.w)
2058                 && ly >= bound.y && ly <= (bound.y + bound.h) {
2059                 newly_hovered = Some(bound.id.clone());
2060                 break;
2061             }
2062         }
2063         if self.hovered_tray_item != newly_hovered {
2064             self.hovered_tray_item = newly_hovered;
2065             *needs_rebuild = true;
2066             self.needs_rebuild = true;
2067         }
2068     }
2069 
2070     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
2071         let (lx, ly) = (pos.x, pos.y);
2072         let is_vertical = self.is_vertical();
2073         let coord = if is_vertical { ly } else { lx };
2074         let cx = lx as f64;
2075         let cy = ly as f64;
2076 
2077         // An open in-surface menu owns every button event: row clicks run
2078         // their action (dispatch / DBusMenu event / page navigation); any
2079         // other press (bar strip, menu padding, right-click) closes.
2080         if self.context_menu.is_some() {
2081             if state != ElementState::Pressed {
2082                 return None;
2083             }
2084             // A menu animating shut is already spoken for — its rows are
2085             // sliding away, so presses neither re-trigger nor re-open.
2086             if self.menu_closing {
2087                 return None;
2088             }
2089             let hit = if button == MouseButton::Left {
2090                 self.context_menu.as_ref().and_then(|m| m.item_at(lx, ly))
2091             } else {
2092                 None
2093             };
2094             let mut result = None;
2095             match hit {
2096                 Some(i) => {
2097                     let menu = self.context_menu.as_mut().unwrap();
2098                     let action = menu.rows().get(i).map(|r| r.action.clone());
2099                     match action {
2100                         Some(MenuRowAction::Dispatch(ev)) => {
2101                             self.menu_closing = true;
2102                             result = Some(ev);
2103                         }
2104                         Some(MenuRowAction::Item(id)) => {
2105                             if let Some((dest, path)) = menu.tray_target.clone() {
2106                                 send_tray_menu_event(dest, path, id);
2107                             }
2108                             self.menu_closing = true;
2109                         }
2110                         Some(MenuRowAction::Submenu(p)) | Some(MenuRowAction::Back(p)) => {
2111                             menu.page = p;
2112                             menu.hovered = None;
2113                         }
2114                         Some(MenuRowAction::Ccectl(args)) => {
2115                             std::thread::spawn(move || {
2116                                 let _ = std::process::Command::new(get_ccectl_cmd())
2117                                     .args(&args)
2118                                     .spawn();
2119                             });
2120                             self.menu_closing = true;
2121                         }
2122                         _ => {}
2123                     }
2124                 }
2125                 None => {
2126                     self.menu_closing = true;
2127                 }
2128             }
2129             self.needs_rebuild = true;
2130             *needs_rebuild = true;
2131             return result;
2132         }
2133 
2134         if state == ElementState::Pressed {
2135             // Check if tray icon was clicked
2136             let mut clicked_tray = None;
2137             for bound in &self.tray_item_bounds {
2138                 if cx >= bound.x as f64 && cx <= (bound.x + bound.w) as f64
2139                     && cy >= bound.y as f64 && cy <= (bound.y + bound.h) as f64 {
2140                     clicked_tray = Some(bound.clone());
2141                     break;
2142                 }
2143             }
2144 
2145             if let Some(bound) = clicked_tray {
2146                 let id = bound.id.clone();
2147                 let btn_code = match button {
2148                     MouseButton::Left => 272,
2149                     MouseButton::Right => 273,
2150                     _ => 0,
2151                 };
2152                 let cx_i = cx as i32;
2153                 let cy_i = cy as i32;
2154                 let thread_sender = self.sender.clone();
2155                 std::thread::spawn(move || {
2156                     let rt = tokio::runtime::Builder::new_current_thread()
2157                         .enable_all()
2158                         .build()
2159                         .unwrap();
2160                     rt.block_on(async move {
2161                         if let Some((destination, path_part)) = id.split_once('/') {
2162                             let path = format!("/{}", path_part);
2163                             match zbus::Connection::session().await {
2164                                 Ok(conn) => {
2165                                     match StatusNotifierItemProxy::builder(&conn)
2166                                         .destination(destination.to_string())
2167                                         .unwrap()
2168                                         .path(path)
2169                                         .unwrap()
2170                                         .build()
2171                                         .await
2172                                     {
2173                                         Ok(proxy) => {
2174                                             let is_menu = proxy.item_is_menu().await.unwrap_or(false);
2175                                             let menu_path = proxy.menu().await.ok();
2176 
2177                                             let should_show_menu = (btn_code == 273 && menu_path.is_some())
2178                                                 || (btn_code == 272 && is_menu && menu_path.is_some());
2179 
2180                                             // In-surface menu: fetch the DBusMenu layout and hand
2181                                             // it to the module's update loop — the tray segment's
2182                                             // own surface expands to show it (no popup process).
2183                                             if should_show_menu {
2184                                                 if let Some(menu_p) = menu_path {
2185                                                     match fetch_tray_menu_pages(&conn, destination, menu_p.as_str()).await {
2186                                                         Ok(pages) if !pages.is_empty() => {
2187                                                             let _ = thread_sender.send(CustomEvent::TrayMenuFetched {
2188                                                                 destination: destination.to_string(),
2189                                                                 menu_path: menu_p.as_str().to_string(),
2190                                                                 pages,
2191                                                             });
2192                                                         }
2193                                                         Ok(_) => log::debug!("[tray-menu] empty menu for {}", destination),
2194                                                         Err(e) => log::warn!("[tray-menu] fetch failed: {:?}", e),
2195                                                     }
2196                                                 }
2197                                             } else if btn_code == 272 {
2198                                                 if let Err(e) = proxy.activate(cx_i, cy_i).await {
2199                                                     log::warn!("[tray-click] Activate failed: {:?}", e);
2200                                                     if let Some(menu_p) = menu_path {
2201                                                         if let Ok(pages) = fetch_tray_menu_pages(&conn, destination, menu_p.as_str()).await {
2202                                                             if !pages.is_empty() {
2203                                                                 let _ = thread_sender.send(CustomEvent::TrayMenuFetched {
2204                                                                     destination: destination.to_string(),
2205                                                                     menu_path: menu_p.as_str().to_string(),
2206                                                                     pages,
2207                                                                 });
2208                                                             }
2209                                                         }
2210                                                     }
2211                                                 }
2212                                             } else if btn_code == 273 {
2213                                                 let _ = proxy.context_menu(cx_i, cy_i).await;
2214                                             }
2215                                         }
2216                                         Err(e) => log::warn!("[tray-click] Failed to build proxy: {:?}", e),
2217                                     }
2218                                 }
2219                                 Err(e) => log::warn!("[tray-click] Failed to connect to session bus: {:?}", e),
2220                             }
2221                         } else {
2222                             log::warn!("[tray-click] Failed to split id: {}", id);
2223                         }
2224                     });
2225                 });
2226                 return None;
2227             }
2228 
2229             if button == MouseButton::Right {
2230                 self.adjust_position_mode =
2231                     query_adjust_position_mode().unwrap_or(self.adjust_position_mode);
2232                 // Find which module was right-clicked
2233                 let mut clicked_module = None;
2234                 for mb in &self.module_bounds {
2235                     if coord >= mb.x && coord <= (mb.x + mb.w) {
2236                         clicked_module = Some(mb.clone());
2237                         break;
2238                     }
2239                 }
2240 
2241                 if let Some(mb) = clicked_module {
2242                     if is_vertical {
2243                         // v1: the in-surface menu only lays out on horizontal
2244                         // segments.
2245                         return None;
2246                     }
2247                     log::debug!("[module-right-click] opening in-surface menu for: {}", mb.name);
2248                     if self.context_menu.is_none() {
2249                         self.menu_anim = 0.0;
2250                     }
2251                     self.menu_closing = false;
2252                     let dispatch_row = |label: &str, ev: CustomEvent| MenuRow {
2253                         label: label.to_string(),
2254                         enabled: true,
2255                         separator: false,
2256                         action: MenuRowAction::Dispatch(ev),
2257                     };
2258                     let mut rows = if self.adjust_position_mode {
2259                         vec![dispatch_row("Done", CustomEvent::ToggleAdjustPositionMode)]
2260                     } else {
2261                         vec![
2262                             dispatch_row(
2263                                 if self.status_hide_mode { "Show Modules" } else { "Hide Modules" },
2264                                 CustomEvent::ToggleHideModules,
2265                             ),
2266                             dispatch_row("Adjust Positions", CustomEvent::ToggleAdjustPositionMode),
2267                         ]
2268                     };
2269                     // The light module's strip presence is just the empty
2270                     // circle; its value lives here in the menu.
2271                     if mb.name == "light_source" {
2272                         rows.insert(0, MenuRow {
2273                             label: format!("{:.2} rad", modules::get_light_source_pos_from_config()),
2274                             enabled: true,
2275                             separator: false,
2276                             action: MenuRowAction::Inert,
2277                         });
2278                     }
2279                     // The window module's strip shows only the title; the
2280                     // focused window's mode lives here in its menu — as a
2281                     // dropdown when the mode is one a user may set, opening
2282                     // a submenu page whose rows run `ccectl set-mode <mode>`
2283                     // on the focused window (status segments never take seat
2284                     // focus, so "focused" is still the real window).
2285                     let mut extra_pages: Vec<MenuPage> = Vec::new();
2286                     if mb.name == "window" && !self.layout.is_empty() {
2287                         const MODES: [&str; 3] = ["Floating", "Tiled", "Fullscreen"];
2288                         if MODES.contains(&self.layout.as_str()) {
2289                             // Page 0 is the root built below; the mode page is
2290                             // the only extra, so it is always page 1.
2291                             rows.insert(0, MenuRow {
2292                                 label: format!("{} >", self.layout),
2293                                 enabled: true,
2294                                 separator: false,
2295                                 action: MenuRowAction::Submenu(1),
2296                             });
2297                             let mut mode_rows = vec![MenuRow {
2298                                 label: "< Back".to_string(),
2299                                 enabled: true,
2300                                 separator: false,
2301                                 action: MenuRowAction::Back(0),
2302                             }];
2303                             for mode in MODES {
2304                                 let current = mode == self.layout;
2305                                 mode_rows.push(MenuRow {
2306                                     label: format!(
2307                                         "{} {}",
2308                                         if current { "[x]" } else { "[ ]" },
2309                                         mode
2310                                     ),
2311                                     // The current mode is a marker, not a
2312                                     // target — disabled rows never match a
2313                                     // click.
2314                                     enabled: !current,
2315                                     separator: false,
2316                                     action: MenuRowAction::Ccectl(vec![
2317                                         "set-mode".to_string(),
2318                                         mode.to_lowercase(),
2319                                     ]),
2320                                 });
2321                             }
2322                             extra_pages.push(MenuPage { title: "Mode".to_string(), rows: mode_rows });
2323                         } else {
2324                             // Internal roles (Popup/Overlay/Status/Utility)
2325                             // and the no-focus "---" stay a plain readout.
2326                             rows.insert(0, MenuRow {
2327                                 label: self.layout.clone(),
2328                                 enabled: true,
2329                                 separator: false,
2330                                 action: MenuRowAction::Inert,
2331                             });
2332                         }
2333                     }
2334                     let mut pages = vec![MenuPage { title: mb.name.clone(), rows }];
2335                     pages.extend(extra_pages);
2336                     self.context_menu = Some(ModuleContextMenu {
2337                         pages,
2338                         page: 0,
2339                         tray_target: None,
2340                         min_w: 190.0,
2341                         hovered: None,
2342                         rect: (0.0, 0.0, 0.0, 0.0),
2343                         row_bounds: Vec::new(),
2344                     });
2345                     self.needs_rebuild = true;
2346                     *needs_rebuild = true;
2347                     return None;
2348                 }
2349             }
2350 
2351             if button == MouseButton::Left {
2352                 let mut clicked_window = false;
2353                 for mb in &self.module_bounds {
2354                     if mb.name == "window" {
2355                         if coord >= mb.x && coord <= (mb.x + mb.w) {
2356                             clicked_window = true;
2357                             break;
2358                         }
2359                     }
2360                 }
2361 
2362                 if clicked_window {
2363                     log::debug!("[window-click] Window module clicked, opening window picker");
2364                     self.trigger_switcher(false);
2365                 }
2366             }
2367         }
2368         None
2369     }
2370 
2371     fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: cce_ui::engine::LogicalPosition, _needs_rebuild: &mut bool) {}
2372 
2373     fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> { None }
2374 }
2375 
2376 
2377 
2378 
2379 
2380 
2381 
2382 
2383 fn main() {
2384     // Info by default; RUST_LOG (e.g. =debug) takes precedence when set.
2385     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
2386 
2387     let args: Vec<String> = std::env::args().collect();
2388     log::info!("cce-status-interface started with args = {:?}", args);
2389     if args.len() > 1 && args[1] == "--trigger-switcher" {
2390         let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
2391         rt.block_on(async {
2392             let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
2393             let socket_path = format!("/tmp/cce-status-interface-switcher-{}.sock", display);
2394             use tokio::io::AsyncWriteExt;
2395             if let Ok(mut stream) = tokio::net::UnixStream::connect(&socket_path).await {
2396                 let _ = stream.write_all(b"trigger\n").await;
2397             }
2398         });
2399         return;
2400     }
2401 
2402     let has_module = args.iter().any(|arg| arg == "--module");
2403 
2404     if !has_module {
2405         log::info!("Starting cce-status-interface launcher daemon...");
2406         let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
2407         rt.block_on(async {
2408             use tokio::signal::unix::{signal, SignalKind};
2409             let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT");
2410             let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM");
2411             
2412             // `stats` is the combined readout segment (cpu, memory,
2413             // brightness, volume, battery in one bubble); the five single
2414             // names remain valid `--module` values but are not launched.
2415             let modules = vec!["window", "tray", "stats", "clock", "light_source"];
2416             let current_exe = std::env::current_exe().unwrap_or_else(|_| {
2417                 std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
2418                     .join(".local/bin/cce-status-interface")
2419             });
2420 
2421             // Restart crashed modules with exponential backoff: a module
2422             // that keeps dying quickly waits longer each time (up to
2423             // RESTART_MAX) instead of respawning twice a second; a run of
2424             // HEALTHY_UPTIME resets its backoff.
2425             const RESTART_BASE: std::time::Duration = std::time::Duration::from_millis(500);
2426             const RESTART_MAX: std::time::Duration = std::time::Duration::from_secs(30);
2427             const HEALTHY_UPTIME: std::time::Duration = std::time::Duration::from_secs(30);
2428 
2429             struct Supervised {
2430                 child: Option<std::process::Child>,
2431                 spawned_at: std::time::Instant,
2432                 backoff: std::time::Duration,
2433                 restart_at: std::time::Instant,
2434             }
2435 
2436             let spawn_module = |module: &str| {
2437                 std::process::Command::new(&current_exe)
2438                     .arg("--module")
2439                     .arg(module)
2440                     .spawn()
2441             };
2442 
2443             let mut supervised: std::collections::HashMap<String, Supervised> = std::collections::HashMap::new();
2444 
2445             for module in &modules {
2446                 let now = std::time::Instant::now();
2447                 let child = match spawn_module(module) {
2448                     Ok(c) => {
2449                         log::info!("Spawned module process for: {}", module);
2450                         Some(c)
2451                     }
2452                     Err(e) => {
2453                         log::error!("Failed to spawn module process for {}: {:?}", module, e);
2454                         None
2455                     }
2456                 };
2457                 supervised.insert(module.to_string(), Supervised {
2458                     child,
2459                     spawned_at: now,
2460                     backoff: RESTART_BASE,
2461                     restart_at: now + RESTART_BASE,
2462                 });
2463             }
2464 
2465             loop {
2466                 tokio::select! {
2467                     _ = sigint.recv() => {
2468                         log::info!("Received SIGINT, shutting down...");
2469                         break;
2470                     }
2471                     _ = sigterm.recv() => {
2472                         log::info!("Received SIGTERM, shutting down...");
2473                         break;
2474                     }
2475                     _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
2476                         let now = std::time::Instant::now();
2477                         for module in &modules {
2478                             let Some(entry) = supervised.get_mut(*module) else { continue };
2479 
2480                             if let Some(child) = entry.child.as_mut() {
2481                                 match child.try_wait() {
2482                                     Ok(None) => continue,
2483                                     Ok(Some(status)) => {
2484                                         entry.child = None;
2485                                         if entry.spawned_at.elapsed() >= HEALTHY_UPTIME {
2486                                             entry.backoff = RESTART_BASE;
2487                                         } else {
2488                                             entry.backoff = (entry.backoff * 2).min(RESTART_MAX);
2489                                         }
2490                                         entry.restart_at = now + entry.backoff;
2491                                         log::warn!(
2492                                             "Module process '{}' exited with status: {:?}. Restarting in {:?}...",
2493                                             module, status, entry.backoff
2494                                         );
2495                                     }
2496                                     Err(e) => {
2497                                         log::error!("Error checking status for module '{}': {:?}", module, e);
2498                                         continue;
2499                                     }
2500                                 }
2501                             }
2502 
2503                             if now >= entry.restart_at {
2504                                 match spawn_module(module) {
2505                                     Ok(c) => {
2506                                         log::info!("Restarted module process for: {}", module);
2507                                         entry.child = Some(c);
2508                                         entry.spawned_at = now;
2509                                     }
2510                                     Err(e) => {
2511                                         entry.backoff = (entry.backoff * 2).min(RESTART_MAX);
2512                                         entry.restart_at = now + entry.backoff;
2513                                         log::error!(
2514                                             "Failed to restart module process for {}: {:?}. Retrying in {:?}...",
2515                                             module, e, entry.backoff
2516                                         );
2517                                     }
2518                                 }
2519                             }
2520                         }
2521                     }
2522                 }
2523             }
2524 
2525             for (module, entry) in supervised {
2526                 if let Some(mut child) = entry.child {
2527                     log::info!("Killing module process: {}", module);
2528                     let _ = child.kill();
2529                 }
2530             }
2531         });
2532         return;
2533     }
2534 
2535     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
2536     let _guard = rt.enter();
2537 
2538     cce_ui::engine::run::<StatusApp>();
2539 }
2540 
2541 
2542 #[cfg(test)]
2543 mod tests {
2544     use super::*;
2545 
2546     // ------------------------------------------------------------------
2547     // Adaptive contrast: the bar cannot see its own backdrop, so these pin
2548     // down what it does with the compositor's measurement of it.
2549     // ------------------------------------------------------------------
2550 
2551     /// Luminance of the black text the droplet style is configured with.
2552     const BLACK_TEXT: f32 = 0.0;
2553     const WHITE_TEXT: f32 = 1.0;
2554 
2555     #[test]
2556     fn dark_text_on_a_light_uniform_backdrop_wants_no_scrim() {
2557         // The case that must stay untouched: the bar already reads fine, so
2558         // an adaptive scheme that decorates it anyway is worse than nothing.
2559         assert_eq!(contrast_demand(BLACK_TEXT, (100, 0)), 0.0);
2560     }
2561 
2562     #[test]
2563     fn dark_text_on_a_dark_uniform_backdrop_wants_a_full_scrim() {
2564         // Black text over a black grid cell — invisible, and the whole
2565         // reason for the feature.
2566         assert_eq!(contrast_demand(BLACK_TEXT, (0, 0)), 1.0);
2567     }
2568 
2569     #[test]
2570     fn light_text_reverses_the_verdict() {
2571         // The decision is about the CONFIGURED color, not a hardcoded
2572         // assumption that module text is dark.
2573         assert_eq!(contrast_demand(WHITE_TEXT, (0, 0)), 0.0);
2574         assert_eq!(contrast_demand(WHITE_TEXT, (100, 0)), 1.0);
2575     }
2576 
2577     #[test]
2578     fn a_comfortable_mean_over_a_split_backdrop_still_wants_a_scrim() {
2579         // Half black cell, half light gap: the mean alone says "mid-gray,
2580         // fine" while the text is invisible over one half. Checking the
2581         // spread's ends is what catches it.
2582         let mean_only = contrast_deficit(BLACK_TEXT, 0.5);
2583         let with_spread = contrast_demand(BLACK_TEXT, (50, 100));
2584         assert!(with_spread > mean_only, "{} !> {}", with_spread, mean_only);
2585         assert_eq!(with_spread, 1.0);
2586     }
2587 
2588     #[test]
2589     fn an_unknown_backdrop_is_treated_as_the_worst_case() {
2590         // What a segment reports before it has heard from the compositor,
2591         // and what an occluding window resolves to: assume unreadable.
2592         assert_eq!(contrast_demand(BLACK_TEXT, (50, 100)), 1.0);
2593     }
2594 
2595     const BLACK: [f32; 3] = [0.0, 0.0, 0.0];
2596     const WHITE: [f32; 3] = [1.0, 1.0, 1.0];
2597 
2598     #[test]
2599     fn a_treatment_contrasts_with_the_text_not_with_a_fixed_assumption() {
2600         // A white outline around white text is not a weaker treatment, it is
2601         // an eraser — which is exactly what a light backdrop got before this
2602         // followed the text color. The same holds for a black pool behind
2603         // black text.
2604         assert_eq!(treatment_rgb([255, 255, 255]), BLACK);
2605         assert_eq!(treatment_rgb([0, 0, 0]), WHITE);
2606     }
2607 
2608     #[test]
2609     fn the_treatment_is_chosen_per_run_so_an_odd_colored_module_is_safe() {
2610         // The volume module paints its muted state in the shared
2611         // disabled_color while every other run is the configured white, and
2612         // the two need not land on the same answer. They happen to today —
2613         // disabled_color is a light red, picked so the muted run keeps the
2614         // same dark pool as its neighbors — so the endpoints below stand in
2615         // for a palette that could part them again.
2616         assert_eq!(treatment_rgb([255, 255, 255]), BLACK);
2617         assert_eq!(treatment_rgb([0, 0, 0]), WHITE);
2618         // A mid accent color still resolves rather than landing in between.
2619         assert!(matches!(treatment_rgb([125, 222, 143]), BLACK | WHITE));
2620     }
2621 
2622     #[test]
2623     fn the_treatment_crossover_follows_the_wcag_curve_not_the_midpoint() {
2624         // Mid-gray (sRGB 128) is luminance ~0.22, which reads better against
2625         // black than white — so the crossover sits well below the halfway
2626         // byte, and picking it by midpoint would give a swathe of grays the
2627         // wrong treatment.
2628         assert_eq!(treatment_rgb([128, 128, 128]), BLACK);
2629         assert_eq!(treatment_rgb([80, 80, 80]), WHITE);
2630     }
2631 
2632     // ------------------------------------------------------------------
2633     // The expanded menu's drop: the module box grows downward, and the
2634     // silhouette must not grow with it.
2635     // ------------------------------------------------------------------
2636 
2637     /// The collapsed drop on a 27px bar, and a 10-row window picker.
2638     const COLLAPSED: (f32, f32) = (90.0, 21.0);
2639     const PICKER: (f32, f32) = (320.0, 339.0);
2640 
2641     #[test]
2642     fn an_expanded_drop_keeps_the_silhouette_it_had_collapsed() {
2643         // The whole point: same taper, same bottom corners, same bow, in
2644         // PIXELS, on a box sixteen times taller. Asserted through
2645         // resolve_silhouette rather than on the knobs, because that is the
2646         // function whose answer the shader actually draws.
2647         let spec = cce_ui::scene::paint::DropletSpec::default();
2648         let (sr0, ar0, bow0) = spec.resolve_silhouette(COLLAPSED.0, COLLAPSED.1);
2649         let grown = spec_at_reference_height(spec, COLLAPSED.1, PICKER.1);
2650         let (sr1, ar1, bow1) = grown.resolve_silhouette(PICKER.0, PICKER.1);
2651         for (a, b, what) in [(sr0, sr1, "sheet_r"), (ar0, ar1, "attach"), (bow0, bow1, "bow")] {
2652             assert!((a - b).abs() < 0.5, "{} drifted: {} vs {}", what, a, b);
2653         }
2654     }
2655 
2656     #[test]
2657     fn the_unscaled_spec_is_what_made_the_backdrop_miss_its_rows() {
2658         // The regression this guards. Left alone, the height fractions put
2659         // the picker's bottom radius on the half-width clamp — a literal
2660         // semicircle — with the rows laid out as a rectangle inside it.
2661         let spec = cce_ui::scene::paint::DropletSpec::default();
2662         let (sr, ar, _) = spec.resolve_silhouette(PICKER.0, PICKER.1);
2663         assert_eq!(sr, PICKER.0 / 2.0, "expected the half-width clamp");
2664         assert!(ar > PICKER.1 * 0.4, "expected the taper to eat the box");
2665     }
2666 
2667     #[test]
2668     fn a_box_no_taller_than_the_reference_is_left_exactly_alone() {
2669         // Every collapsed module box takes this path, and the expansion
2670         // animation starts here — so it has to be the identity, or the drop
2671         // pops on the first frame of opening.
2672         let spec = cce_ui::scene::paint::DropletSpec::default();
2673         assert_eq!(spec_at_reference_height(spec, COLLAPSED.1, COLLAPSED.1), spec);
2674         assert_eq!(spec_at_reference_height(spec, COLLAPSED.1, COLLAPSED.1 - 5.0), spec);
2675         assert_eq!(spec_at_reference_height(spec, 0.0, PICKER.1), spec);
2676     }
2677 
2678     #[test]
2679     fn only_the_knobs_measured_in_height_are_rescaled() {
2680         // The material and the exponents have no length in them: rescaling
2681         // `curve` would change the corner family, `shadow` the contact cue.
2682         let spec = cce_ui::scene::paint::DropletSpec::default();
2683         let grown = spec_at_reference_height(spec, COLLAPSED.1, PICKER.1);
2684         assert_eq!(grown.curve, spec.curve);
2685         assert_eq!(grown.core, spec.core);
2686         assert_eq!(grown.clarity, spec.clarity);
2687         assert_eq!(grown.shine, spec.shine);
2688         assert_eq!(grown.rim, spec.rim);
2689         assert_eq!(grown.shadow, spec.shadow);
2690         // belly_w is a fraction of the remaining half-WIDTH, not of height.
2691         assert_eq!(grown.belly_w, spec.belly_w);
2692         assert!(grown.sheet_r < spec.sheet_r);
2693     }
2694 
2695     #[test]
2696     fn the_expanded_panel_is_a_flat_sheet_with_the_water_terms_kept() {
2697         // The dome and its gleam fade OUT as the box grows: at full strength
2698         // the SDF-gradient dome draws a blocky lit frame (band pinned) or
2699         // envelope folds (band grown) on a long-sided panel — both tried,
2700         // both read as broken lighting. A real menu (k well under 0.5) lands
2701         // at exactly zero: flat glass, keeping clarity, rim, core, shadow.
2702         let spec = cce_ui::scene::paint::DropletSpec::default();
2703         let grown = spec_at_reference_height(spec, COLLAPSED.1, PICKER.1);
2704         assert_eq!(grown.dome, 0.0);
2705         assert_eq!(grown.gleam, 0.0);
2706         assert_eq!(grown.clarity, spec.clarity);
2707         assert_eq!(grown.rim, spec.rim);
2708 
2709         // The fade is CONTINUOUS in the growth factor — the expansion
2710         // animation passes through every k on its way down, so a step
2711         // anywhere would pop mid-flight. Just past the reference it is
2712         // near-identity; by twice the reference it has reached zero.
2713         let barely = spec_at_reference_height(spec, COLLAPSED.1, COLLAPSED.1 + 0.5);
2714         assert!((barely.dome - spec.dome).abs() < 0.1);
2715         let doubled = spec_at_reference_height(spec, COLLAPSED.1, COLLAPSED.1 * 2.0);
2716         assert_eq!(doubled.dome, 0.0);
2717     }
2718 
2719     #[test]
2720     fn the_scrim_is_constant_without_the_adaptive_knob() {
2721         // text_contrast off leaves `demand` at zero, and the scrim is then
2722         // exactly what was configured — a fixed dark ground, which is the
2723         // whole reason it can stand alone as a treatment.
2724         assert_eq!(scrim_alpha(0.55, 0.0), 0.55);
2725         assert_eq!(scrim_alpha(0.0, 0.0), 0.0);
2726     }
2727 
2728     #[test]
2729     fn the_scrim_deepens_with_demand_and_never_thins() {
2730         // Adaptive contrast can only ever darken the ground further; a
2731         // backdrop that needs help must not be able to lighten it.
2732         assert!(scrim_alpha(0.55, 0.5) > 0.55);
2733         assert_eq!(scrim_alpha(0.55, 1.0), 1.0);
2734         assert!(scrim_alpha(0.55, 1.0) >= scrim_alpha(0.55, 0.0));
2735     }
2736 
2737     #[test]
2738     fn the_feather_defaults_to_a_quarter_of_the_bubble_height() {
2739         assert_eq!(scrim_feather(200.0, 28.0, None), 7.0);
2740         assert_eq!(scrim_feather(200.0, 28.0, Some(9.0)), 9.0);
2741         assert_eq!(scrim_feather(200.0, 28.0, Some(-3.0)), 0.0);
2742     }
2743 
2744     #[test]
2745     fn the_feather_cannot_swallow_the_core_it_surrounds() {
2746         // Drawn OUTSIDE the core, so the core is inset by it; a feather past
2747         // half of either axis would invert the core and the pool would
2748         // disappear — exactly where a narrow module (a lone icon) lands.
2749         assert_eq!(scrim_feather(10.0, 28.0, Some(40.0)), 5.0);
2750         assert_eq!(scrim_feather(200.0, 28.0, Some(40.0)), 14.0);
2751     }
2752 
2753     fn run(x: f32, y: f32, w: f32, color: [u8; 3]) -> TextPrim {
2754         ("x".to_string(), 14.0, x, y, color, None, None, None, Some(w), None)
2755     }
2756 
2757     #[test]
2758     fn the_pool_takes_its_color_from_the_widest_run_it_covers() {
2759         let runs = vec![run(20.0, 7.0, 30.0, [255, 255, 255]), run(60.0, 7.0, 90.0, [0, 0, 0])];
2760         assert_eq!(dominant_run_color(&runs, 10.0, 0.0, 200.0, 27.0), Some([0, 0, 0]));
2761     }
2762 
2763     #[test]
2764     fn a_run_in_another_box_does_not_color_this_pool() {
2765         // An expanded segment has text in the strip AND in the menu below it;
2766         // the strip's pool must not be colored by a menu row.
2767         let runs = vec![run(20.0, 7.0, 30.0, [255, 255, 255]), run(20.0, 60.0, 90.0, [0, 0, 0])];
2768         assert_eq!(dominant_run_color(&runs, 10.0, 0.0, 200.0, 27.0), Some([255, 255, 255]));
2769     }
2770 
2771     #[test]
2772     fn a_box_with_no_measured_text_gets_no_pool() {
2773         // The tray is icons; there is no text to ground, and a pool there
2774         // would just be a smudge behind the icons.
2775         let runs: Vec<TextPrim> = vec![("i".to_string(), 14.0, 20.0, 7.0, [255, 255, 255], None, None, None, None, None)];
2776         assert_eq!(dominant_run_color(&runs, 10.0, 0.0, 200.0, 27.0), None);
2777         assert_eq!(dominant_run_color(&[], 10.0, 0.0, 200.0, 27.0), None);
2778     }
2779 
2780     #[test]
2781     fn a_box_of_tray_icons_gets_the_pool_a_white_run_would() {
2782         // The tray draws icons, not measured runs, so the text-keyed lookup
2783         // finds nothing; the icon fallback grounds the box as light glyphs.
2784         let icon = |x: f32| TrayIconBounds {
2785             id: "i".into(), x, y: 5.5, w: 16.0, h: 16.0, title: None, dbus_id: None,
2786         };
2787         let icons = [icon(18.0), icon(42.0)];
2788         assert_eq!(dominant_icon_color(&icons, 10.0, 0.0, 80.0, 27.0), Some([255, 255, 255]));
2789         assert_eq!(treatment_rgb([255, 255, 255]), [0.0, 0.0, 0.0]);
2790         // An icon whose center lies outside the box does not ground it —
2791         // the expanded menu box below the strip holds no icons.
2792         assert_eq!(dominant_icon_color(&icons, 10.0, 27.0, 80.0, 100.0), None);
2793         assert_eq!(dominant_icon_color(&[], 10.0, 0.0, 80.0, 27.0), None);
2794     }
2795 
2796     #[test]
2797     fn contrast_ratio_matches_the_wcag_endpoints() {
2798         assert!((contrast_ratio(0.0, 1.0) - 21.0).abs() < 0.01);
2799         assert!((contrast_ratio(0.5, 0.5) - 1.0).abs() < 0.001);
2800     }
2801 
2802     #[test]
2803     fn parse_backdrop_reads_a_well_formed_line() {
2804         assert_eq!(crate::listeners::parse_backdrop("42 17"), (42, 17));
2805         assert_eq!(crate::listeners::parse_backdrop("  0 0  "), (0, 0));
2806     }
2807 
2808     #[test]
2809     fn parse_backdrop_falls_back_to_the_worst_case_not_the_best() {
2810         // Every unreadable form must fail toward "assume unreadable": a
2811         // fallback of (bright, uniform) would silently switch the treatment
2812         // off, and bare text over an unknown backdrop is the failure this
2813         // whole path exists to prevent.
2814         for line in ["unknown", "", "42", "nonsense here", "42 spread"] {
2815             assert_eq!(crate::listeners::parse_backdrop(line), (50, 100), "line {:?}", line);
2816         }
2817         assert_eq!(contrast_demand(BLACK_TEXT, crate::listeners::parse_backdrop("unknown")), 1.0);
2818     }
2819 
2820     #[test]
2821     fn parse_backdrop_rejects_out_of_range_but_tolerates_extra_fields() {
2822         // Out of protocol is unknown, not clamped — clamping a bad luma to
2823         // 100 would read as "bright and uniform" and switch the scrim off.
2824         assert_eq!(crate::listeners::parse_backdrop("200 200"), (50, 100));
2825         assert_eq!(crate::listeners::parse_backdrop("101 0"), (50, 100));
2826         // Room for the compositor to grow the line without the bar
2827         // misreading it as garbage.
2828         assert_eq!(crate::listeners::parse_backdrop("30 40 future"), (30, 40));
2829     }
2830 
2831     // ------------------------------------------------------------------
2832     // Characterization tests (phase 0): these pin down current behavior
2833     // before the refactors in PROPOSAL.md. Where the behavior is odd, the
2834     // test documents it rather than fixing it. The config-lookup and color
2835     // tests moved to config.rs with the phase-2 rewrite.
2836     // ------------------------------------------------------------------
2837 
2838 
2839 
2840     // --- module_side_from_json ---
2841 
2842     fn side_cfg(name: &str, value: &str) -> serde_json::Value {
2843         serde_json::json!({"layout": {"status_bar": {name: value}}})
2844     }
2845 
2846     #[test]
2847     fn module_side_explicit_values() {
2848         assert_eq!(module_side_from_json(&side_cfg("clock", "left"), "clock"), Side::Left);
2849         assert_eq!(module_side_from_json(&side_cfg("window", "right"), "window"), Side::Right);
2850     }
2851 
2852     #[test]
2853     fn module_side_snap_aliases() {
2854         for (snap, side) in [
2855             ("top-left", Side::Left),
2856             ("bottom-left", Side::Left),
2857             ("top-center", Side::Left),
2858             ("bottom-center", Side::Left),
2859             ("top-right", Side::Right),
2860             ("bottom-right", Side::Right),
2861             ("TOP-LEFT", Side::Left), // case-insensitive
2862         ] {
2863             assert_eq!(module_side_from_json(&side_cfg("cpu", snap), "cpu"), side, "snap {}", snap);
2864         }
2865     }
2866 
2867     #[test]
2868     fn module_side_only_canonical_location_resolves() {
2869         // Only `layout { status_bar <name>=... }` counts; a same-named key
2870         // anywhere else is ignored (the fuzzy search is gone).
2871         let val = serde_json::json!({"stray": {"clock": "left"}});
2872         assert_eq!(module_side_from_json(&val, "clock"), Side::Right);
2873     }
2874 
2875     #[test]
2876     fn module_side_defaults() {
2877         let val = serde_json::json!({});
2878         assert_eq!(module_side_from_json(&val, "window"), Side::Left);
2879         assert_eq!(module_side_from_json(&val, "clock"), Side::Right);
2880         assert_eq!(module_side_from_json(&val, "tray"), Side::Right);
2881     }
2882 
2883     #[test]
2884     fn module_side_unknown_value_falls_through_to_default() {
2885         assert_eq!(module_side_from_json(&side_cfg("window", "sideways"), "window"), Side::Left);
2886         assert_eq!(module_side_from_json(&side_cfg("clock", "sideways"), "clock"), Side::Right);
2887     }
2888 
2889     #[test]
2890     fn module_side_light_source_from_angle() {
2891         // Left iff angle (rad) in [5π/8, 11π/8); default 135° is Left.
2892         let mk = |v: serde_json::Value| serde_json::json!({"window_manager": {"light_source_position": v}});
2893         assert_eq!(module_side_from_json(&serde_json::json!({}), "light_source"), Side::Left);
2894         // Float values are radians.
2895         assert_eq!(
2896             module_side_from_json(&mk(serde_json::json!(std::f64::consts::PI)), "light_source"),
2897             Side::Left
2898         );
2899         assert_eq!(module_side_from_json(&mk(serde_json::json!(0.0)), "light_source"), Side::Right);
2900         // Values > 2π are degrees (int or float), otherwise radians.
2901         assert_eq!(module_side_from_json(&mk(serde_json::json!(180)), "light_source"), Side::Left);
2902         assert_eq!(module_side_from_json(&mk(serde_json::json!(135.0)), "light_source"), Side::Left);
2903         assert_eq!(module_side_from_json(&mk(serde_json::json!(3)), "light_source"), Side::Left);
2904         assert_eq!(module_side_from_json(&mk(serde_json::json!(0)), "light_source"), Side::Right);
2905     }
2906 
2907     // --- parse_ccectl_windows ---
2908 
2909     #[test]
2910     fn ccectl_windows_full_line() {
2911         let out = parse_ccectl_windows(
2912             "window id=3 app_id=firefox title=\"Mozilla Firefox\" focused=true\n\
2913              window id=7 app_id=kitty title=\"~\" focused=false",
2914         );
2915         assert_eq!(out.len(), 2);
2916         assert_eq!(out[0], ("3".into(), "firefox".into(), "Mozilla Firefox".into(), true));
2917         assert_eq!(out[1], ("7".into(), "kitty".into(), "~".into(), false));
2918     }
2919 
2920     #[test]
2921     fn ccectl_windows_missing_required_fields_skips_line() {
2922         // No app_id → skipped; no window id → skipped.
2923         assert!(parse_ccectl_windows("window id=3 title=\"x\"").is_empty());
2924         assert!(parse_ccectl_windows("app_id=firefox title=\"x\"").is_empty());
2925     }
2926 
2927     #[test]
2928     fn ccectl_windows_optional_fields_default() {
2929         let out = parse_ccectl_windows("window id=3 app_id=firefox");
2930         assert_eq!(out.len(), 1);
2931         assert_eq!(out[0], ("3".into(), "firefox".into(), "".into(), false));
2932     }
2933 
2934     #[test]
2935     fn ccectl_windows_filters_own_surfaces() {
2936         let out = parse_ccectl_windows(
2937             "window id=1 app_id=cce-status\n\
2938              window id=2 app_id=cce-status-interface\n\
2939              window id=3 app_id=cce-cloud\n\
2940              window id=4 app_id=firefox",
2941         );
2942         assert_eq!(out.len(), 1);
2943         assert_eq!(out[0].1, "firefox");
2944     }
2945 
2946     #[test]
2947     fn ccectl_windows_title_truncates_at_inner_quote() {
2948         // Known limitation of the legacy text format kept as the fallback for
2949         // pre---json compositors: titles are not escaped, so an inner quote
2950         // truncates the title. The JSON path below handles this correctly.
2951         let out = parse_ccectl_windows("window id=3 app_id=x title=\"say \"hi\"\" focused=false");
2952         assert_eq!(out.len(), 1);
2953         assert_eq!(out[0].2, "say ");
2954     }
2955 
2956     // --- parse_ccectl_windows, JSON format (`windows --json`) ---
2957 
2958     #[test]
2959     fn ccectl_windows_json_full_line() {
2960         let out = parse_ccectl_windows(
2961             r#"{"id":3,"app_id":"firefox","title":"hello","mode":"grid","x":0,"y":0,"w":800,"h":600,"vx":0.0,"vy":0.0,"minimized":false,"has_parent":false,"focused":true,"ssd":false}"#,
2962         );
2963         assert_eq!(out.len(), 1);
2964         assert_eq!(out[0], ("3".to_string(), "firefox".to_string(), "hello".to_string(), true));
2965     }
2966 
2967     #[test]
2968     fn ccectl_windows_json_title_with_quotes_and_spaces() {
2969         // The reason --json exists: titles survive quoting untouched.
2970         let out = parse_ccectl_windows(
2971             r#"{"id":3,"app_id":"x","title":"say \"hi\" title=fake","focused":false}"#,
2972         );
2973         assert_eq!(out.len(), 1);
2974         assert_eq!(out[0].2, "say \"hi\" title=fake");
2975     }
2976 
2977     #[test]
2978     fn ccectl_windows_json_filters_own_surfaces() {
2979         let out = parse_ccectl_windows(
2980             "{\"id\":1,\"app_id\":\"cce-status\",\"title\":\"\",\"focused\":false}\n\
2981              {\"id\":2,\"app_id\":\"cce-cloud\",\"title\":\"\",\"focused\":false}\n\
2982              {\"id\":3,\"app_id\":\"firefox\",\"title\":\"\",\"focused\":false}",
2983         );
2984         assert_eq!(out.len(), 1);
2985         assert_eq!(out[0].1, "firefox");
2986     }
2987 
2988     #[test]
2989     fn ccectl_windows_json_missing_required_fields_skips_line() {
2990         assert!(parse_ccectl_windows(r#"{"app_id":"x","title":"no id"}"#).is_empty());
2991         assert!(parse_ccectl_windows(r#"{"id":3,"title":"no app_id"}"#).is_empty());
2992         assert!(parse_ccectl_windows("{not json").is_empty());
2993     }
2994 
2995     /// The fast path and the full stats push must agree about which modules
2996     /// are blind to a value; a disagreement would redraw a segment for a
2997     /// number it does not paint (and re-bake the compositor's blur with it).
2998     #[test]
2999     fn paints_stat_agrees_with_the_stats_signature() {
3000         let a = SystemStats {
3001             clock: "x".into(),
3002             memory: Some(1),
3003             cpu_pct: Some(1),
3004             battery: Some((1, false)),
3005             volume: Some((Some(10), false)),
3006             brightness: Some(10),
3007         };
3008         for field in ["brightness", "volume"] {
3009             for module in [
3010                 None,
3011                 Some("stats"),
3012                 Some("brightness"),
3013                 Some("volume"),
3014                 Some("clock"),
3015                 Some("cpu"),
3016                 Some("memory"),
3017                 Some("battery"),
3018                 Some("window"),
3019                 Some("tray"),
3020                 Some("light_source"),
3021             ] {
3022                 // Move only `field`, then ask both paths whether it shows.
3023                 let mut b = a.clone();
3024                 match field {
3025                     "brightness" => b.brightness = Some(50),
3026                     _ => b.volume = Some((Some(50), false)),
3027                 }
3028                 let by_signature = stats_signature(module, &a) != stats_signature(module, &b);
3029                 assert_eq!(
3030                     paints_stat(module, field),
3031                     by_signature,
3032                     "module {:?}, field {}",
3033                     module,
3034                     field
3035                 );
3036             }
3037         }
3038     }
3039 
3040     /// The sink and the default-sink change are ours; a single application's
3041     /// stream is not — `sink-input` fires throughout playback and would have
3042     /// us re-reading `pactl` the whole time.
3043     #[test]
3044     fn sink_events_exclude_sink_inputs() {
3045         assert!(is_sink_event("Event 'change' on sink #0"));
3046         assert!(is_sink_event("Event 'change' on server"));
3047         assert!(!is_sink_event("Event 'change' on sink-input #34"));
3048         assert!(!is_sink_event("Event 'new' on source-output #7"));
3049         assert!(!is_sink_event(""));
3050     }
3051 }