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

src/modules.rs (37K)

  1 use std::collections::HashMap;
  2 use cce_ui::cosmic_text::FontSystem;
  3 use cce_ui::color;
  4 use cce_ui::widget::StyledLabel as Label;
  5 
  6 use crate::{
  7     RectWidget, RoundedBox, SystemStats, TrayItem,
  8     TrayIconBounds, make_text_buffer,
  9 };
 10 
 11 /// Vertical offset that centers a text run in a box `box_h` tall. The engine
 12 /// shapes horizontal text with a line box of exactly `font_size` (cce-ui
 13 /// window_runner uses line_height = physical_size * 1.0), so centering must
 14 /// use that height — not a CSS-ish 1.4em line box.
 15 pub(crate) fn centered_text_y(box_h: f32, font_size: f32) -> f32 {
 16     (box_h - font_size) / 2.0 - crate::config::read_text_raise_from_config()
 17 }
 18 
 19 pub trait StatusModule {
 20     fn name(&self) -> &'static str;
 21     
 22     fn has_custom_background(&self, _title: &str) -> bool { false }
 23 
 24     fn width(
 25         &self,
 26         stats: &Option<SystemStats>,
 27         title: &str,
 28         font_system: &mut FontSystem,
 29         font_family: &str,
 30         font_size: f32,
 31         tray_items: &HashMap<String, TrayItem>,
 32         padding: f32,
 33     ) -> f32;
 34 
 35     /// Width of the module's LIVE content plus padding — what the drawn
 36     /// bubble hugs. `width()` stays the stable LAYOUT width (widest-plausible
 37     /// templates, title quantization) that sizes the slot and the surface, so
 38     /// the compositor never sees a resize; this one may be narrower, and the
 39     /// bubble is centered in the slot on the difference so the padding on
 40     /// each side of the content is the configured padding rather than
 41     /// padding-plus-template-surplus. Defaults to `width()` for modules whose
 42     /// slot already is their content (clock, tray, light_source).
 43     fn content_width(
 44         &self,
 45         stats: &Option<SystemStats>,
 46         title: &str,
 47         font_system: &mut FontSystem,
 48         font_family: &str,
 49         font_size: f32,
 50         tray_items: &HashMap<String, TrayItem>,
 51         padding: f32,
 52     ) -> f32 {
 53         self.width(stats, title, font_system, font_family, font_size, tray_items, padding)
 54     }
 55 
 56     fn render(
 57         &self,
 58         x: f32,
 59         w: f32,
 60         stats: &Option<SystemStats>,
 61         title: &str,
 62         font_system: &mut FontSystem,
 63         font_family: &str,
 64         font_size: f32,
 65         normal_color: [f32; 4],
 66         bar_h: f32,
 67         scale_factor: f64,
 68         text_prims: &mut Vec<crate::TextPrim>,
 69         icon_prims: &mut Vec<crate::IconPrim>,
 70         rects: &mut Vec<RectWidget>,
 71         overlay_rects: &mut Vec<RectWidget>,
 72         tray_items: &HashMap<String, TrayItem>,
 73         tray_item_bounds: &mut Vec<TrayIconBounds>,
 74         box_bg_color: Option<[f32; 4]>,
 75         status_box_radius: f32,
 76         rounded_boxes: &mut Vec<RoundedBox>,
 77         padding: f32,
 78     );
 79 }
 80 
 81 /// A stat module's width from the WIDER of its live text and a
 82 /// widest-plausible template ("Cpu 100%"), plus padding. Sizing to the live
 83 /// text alone made the surface resize whenever the value crossed a digit
 84 /// boundary ("Cpu 9.9%" ↔ "Cpu 10.2%"), which re-arranged the whole status
 85 /// strip and — through the compositor's configure echo — ping-ponged the
 86 /// module and its neighbors at frame rate (the tray/cpu jitter).
 87 fn stable_text_width(
 88     font_system: &mut FontSystem,
 89     text: &str,
 90     template: &str,
 91     font_size: f32,
 92     font_family: &str,
 93     padding: f32,
 94 ) -> f32 {
 95     let live = Label::new_with_family(font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], font_family).w;
 96     let tmpl = Label::new_with_family(font_system, template, font_size, [0.0, 0.0, 0.0, 1.0], font_family).w;
 97     live.max(tmpl) + 2.0 * padding
 98 }
 99 
