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(¤t_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 }