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 }