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

src/modules.rs (36.7K)

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