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