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 }