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

src/main.rs (122.6K)

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