100 /// The live half of `stable_text_width`: the text as it is right now, plus
101 /// padding — the content measure `content_width` implementations return.
102 fn live_text_width(
103     font_system: &mut FontSystem,
104     text: &str,
105     font_size: f32,
106     font_family: &str,
107     padding: f32,
108 ) -> f32 {
109     Label::new_with_family(font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], font_family).w
110         + 2.0 * padding
111 }
112 
113 /// Chip text shown by the window module while nothing holds keyboard focus.
114 const NO_FOCUS_TEXT: &str = "no focus";
115 
116 /// The window module's title as displayed: ellipsized past 40 chars. One
117 /// place, because `width`, `content_width` and `render` must all measure the
118 /// same string.
119 fn display_title(title: &str) -> String {
120     if title.chars().count() > 40 {
121         title.chars().take(37).collect::<String>() + "..."
122     } else {
123         title.to_string()
124     }
125 }
126 
127 pub struct WindowModule;
128 
129 impl StatusModule for WindowModule {
130     fn name(&self) -> &'static str { "window" }
131 
132     // No has_custom_background override: the "(none)" chip is an ordinary
133     // bubble (it used to draw its own square-topped box in render, which
134     // ignored the droplet style), and the empty-title state has width 0, so
135     // no box is ever drawn for it anyway.
136 
137     fn width(
138         &self,
139         _stats: &Option<SystemStats>,
140         title: &str,
141         font_system: &mut FontSystem,
142         font_family: &str,
143         font_size: f32,
144         _tray_items: &HashMap<String, TrayItem>,
145         padding: f32,
146     ) -> f32 {
147         let has_title = !title.is_empty() && title != "(none)";
148         if has_title {
149             let total_w = live_text_width(font_system, &display_title(title), font_size, font_family, padding);
150 
151             // Title-mode width quantized UP to a coarse step: titles change
152             // constantly (dirty markers, browser tabs, terminal cwd), and
153             // sizing to the exact text resized this surface on every change —
154             // shoving the neighboring module sideways each time (the
155             // light_source flicker) and, at 372↔456px alternation rates,
156             // feeding the compositor's configure echo loop. Within a bucket a
157             // title change costs nothing. (The drawn bubble hugs the exact
158             // title via `content_width`; the bucket sizes only the surface.)
159             const TITLE_WIDTH_STEP: f32 = 24.0;
160             (total_w / TITLE_WIDTH_STEP).ceil() * TITLE_WIDTH_STEP
161         } else {
162             // "(none)" is the compositor explicitly reporting Focus::None
163             // (keystrokes go nowhere); an empty title is just the feed not
164             // having connected yet, which must not flash the indicator.
165             if title == "(none)" {
166                 live_text_width(font_system, NO_FOCUS_TEXT, font_size, font_family, padding)
167             } else {
168                 0.0
169             }
170         }
171     }
172 
173     fn content_width(
174         &self,
175         _stats: &Option<SystemStats>,
176         title: &str,
177         font_system: &mut FontSystem,
178         font_family: &str,
179         font_size: f32,
180         _tray_items: &HashMap<String, TrayItem>,
181         padding: f32,
182     ) -> f32 {
183         let has_title = !title.is_empty() && title != "(none)";
184         if has_title {
185             live_text_width(font_system, &display_title(title), font_size, font_family, padding)
186         } else if title == "(none)" {
187             live_text_width(font_system, NO_FOCUS_TEXT, font_size, font_family, padding)
188         } else {
189             0.0
190         }
191     }
192 
193     fn render(
194         &self,
195         x: f32,
196         _w: f32,
197         _stats: &Option<SystemStats>,
198         title: &str,
199         font_system: &mut FontSystem,
200         font_family: &str,
201         font_size: f32,
202         normal_color: [f32; 4],
203         bar_h: f32,
204         _scale_factor: f64,
205         text_prims: &mut Vec<crate::TextPrim>,
206         _icon_prims: &mut Vec<crate::IconPrim>,
207         _rects: &mut Vec<RectWidget>,
208         _overlay_rects: &mut Vec<RectWidget>,
209         _tray_items: &HashMap<String, TrayItem>,
210         _tray_item_bounds: &mut Vec<TrayIconBounds>,
211         _box_bg_color: Option<[f32; 4]>,
212         _status_box_radius: f32,
213         _rounded_boxes: &mut Vec<RoundedBox>,
214         padding: f32,
215     ) {
216         let has_title = !title.is_empty() && title != "(none)";
217         if has_title {
218             let label = Label::new_with_family(font_system, &display_title(title), font_size, normal_color, font_family);
219             crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
220         } else if title == "(none)" {
221             // Dim chip signalling that no window has keyboard focus — the
222             // state where typing goes nowhere. Half-alpha text, not
223             // clickable; the bubble behind it is the standard one drawn by
224             // rebuild_layout, same as every module.
225             let mut dim = normal_color;
226             dim[3] *= 0.5;
227             let label = Label::new_with_family(font_system, NO_FOCUS_TEXT, font_size, dim, font_family);
228             crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
229         }
230     }
231 }
232 
233 pub struct ClockModule;
234 
235 impl StatusModule for ClockModule {
236     fn name(&self) -> &'static str { "clock" }
237 
238     fn width(
239         &self,
240         stats: &Option<SystemStats>,
241         _title: &str,
242         font_system: &mut FontSystem,
243         font_family: &str,
244         font_size: f32,
245         _tray_items: &HashMap<String, TrayItem>,
246         padding: f32,
247     ) -> f32 {
248         let text = if let Some(ref s) = stats {
249             &s.clock
250         } else {
251             "Monday, January 01, 2000 00:00 AM"
252         };
253         let label = Label::new_with_family(font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], font_family);
254         label.w + 2.0 * padding
255     }
256 
257     fn render(
258         &self,
259         x: f32,
260         _w: f32,
261         stats: &Option<SystemStats>,
262         _title: &str,
263         font_system: &mut FontSystem,
264         font_family: &str,
265         font_size: f32,
266         normal_color: [f32; 4],
267         bar_h: f32,
268         _scale_factor: f64,
269         text_prims: &mut Vec<crate::TextPrim>,
270         _icon_prims: &mut Vec<crate::IconPrim>,
271         _rects: &mut Vec<RectWidget>,
272         _overlay_rects: &mut Vec<RectWidget>,
273         _tray_items: &HashMap<String, TrayItem>,
274         _tray_item_bounds: &mut Vec<TrayIconBounds>,
275         _box_bg_color: Option<[f32; 4]>,
276         _status_box_radius: f32,
277         _rounded_boxes: &mut Vec<RoundedBox>,
278         padding: f32,
279     ) {
280         if let Some(ref s) = stats {
281             let label = Label::new_with_family(font_system, &s.clock, font_size, normal_color, font_family);
282             crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
283         }
284     }
285 }
286 
287 /// A stat module's readout: a cce-icons glyph with its value beside it —
288 /// the glyph IS the unit, so the number is bare ("87" next to the battery,
289 /// not "Bat 87%"). The glyph is tinted the readout's color; see `icons.rs`
290 /// for why the tint is done here rather than through `cce_ui::upload_icon`.
291 ///
292 /// The pre-glyph text form rides along as the FALLBACK: `tinted_icon` returns
293 /// `None` when the icon set is missing or unparsable, and a readout that
294 /// silently loses its glyph would be a bare number nobody can attribute — so
295 /// it degrades to the old "Cpu 45%" instead.
296 pub(crate) struct IconReadout {
297     pub icon: &'static str,
298     /// The number drawn beside the glyph; `None` draws the glyph alone (a
299     /// value the reader could not produce — cpu with no /proc/stat, a
300     /// sink without a level).
301     pub number: Option<String>,
302     pub color: [f32; 4],
303     pub fallback: String,
304     /// Widest-plausible fallback text, for the stable slot width.
305     pub fallback_template: &'static str,
306 }
307 
308 /// The widest number a percentage readout shows.
309 const NUMBER_TEMPLATE: &str = "100";
310 
311 impl IconReadout {
312     /// The glyph as an uploaded texture plus its LOGICAL size, at
313     /// `module { icon_size }` on the longer side. `None` = no glyph, text
314     /// fallback.
315     fn glyph(&self) -> Option<(u32, f32, f32)> {
316         let scale = cce_ui::scale::scale_factor();
317         let px = (crate::read_icon_size_from_config() * scale).round().max(1.0) as u32;
318         let (image, w, h) = crate::icons::tinted_icon(self.icon, px, crate::icons::tint_of(self.color))?;
319         Some((image, w as f32 / scale, h as f32 / scale))
320     }
321 
322     /// The number's font size — `module { icon_font_size }`, else the
323     /// module font.
324     fn number_size(font_size: f32) -> f32 {
325         crate::read_icon_font_size_from_config(font_size)
326     }
327 
328     /// A number's run width at the readout weight, logical px.
329     fn number_width(font_system: &mut FontSystem, text: &str, size: f32, font_family: &str) -> f32 {
330         let buf = crate::make_text_buffer_weighted(font_system, text, size, font_family, crate::read_icon_weight_from_config());
331         buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / cce_ui::scale::scale_factor()
332     }
333 
334     /// This item's width — glyph, gap and number — with the number as the
335     /// live value (`template` false) or the widest plausible one (`template`
336     /// true, for the stable slot). In text fallback, the fallback string or
337     /// its template.
338     fn item_width(&self, font_system: &mut FontSystem, font_family: &str, font_size: f32, template: bool) -> f32 {
339         match self.glyph() {
340             Some((_, gw, _)) => {
341                 let ns = Self::number_size(font_size);
342                 let nw = if template {
343                     Self::number_width(font_system, NUMBER_TEMPLATE, ns, font_family)
344                 } else {
345                     self.number.as_deref().map_or(0.0, |n| Self::number_width(font_system, n, ns, font_family))
346                 };
347                 if nw > 0.0 { gw + crate::read_icon_gap_from_config() + nw } else { gw }
348             }
349             None => {
350                 let text = if template { self.fallback_template } else { self.fallback.as_str() };
351                 Label::new_with_family(font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], font_family).w
352             }
353         }
354     }
355 
356     /// Draw the item with its left edge at `x`; returns the width drawn.
357     fn render(
358         &self,
359         x: f32,
360         font_system: &mut FontSystem,
361         font_family: &str,
362         font_size: f32,
363         bar_h: f32,
364         text_prims: &mut Vec<crate::TextPrim>,
365         icon_prims: &mut Vec<crate::IconPrim>,
366     ) -> f32 {
367         match self.glyph() {
368             Some((image, gw, gh)) => {
369                 // The same `module { text_raise }` lift every text run gets
370                 // via `centered_text_y` is applied to the glyph too, so
371                 // glyph and number stay level with each other and with the
372                 // neighboring modules' text.
373                 let gy = (bar_h - gh) / 2.0 - crate::config::read_text_raise_from_config();
374                 icon_prims.push(crate::IconPrim {
375                     image,
376                     x,
377                     y: gy,
378                     w: gw,
379                     h: gh,
380                     alpha: crate::read_icon_alpha_from_config(),
381                 });
382                 let mut w = gw;
383                 if let Some(n) = self.number.as_deref() {
384                     let ns = Self::number_size(font_size);
385                     let nw = Self::number_width(font_system, n, ns, font_family);
386                     let nx = x + gw + crate::read_icon_gap_from_config();
387                     text_prims.push((
388                         n.to_string(),
389                         ns,
390                         nx,
391                         centered_text_y(bar_h, ns),
392                         crate::icons::tint_of(self.color),
393                         Some(font_family.to_string()),
394                         None,
395                         None,
396                         Some(nw),
397                         crate::read_icon_weight_from_config(),
398                     ));
399                     w = nx + nw - x;
400                 }
401                 w
402             }
403             None => {
404                 let label = Label::new_with_family(font_system, &self.fallback, font_size, self.color, font_family);
405                 crate::draw_label(text_prims, label, x, centered_text_y(bar_h, font_size))
406             }
407         }
408     }
409 }
410 
411 /// Width of a row of readouts in one bubble: `padding` inside each end,
412 /// `module { icon_spacing }` between items. 0 for an empty row (the module
413 /// hides). `template` sizes every number at its widest, for the stable
414 /// slot; the live row is what the bubble hugs.
415 fn readouts_width(
416     items: &[IconReadout],
417     font_system: &mut FontSystem,
418     font_family: &str,
419     font_size: f32,
420     padding: f32,
421     template: bool,
422 ) -> f32 {
423     if items.is_empty() {
424         return 0.0;
425     }
426     let spacing = crate::read_icon_spacing_from_config();
427     let sum: f32 = items.iter().map(|r| r.item_width(font_system, font_family, font_size, template)).sum();
428     sum + spacing * (items.len() as f32 - 1.0) + 2.0 * padding
429 }
430 
431 /// Draw a row of readouts starting at the bubble's left edge `x`.
432 fn render_readouts(
433     items: &[IconReadout],
434     x: f32,
435     font_system: &mut FontSystem,
436     font_family: &str,
437     font_size: f32,
438     bar_h: f32,
439     text_prims: &mut Vec<crate::TextPrim>,
440     icon_prims: &mut Vec<crate::IconPrim>,
441     padding: f32,
442 ) {
443     let spacing = crate::read_icon_spacing_from_config();
444     let mut cx = x + padding;
445     for (i, r) in items.iter().enumerate() {
446         if i > 0 {
447             cx += spacing;
448         }
449         cx += r.render(cx, font_system, font_family, font_size, bar_h, text_prims, icon_prims);
450     }
451 }
452 
453 /// A module that reads out as one [`IconReadout`]: it only has to say which
454 /// glyph, which number and which color, and the blanket `StatusModule` impl
455 /// below does the shared layout. `None` hides the module (width 0) — a
456 /// machine with no battery or backlight has nothing to read out.
457 pub(crate) trait IconStat {
458     const NAME: &'static str;
459     fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout>;
460 }
461 
462 impl<T: IconStat> StatusModule for T {
463     fn name(&self) -> &'static str { T::NAME }
464 
465     fn width(
466         &self,
467         stats: &Option<SystemStats>,
468         _title: &str,
469         font_system: &mut FontSystem,
470         font_family: &str,
471         font_size: f32,
472         _tray_items: &HashMap<String, TrayItem>,
473         padding: f32,
474     ) -> f32 {
475         // The color only tints the glyph, and the width is the same in any
476         // tint; the readout's own color is applied at render.
477         let items: Vec<_> = T::readout(stats, color::TEXT_FG).into_iter().collect();
478         readouts_width(&items, font_system, font_family, font_size, padding, true)
479             .max(readouts_width(&items, font_system, font_family, font_size, padding, false))
480     }
481 
482     fn content_width(
483         &self,
484         stats: &Option<SystemStats>,
485         _title: &str,
486         font_system: &mut FontSystem,
487         font_family: &str,
488         font_size: f32,
489         _tray_items: &HashMap<String, TrayItem>,
490         padding: f32,
491     ) -> f32 {
492         let items: Vec<_> = T::readout(stats, color::TEXT_FG).into_iter().collect();
493         readouts_width(&items, font_system, font_family, font_size, padding, false)
494     }
495 
496     fn render(
497         &self,
498         x: f32,
499         _w: f32,
500         stats: &Option<SystemStats>,
501         _title: &str,
502         font_system: &mut FontSystem,
503         font_family: &str,
504         font_size: f32,
505         normal_color: [f32; 4],
506         bar_h: f32,
507         _scale_factor: f64,
508         text_prims: &mut Vec<crate::TextPrim>,
509         icon_prims: &mut Vec<crate::IconPrim>,
510         _rects: &mut Vec<RectWidget>,
511         _overlay_rects: &mut Vec<RectWidget>,
512         _tray_items: &HashMap<String, TrayItem>,
513         _tray_item_bounds: &mut Vec<TrayIconBounds>,
514         _box_bg_color: Option<[f32; 4]>,
515         _status_box_radius: f32,
516         _rounded_boxes: &mut Vec<RoundedBox>,
517         padding: f32,
518     ) {
519         let items: Vec<_> = T::readout(stats, normal_color).into_iter().collect();
520         render_readouts(&items, x, font_system, font_family, font_size, bar_h, text_prims, icon_prims, padding);
521     }
522 }
523 
524 /// The combined readout segment: every `IconStat` module's readout in one
525 /// bubble, in the order the compositor used to lay the five separate
526 /// segments out (cpu, memory, brightness, volume, battery). A reader with
527 /// nothing (no battery, no backlight) simply drops out of the row. This is
528 /// what the launcher daemon runs; the five single names stay valid for a
529 /// bar configured to run them separately.
530 pub struct StatsModule;
531 
532 impl StatsModule {
533     fn readouts(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Vec<IconReadout> {
534         [
535             CpuModule::readout(stats, normal_color),
536             MemoryModule::readout(stats, normal_color),
537             BrightnessModule::readout(stats, normal_color),
538             VolumeModule::readout(stats, normal_color),
539             BatteryModule::readout(stats, normal_color),
540         ]
541         .into_iter()
542         .flatten()
543         .collect()
544     }
545 }
546 
547 impl StatusModule for StatsModule {
548     fn name(&self) -> &'static str { "stats" }
549 
550     fn width(
551         &self,
552         stats: &Option<SystemStats>,
553         _title: &str,
554         font_system: &mut FontSystem,
555         font_family: &str,
556         font_size: f32,
557         _tray_items: &HashMap<String, TrayItem>,
558         padding: f32,
559     ) -> f32 {
560         let items = Self::readouts(stats, color::TEXT_FG);
561         readouts_width(&items, font_system, font_family, font_size, padding, true)
562             .max(readouts_width(&items, font_system, font_family, font_size, padding, false))
563     }
564 
565     fn content_width(
566         &self,
567         stats: &Option<SystemStats>,
568         _title: &str,
569         font_system: &mut FontSystem,
570         font_family: &str,
571         font_size: f32,
572         _tray_items: &HashMap<String, TrayItem>,
573         padding: f32,
574     ) -> f32 {
575         let items = Self::readouts(stats, color::TEXT_FG);
576         readouts_width(&items, font_system, font_family, font_size, padding, false)
577     }
578 
579     fn render(
580         &self,
581         x: f32,
582         _w: f32,
583         stats: &Option<SystemStats>,
584         _title: &str,
585         font_system: &mut FontSystem,
586         font_family: &str,
587         font_size: f32,
588         normal_color: [f32; 4],
589         bar_h: f32,
590         _scale_factor: f64,
591         text_prims: &mut Vec<crate::TextPrim>,
592         icon_prims: &mut Vec<crate::IconPrim>,
593         _rects: &mut Vec<RectWidget>,
594         _overlay_rects: &mut Vec<RectWidget>,
595         _tray_items: &HashMap<String, TrayItem>,
596         _tray_item_bounds: &mut Vec<TrayIconBounds>,
597         _box_bg_color: Option<[f32; 4]>,
598         _status_box_radius: f32,
599         _rounded_boxes: &mut Vec<RoundedBox>,
600         padding: f32,
601     ) {
602         let items = Self::readouts(stats, normal_color);
603         render_readouts(&items, x, font_system, font_family, font_size, bar_h, text_prims, icon_prims, padding);
604     }
605 }
606 
607 pub struct BatteryModule;
608 
609 impl IconStat for BatteryModule {
610     const NAME: &'static str = "battery";
611 
612     fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
613         let (cap, charging) = match stats {
614             Some(s) => s.battery?,
615             None => (100, false),
616         };
617         // Accent while charging or nearly flat; charging also swaps in the
618         // bolt glyph, the icon form of the text readout's "⚡" prefix.
619         let color = if !charging && cap > 10 { normal_color } else { color::TEXT_ACCENT };
620         Some(IconReadout {
621             icon: if charging { "battery-charging" } else { "battery" },
622             number: Some(cap.to_string()),
623             color,
624             fallback: format!("{} {cap}%", if charging { "⚡" } else { "Bat" }),
625             fallback_template: "Bat 100%",
626         })
627     }
628 }
629 
630 pub struct VolumeModule;
631 
632 impl IconStat for VolumeModule {
633     const NAME: &'static str = "volume";
634 
635     fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
636         let (pct, muted) = match stats {
637             Some(s) => s.volume?,
638             None => (Some(100), false),
639         };
640         let color = if muted {
641             crate::read_disabled_color_from_config().unwrap_or(color::TEXT_DIM)
642         } else {
643             normal_color
644         };
645         let fallback = match (muted, pct) {
646             (_, Some(p)) => format!("Vol {p}%"),
647             (true, None) => "Vol Muted".to_string(),
648             (false, None) => "Vol N/A".to_string(),
649         };
650         Some(IconReadout {
651             icon: if muted { "volume-muted" } else { "volume" },
652             number: pct.map(|p| p.to_string()),
653             color,
654             fallback,
655             fallback_template: "Vol 100%",
656         })
657     }
658 }
659 
660 pub struct BrightnessModule;
661 
662 impl IconStat for BrightnessModule {
663     const NAME: &'static str = "brightness";
664 
665     fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
666         let pct = match stats {
667             Some(s) => s.brightness?,
668             None => 100,
669         };
670         Some(IconReadout {
671             icon: "brightness",
672             number: Some(pct.to_string()),
673             color: normal_color,
674             fallback: format!("Bri {pct}%"),
675             fallback_template: "Bri 100%",
676         })
677     }
678 }
679 
680 pub struct MemoryModule;
681 
682 impl IconStat for MemoryModule {
683     const NAME: &'static str = "memory";
684 
685     fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
686         let pct = match stats {
687             Some(s) => s.memory,
688             None => Some(0),
689         };
690         Some(IconReadout {
691             icon: "memory",
692             number: pct.map(|p| p.to_string()),
693             color: normal_color,
694             fallback: pct.map_or("Mem N/A".to_string(), |p| format!("Mem {p}%")),
695             fallback_template: "Mem 100%",
696         })
697     }
698 }
699 
700 pub struct CpuModule;
701 
702 impl IconStat for CpuModule {
703     const NAME: &'static str = "cpu";
704 
705     fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
706         let pct = match stats {
707             Some(s) => s.cpu_pct,
708             None => Some(0),
709         };
710         Some(IconReadout {
711             icon: "cpu",
712             number: pct.map(|p| p.to_string()),
713             color: normal_color,
714             fallback: pct.map_or("Cpu N/A".to_string(), |p| format!("Cpu {p}%")),
715             fallback_template: "Cpu 100%",
716         })
717     }
718 }
719 
720 pub struct TrayModule;
721 
722 impl StatusModule for TrayModule {
723     fn name(&self) -> &'static str { "tray" }
724 
725     fn width(
726         &self,
727         _stats: &Option<SystemStats>,
728         _title: &str,
729         _font_system: &mut FontSystem,
730         _font_family: &str,
731         _font_size: f32,
732         tray_items: &HashMap<String, TrayItem>,
733         padding: f32,
734     ) -> f32 {
735         if tray_items.is_empty() {
736             0.0
737         } else {
738             let len = tray_items.len() as f32;
739             (len * 16.0) + ((len - 1.0) * 8.0) + 2.0 * padding
740         }
741     }
742 
743     fn render(
744         &self,
745         x: f32,
746         _w: f32,
747         _stats: &Option<SystemStats>,
748         _title: &str,
749         font_system: &mut FontSystem,
750         font_family: &str,
751         font_size: f32,
752         _normal_color: [f32; 4],
753         bar_h: f32,
754         scale_factor: f64,
755         text_prims: &mut Vec<crate::TextPrim>,
756         _icon_prims: &mut Vec<crate::IconPrim>,
757         _rects: &mut Vec<RectWidget>,
758         overlay_rects: &mut Vec<RectWidget>,
759         tray_items: &HashMap<String, TrayItem>,
760         tray_item_bounds: &mut Vec<TrayIconBounds>,
761         _box_bg_color: Option<[f32; 4]>,
762         _status_box_radius: f32,
763         _rounded_boxes: &mut Vec<RoundedBox>,
764         padding: f32,
765     ) {
766         if tray_items.is_empty() {
767             return;
768         }
769         let mut sorted_tray: Vec<&TrayItem> = tray_items.values().collect();
770         sorted_tray.sort_by_key(|item| &item.id);
771 
772         for (i, item) in sorted_tray.iter().enumerate() {
773             let icon_size = crate::config::TRAY_ICON_SIZE;
774             let icon_x = x + padding + (i as f32) * (icon_size + 8.0);
775             // The same `module { text_raise }` lift every text run gets via
776             // `centered_text_y` — without it the icons sit at geometric
777             // center while neighboring modules' text rides `text_raise`
778             // higher, and the tray reads as low.
779             let icon_y = (bar_h - icon_size) / 2.0 - crate::config::read_text_raise_from_config();
780 
781             tray_item_bounds.push(TrayIconBounds {
782                 id: item.id.clone(),
783                 x: icon_x,
784                 y: icon_y,
785                 w: icon_size,
786                 h: icon_size,
787                 title: item.title.clone(),
788                 dbus_id: item.dbus_id.clone(),
789             });
790 
791             let mut drawn_pixmap = false;
792             if let Some(ref pixmaps) = item.pixmaps {
793                 if !pixmaps.is_empty() {
794                     let target_pixel_width = (icon_size * scale_factor as f32) as i32;
795                     if let Some(pixmap) = pixmaps.iter().min_by_key(|p| (p.width - target_pixel_width).abs()) {
796                         if pixmap.width > 0 && pixmap.height > 0 {
797                             let mut total_brightness = 0.0;
798                             let mut visible_pixel_count = 0;
799                             for row in 0..pixmap.height {
800                                 for col in 0..pixmap.width {
801                                     let idx = ((row * pixmap.width + col) * 4) as usize;
802                                     if idx + 3 < pixmap.pixels.len() {
803                                         let a = pixmap.pixels[idx] as f32 / 255.0;
804                                         if a > 0.1 {
805                                             let r = pixmap.pixels[idx + 1] as f32 / 255.0;
806                                             let g = pixmap.pixels[idx + 2] as f32 / 255.0;
807                                             let b = pixmap.pixels[idx + 3] as f32 / 255.0;
808                                             total_brightness += (r + g + b) / 3.0;
809                                             visible_pixel_count += 1;
810                                         }
811                                     }
812                                 }
813                             }
814                             
815                             let avg_brightness = if visible_pixel_count > 0 {
816                                 total_brightness / visible_pixel_count as f32
817                             } else {
818                                 0.5
819                             };
820                             let recolor_light = avg_brightness < 0.35;
821 
822                             let mut draw_w = pixmap.width;
823                             let mut draw_h = pixmap.height;
824                             if draw_w > 48 {
825                                 draw_w = 48;
826                                 draw_h = 48;
827                             }
828                             let pixel_w = icon_size / draw_w as f32;
829                             let pixel_h = icon_size / draw_h as f32;
830                             for row in 0..draw_h {
831                                 for col in 0..draw_w {
832                                     let src_row = row * pixmap.height / draw_h;
833                                     let src_col = col * pixmap.width / draw_w;
834                                     let idx = ((src_row * pixmap.width + src_col) * 4) as usize;
835                                     if idx + 3 < pixmap.pixels.len() {
836                                         let a = pixmap.pixels[idx] as f32 / 255.0;
837                                         if a > 0.0 {
838                                             let mut r = pixmap.pixels[idx + 1] as f32 / 255.0;
839                                             let mut g = pixmap.pixels[idx + 2] as f32 / 255.0;
840                                             let mut b = pixmap.pixels[idx + 3] as f32 / 255.0;
841                                             
842                                             if recolor_light {
843                                                 let l = (r + g + b) / 3.0;
844                                                 let new_l = 0.85 + (1.0 - 0.85) * l;
845                                                 r = new_l;
846                                                 g = new_l;
847                                                 b = new_l;
848                                             }
849                                             
850                                             overlay_rects.push(RectWidget {
851                                                 x: icon_x + col as f32 * pixel_w,
852                                                 y: icon_y + row as f32 * pixel_h,
853                                                 w: pixel_w,
854                                                 h: pixel_h,
855                                                 color: [r, g, b, a],
856                                             });
857                                         }
858                                     }
859                                 }
860                             }
861                             drawn_pixmap = true;
862                         }
863                     }
864                 }
865             }
866 
867             if !drawn_pixmap {
868                 let symbol = if let Some(ref name) = item.icon_name {
869                     let name_lower = name.to_lowercase();
870                     if name_lower.contains("volume") || name_lower.contains("sound") || name_lower.contains("audio") {
871                         if name_lower.contains("mute") { "🔇" } else { "🔊" }
872                     } else if name_lower.contains("wifi") || name_lower.contains("network") || name_lower.contains("ethernet") {
873                         "📶"
874                     } else if name_lower.contains("battery") {
875                         "🔋"
876                     } else if name_lower.contains("bluetooth") {
877                         "ᛒ"
878                     } else if name_lower.contains("mail") || name_lower.contains("envelope") {
879                         "✉"
880                     } else if name_lower.contains("chat") || name_lower.contains("messenger") || name_lower.contains("discord") || name_lower.contains("slack") || name_lower.contains("telegram") {
881                         "💬"
882                     } else if name_lower.contains("steam") || name_lower.contains("game") {
883                         "🎮"
884                     } else if name_lower.contains("dropbox") {
885                         "📦"
886                     } else {
887                         "⚙"
888                     }
889                 } else {
890                     "⚙"
891                 };
892 
893                 // Measure the throwaway buffer for centering, then emit a text prim.
894                 let buf = make_text_buffer(font_system, symbol, font_size, font_family);
895                 let scale = cce_ui::scale::scale_factor();
896                 let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / scale;
897                 let tx = icon_x + (icon_size - tw) / 2.0;
898                 // Plain centering within the icon box: the box itself already
899                 // carries the `text_raise` lift, and `centered_text_y` here
900                 // would apply it a second time.
901                 let ty = icon_y + (icon_size - font_size) / 2.0;
902                 text_prims.push((
903                     symbol.to_string(),
904                     font_size,
905                     tx,
906                     ty,
907                     [
908                         (color::TEXT_ACCENT[0] * 255.0) as u8,
909                         (color::TEXT_ACCENT[1] * 255.0) as u8,
910                         (color::TEXT_ACCENT[2] * 255.0) as u8,
911                     ],
912                     Some(font_family.to_string()),
913                     None,
914                     None,
915                     Some(tw),
916                     None,
917                 ));
918             }
919         }
920     }
921 }
922 
923 pub struct LightSourceModule;
924 
925 pub(crate) fn get_light_source_pos_from_config() -> f32 {
926     crate::config::read_light_source_position_from_config()
927 }
928 
929 impl StatusModule for LightSourceModule {
930     fn name(&self) -> &'static str { "light_source" }
931 
932     // The module is just the empty circle — no module box behind it.
933     fn has_custom_background(&self, _title: &str) -> bool { true }
934 
935     fn width(
936         &self,
937         _stats: &Option<SystemStats>,
938         _title: &str,
939         _font_system: &mut FontSystem,
940         _font_family: &str,
941         _font_size: f32,
942         _tray_items: &HashMap<String, TrayItem>,
943         _padding: f32,
944     ) -> f32 {
945         // An empty circle with the bar's own thickness as its diameter; the
946         // radians value lives in the module's menu, not the strip.
947         crate::read_status_height_from_config()
948     }
949 
950     fn render(
951         &self,
952         x: f32,
953         w: f32,
954         _stats: &Option<SystemStats>,
955         _title: &str,
956         _font_system: &mut FontSystem,
957         _font_family: &str,
958         _font_size: f32,
959         _normal_color: [f32; 4],
960         bar_h: f32,
961         _scale_factor: f64,
962         _text_prims: &mut Vec<crate::TextPrim>,
963         _icon_prims: &mut Vec<crate::IconPrim>,
964         _rects: &mut Vec<RectWidget>,
965         _overlay_rects: &mut Vec<RectWidget>,
966         _tray_items: &HashMap<String, TrayItem>,
967         _tray_item_bounds: &mut Vec<TrayIconBounds>,
968         box_bg_color: Option<[f32; 4]>,
969         _status_box_radius: f32,
970         rounded_boxes: &mut Vec<RoundedBox>,
971         _padding: f32,
972     ) {
973         let d = w.min(bar_h);
974         // The circle wears the module-box fill: same color, opacity and
975         // (per-pixel, compositor-side) backdrop blur as every other module's
976         // box — just circle-shaped and empty of content.
977         rounded_boxes.push(RoundedBox {
978             x: x + (w - d) / 2.0,
979             y: (bar_h - d) / 2.0,
980             w: d,
981             h: d,
982             radius: d / 2.0,
983             color: box_bg_color.unwrap_or([0.0, 0.0, 0.0, 0.0]),
984             corners: (true, true, true, true),
985             // Circle marker; zero thickness = fill only, no stroke.
986             border: Some(([0.0; 4], 0.0)),
987         });
988     }
989 }