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

commit7605e3b09f55ba94cd963b041c3bcea25e1088b7
parent4b4a129353
authorLucas Galante <[email protected]>
date2026-09-16 10:09
feat: percentage modules read out as a cce-icons glyph with the bare number on it

cpu, brightness, volume and battery draw a glyph from cce-icons (battery,
cpu, brightness, volume / volume-muted) ghosted at module { icon_alpha }
with the value centered over it and no unit symbol: the glyph is the
unit. The glyph is tinted the readout's own color — text_color, the
battery accent, the muted disabled_color — which cce_ui::upload_icon
cannot do (Prim::Image carries no color), so icons.rs rasterizes the SVG
itself, multiplies in the raw-sRGB color and uploads it, cached per
(name, px, color). A glyph that fails to load falls back to the old text
readout.

The four modules collapse into IconStat implementations under one
blanket StatusModule impl; SystemStats now carries numbers rather than
preformatted strings, so the modules do their own formatting. A module
whose reader has nothing (no battery, no backlight, no pactl) is hidden
rather than an empty bubble. New keys: module { icon_size }
(default bar height - 6), module { icon_font_size } (default 3/4 of
font_size), module { icon_alpha } (default 0.4).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md      |  49 ++++++-
 src/config.rs  |  25 ++++
 src/icons.rs   |  69 ++++++++++
 src/main.rs    |  66 ++++++++--
 src/modules.rs | 397 ++++++++++++++++++++++++++++-----------------------------
 src/stats.rs   |  95 +++++---------
 6 files changed, 421 insertions(+), 280 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f1c13c4..e403fdc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,14 +11,15 @@ and the `cce-ui` toolkit this app is built on. This crate is deliberately small:
 `src/main.rs` (the `StatusApp` application, layout/input, launcher daemon),
 `src/modules.rs` (the `StatusModule` trait and its nine implementations),
 `src/config.rs` (pointer-first config readers), `src/tray.rs` (SNI host),
-`src/cloud.rs` (menu page building), `src/stats.rs` (system stat readers),
-`src/listeners.rs` (status/switcher socket tasks).
+`src/cloud.rs` (menu page building), `src/stats.rs` (system stat readers —
+numbers, not strings; the modules do the formatting), `src/icons.rs` (tinted
+cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks).
 
 ## Build, test, run
 
 ```sh
 cargo build --release                 # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test                            # 40 tests: main.rs (contrast, parsers), config.rs, tray.rs
+cargo test                            # 46 tests: main.rs (contrast, parsers), config.rs, tray.rs
 make install                          # installs ../target/release/cce-status-interface to ~/.local/bin
 ```
 
@@ -62,8 +63,9 @@ The app implements `cce_ui::engine::Application` on the **`display_list()` paint
    `StatusModule::width()`, then `StatusModule::render()` — filling retained buffers on
    `StatusApp`: `rects`, `rounded_boxes`, `text_prims`
    (the `TextPrim` tuple type; build them with `draw_label()` from a
-   `cce_ui::widget::StyledLabel`), plus `input_regions`, `module_bounds`,
-   `tray_item_bounds`.
+   `cce_ui::widget::StyledLabel`), `icon_prims` (`IconPrim` — a tinted
+   cce-icons glyph texture at a logical rect), plus `input_regions`,
+   `module_bounds`, `tray_item_bounds`.
 2. `display_list()` replays those buffers into a `PaintCtx` each frame (and triggers
    `rebuild_layout()` when size/scale changed or `needs_rebuild` is set).
    `overlay_quads()` remains a separate on-top pass (used for drag feedback).
@@ -84,6 +86,34 @@ Orientation is dynamic: `is_vertical()` compares the surface size against the
 configured bar thickness; every module renders along one axis using `bar_h`/`coord`
 accordingly.
 
+**The percentage modules read out as a glyph, not a label.** `cpu`,
+`brightness`, `volume` and `battery` are `IconStat` implementations (a
+blanket `impl<T: IconStat> StatusModule for T` in `modules.rs` does the shared
+layout): each names a cce-icons glyph, the bare number and a color, and
+`IconReadout` draws the glyph ghosted at `module { icon_alpha }` with the
+number centered on it — no unit symbol, since the glyph IS the unit ("87" on
+the battery, not "Bat 87%"). The muted sink swaps to `volume-muted`; the
+battery keeps its accent color while charging or under 10% (the only
+charging cue now that the "⚡" prefix went with the text form). A module whose
+reader has nothing (no battery, no backlight, no pactl) returns `None` and
+has width 0, i.e. it is hidden rather than an empty bubble; a reader that
+answers without a number (cpu with no /proc/stat, a sink with no level)
+draws the glyph alone.
+
+The glyphs come from the **cce-icons** crate via `cce_ui::icons_dir()`
+(`$CCE_ICONS_DIR`, else `~/projects/cce/cce-icons/svg`) — but NOT through
+`cce_ui::upload_icon`: a `Prim::Image` has alpha and no color, and the
+artwork is white, so `icons.rs::tinted_icon` rasterizes the SVG itself
+(`cce_ui::rasterize_svg`), multiplies it by the readout's raw-sRGB color and
+uploads it, cached per `(name, px, color)` for the life of the process. A
+glyph that fails to load falls back to the old text readout ("Cpu 45%"), so
+a bar started without the icon set is still attributable; **a shadow session
+needs `CCE_ICONS_DIR` exported into the spawn**, its HOME being elsewhere,
+exactly as it needs `CCE_FONTS_DIR`. Slot stability holds as before: the
+stable width is the wider of the glyph and the "100" template, and the glyph
+normally wins, so a value crossing a digit boundary never resizes the
+surface. `memory` is not a percentage ("Mem 10/62G") and stays a label.
+
 ## Events and IPC
 
 `update()` consumes `CustomEvent`s sent over a calloop channel from tokio tasks spawned
@@ -103,6 +133,9 @@ listen to tray D-Bus, etc.:
   first. (The old `viewport` topic is gone with the viewport-tag feature.)
 - **System stats** (`spawn_system_stats`): `/proc/stat`, `/proc/meminfo`,
   `/sys/class/power_supply/BAT*`, `/sys/class/backlight`, and `pactl` for volume/mute.
+  `SystemStats` carries numbers (`cpu_pct`, `battery: (capacity, charging)`,
+  `volume: (level, muted)`, `brightness`), each `Option` where the source can
+  be absent; only clock and memory arrive pre-formatted.
 - **Tray** (`spawn_status_tray`): a full StatusNotifierItem/Watcher host over `zbus`,
   including DBusMenu fetching. Icons arrive as pixmaps or theme names (rendered via
   `resvg`/`png`).
@@ -193,7 +226,11 @@ drop box does not fill the surface: the box is inset by 1px for the
 silhouette's AA feather plus `DropletSpec::shadow_gap()` for the shadow's
 falloff (`main.rs`, three call sites — left, right, and the expanded menu
 box, which becomes the drop growing))
-and `module { text_raise }` (lifts module text above vertical center, logical
+and `module { icon_size }` (glyph height for the icon readouts, logical px,
+default bar height − 6) and `module { icon_font_size }` (the number on the
+glyph, default ¾ of `module { font_size }` — "100" at the full size
+overhangs a bar-height glyph) and `module { icon_alpha }` (the glyph's
+ghosting under the number, 0-1, default 0.4) and `module { text_raise }` (lifts module text above vertical center, logical
 px, bar-side only — every module funnels through `centered_text_y`) and
 `module { text_scrim }` (0-1 resting opacity of a
 feathered pool filling each module box, the DE's one text-contrast treatment)
diff --git a/src/config.rs b/src/config.rs
index 42498aa..caaae50 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -191,6 +191,31 @@ pub(crate) fn read_text_raise_from_config() -> f32 {
     cfg_f32("/module/text_raise").unwrap_or(0.0)
 }
 
+/// `module { icon_size }` — the glyph height, logical px, for the modules
+/// that read out as a cce-icons glyph with their value on it (cpu,
+/// brightness, volume, battery). Default: the bar height less 6, so the
+/// glyph sits inside the bubble with a 3px breath above and below.
+pub(crate) fn read_icon_size_from_config() -> f32 {
+    cfg_f32("/module/icon_size")
+        .unwrap_or_else(|| read_status_height_from_config() - 6.0)
+        .max(4.0)
+}
+
+/// `module { icon_font_size }` — the size of the number superimposed on a
+/// glyph. Defaults to three quarters of the module font size: "100" at the
+/// full size overhangs a bar-height glyph on both sides, and the glyph is
+/// what carries the unit now, so the number can afford to be smaller.
+pub(crate) fn read_icon_font_size_from_config(font_size: f32) -> f32 {
+    cfg_f32("/module/icon_font_size").unwrap_or(font_size * 0.75).max(1.0)
+}
+
+/// `module { icon_alpha }` — opacity 0-1 of the glyph under the number
+/// (default 0.4). The glyph is tinted the number's color, so it is this
+/// ghosting alone that keeps the digits legible on it.
+pub(crate) fn read_icon_alpha_from_config() -> f32 {
+    cfg_f32("/module/icon_alpha").unwrap_or(0.4).clamp(0.0, 1.0)
+}
+
 /// `module { text_contrast }` — adaptive contrast strength (0 = off, the
 /// default; 1 = full). The compositor's per-segment `backdrop` measurement
 /// drives the scrim through it: the ground darkens only as far as a backdrop
diff --git a/src/icons.rs b/src/icons.rs
new file mode 100644
index 0000000..39a3c73
--- /dev/null
+++ b/src/icons.rs
@@ -0,0 +1,69 @@
+//! Bundled cce-icons glyphs, tinted for the bar.
+//!
+//! The stat modules draw a cce-icons glyph with their value superimposed on it,
+//! and the glyph has to wear the same color as the number — `module
+//! { text_color }`, the battery's accent when it is low or charging, the
+//! volume's `disabled_color` while muted. `cce_ui::upload_icon` cannot do that:
+//! a `Prim::Image` carries alpha but no color, and the bundled artwork is
+//! white. So this rasterizes the SVG itself, multiplies the pixels by the
+//! color, and uploads the result — one texture per `(name, px, color)`,
+//! cached for the life of the process (the key space is a handful of colors
+//! times one size, so nothing is ever freed).
+//!
+//! The artwork comes from the cce-icons crate via [`cce_ui::icons_dir`]
+//! (`$CCE_ICONS_DIR`, else `~/projects/cce/cce-icons/svg`). A glyph that is
+//! missing or unparsable yields `None` — the caller keeps its text readout as
+//! the fallback — and is logged once, since the miss is cached too.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+/// Rasterize `<name>.svg` from cce-icons at `px` on its longer side, tinted
+/// to `rgb` (raw sRGB, like every text color here — uploaded images are
+/// sampled as sRGB), and upload it as a renderer texture. Returns the image
+/// id plus the pixel size for `PaintCtx::image`.
+pub(crate) fn tinted_icon(name: &str, px: u32, rgb: [u8; 3]) -> Option<(u32, u32, u32)> {
+    type Key = (String, u32, [u8; 3]);
+    static CACHE: Mutex<Option<HashMap<Key, Option<(u32, u32, u32)>>>> = Mutex::new(None);
+    let key = (name.to_string(), px, rgb);
+    let mut guard = CACHE.lock().unwrap();
+    let cache = guard.get_or_insert_with(HashMap::new);
+    if let Some(hit) = cache.get(&key) {
+        return *hit;
+    }
+    let loaded = (|| {
+        let path = format!("{}/{name}.svg", cce_ui::icons_dir());
+        let data = match std::fs::read(&path) {
+            Ok(d) => d,
+            Err(e) => {
+                log::warn!("[icons] {path}: {e} — falling back to the text readout");
+                return None;
+            }
+        };
+        let (mut rgba, w, h) = cce_ui::rasterize_svg(&data, px).or_else(|| {
+            log::warn!("[icons] {path}: unparsable SVG — falling back to the text readout");
+            None
+        })?;
+        // The artwork is white, so multiplying is tinting; anything the
+        // glyph shades darker (a cut-through keyhole) stays proportionally
+        // darker in the tint.
+        for p in rgba.chunks_exact_mut(4) {
+            for (c, &t) in p[..3].iter_mut().zip(rgb.iter()) {
+                *c = ((*c as u16 * t as u16 + 127) / 255) as u8;
+            }
+        }
+        Some((cce_ui::vk::upload_rgba(rgba, w, h), w, h))
+    })();
+    cache.insert(key, loaded);
+    loaded
+}
+
+/// The `[u8; 3]` a text color becomes for tinting — the same conversion
+/// `StyledLabel` applies to its color, so glyph and number match exactly.
+pub(crate) fn tint_of(color: [f32; 4]) -> [u8; 3] {
+    [
+        (color[0] * 255.0).round() as u8,
+        (color[1] * 255.0).round() as u8,
+        (color[2] * 255.0).round() as u8,
+    ]
+}
diff --git a/src/main.rs b/src/main.rs
index 9b9b567..0326af9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 mod cloud;
 mod config;
+mod icons;
 mod listeners;
 mod modules;
 mod stats;
@@ -58,13 +59,31 @@ pub struct TrayIconBounds {
 pub struct SystemStats {
     pub clock: String,
     pub memory: String,
-    pub cpu: String,
-    pub battery: String,
-    pub battery_capacity: i32,
-    pub battery_charging: bool,
-    pub volume: String,
-    pub volume_muted: bool,
-    pub brightness: String,
+    /// CPU busy share as a whole percentage; `None` when /proc/stat is
+    /// unreadable.
+    pub cpu_pct: Option<u8>,
+    /// `(capacity %, charging)`; `None` on a machine without a battery.
+    pub battery: Option<(i32, bool)>,
+    /// `(level %, muted)`; `None` when pactl is unavailable. The level is
+    /// `None` when pactl answered but reported no percentage.
+    pub volume: Option<(Option<u32>, bool)>,
+    /// Backlight level as a whole percentage; `None` without a backlight.
+    pub brightness: Option<i32>,
+}
+
+/// A bundled cce-icons glyph placed in the bar: `image` is the tinted
+/// texture from `icons::tinted_icon`, the rect is logical px, `alpha` the
+/// ghosting under a superimposed readout. Retained like `text_prims` and
+/// replayed by `display_list`, drawn after the scrim and before the text so
+/// the number sits on the glyph.
+#[derive(Debug, Clone, Copy)]
+pub struct IconPrim {
+    pub image: u32,
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+    pub alpha: f32,
 }
 
 /// The subset of [`SystemStats`] a given module actually paints, as a comparable
@@ -77,16 +96,15 @@ pub struct SystemStats {
 fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
     match module {
         Some("clock") => Some(s.clock.clone()),
-        Some("cpu") => Some(s.cpu.clone()),
+        Some("cpu") => Some(format!("{:?}", s.cpu_pct)),
         Some("memory") => Some(s.memory.clone()),
-        Some("battery") => Some(format!("{}|{}|{}", s.battery, s.battery_capacity, s.battery_charging)),
-        Some("volume") => Some(format!("{}|{}", s.volume, s.volume_muted)),
-        Some("brightness") => Some(s.brightness.clone()),
+        Some("battery") => Some(format!("{:?}", s.battery)),
+        Some("volume") => Some(format!("{:?}", s.volume)),
+        Some("brightness") => Some(format!("{:?}", s.brightness)),
         Some("window") | Some("tray") | Some("light_source") => None,
         _ => Some(format!(
-            "{}|{}|{}|{}|{}|{}|{}|{}|{}",
-            s.clock, s.memory, s.cpu, s.battery, s.battery_capacity,
-            s.battery_charging, s.volume, s.volume_muted, s.brightness
+            "{}|{}|{:?}|{:?}|{:?}|{:?}",
+            s.clock, s.memory, s.cpu_pct, s.battery, s.volume, s.brightness
         )),
     }
 }
@@ -484,6 +502,8 @@ struct StatusApp {
     /// as an adaptation.
     contrast_now: f32,
     text_prims: Vec<TextPrim>,
+    /// The glyphs the stat modules paint their readouts on — see `IconPrim`.
+    icon_prims: Vec<IconPrim>,
 
     scale_factor: f64,
     width: u32,
@@ -617,6 +637,7 @@ impl StatusApp {
         self.overlay_rects.clear();
         self.rounded_boxes.clear();
         self.text_prims.clear();
+        self.icon_prims.clear();
         self.input_regions.clear();
         self.module_bounds.clear();
         self.tray_item_bounds.clear();
@@ -720,6 +741,7 @@ impl StatusApp {
                     bar_h,
                     self.scale_factor,
                     &mut self.text_prims,
+                    &mut self.icon_prims,
                     &mut self.rects,
                     &mut self.overlay_rects,
                     &self.tray_items,
@@ -817,6 +839,7 @@ impl StatusApp {
                     bar_h,
                     self.scale_factor,
                     &mut self.text_prims,
+                    &mut self.icon_prims,
                     &mut self.rects,
                     &mut self.overlay_rects,
                     &self.tray_items,
@@ -1132,6 +1155,14 @@ impl StatusApp {
                 tp.2 = old_y;
                 tp.3 = old_x;
             }
+            // Rotate icon prims (square glyphs, so only the corner moves)
+            for ip in &mut self.icon_prims {
+                let (old_x, old_y, old_w, old_h) = (ip.x, ip.y, ip.w, ip.h);
+                ip.x = old_y;
+                ip.y = old_x;
+                ip.w = old_h;
+                ip.h = old_w;
+            }
             // Rotate tray_item_bounds
             for tib in &mut self.tray_item_bounds {
                 let old_x = tib.x;
@@ -1459,6 +1490,7 @@ impl cce_ui::engine::Application for StatusApp {
             text_scrim_feather: None,
             contrast_now: 0.0,
             text_prims: Vec::new(),
+            icon_prims: Vec::new(),
             scale_factor: 1.0,
             width: if selected_module.is_some() { 120 } else { 1920 },
             height: read_status_height_from_config() as u32,
@@ -1881,6 +1913,12 @@ impl cce_ui::engine::Application for StatusApp {
             );
         }
 
+        // Readout glyphs: post-scrim so the pool grounds them like any run,
+        // pre-text so the number lands on the glyph.
+        for ip in &self.icon_prims {
+            pc.image(ip.image, Rect { x: ip.x, y: ip.y, width: ip.w, height: ip.h }, ip.alpha);
+        }
+
         for (text, tsize, x, y, color, font, bounds, layout, _run_w) in &self.text_prims {
             match layout {
                 Some(l) => pc.text_boxed(text.clone(), *x, *y, *tsize, *color, font.clone(), *bounds, cce_ui::scene::paint::TextAttrs::default(), *l),
diff --git a/src/modules.rs b/src/modules.rs
index 6608d04..0d752c7 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -66,6 +66,7 @@ pub trait StatusModule {
         bar_h: f32,
         scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
+        icon_prims: &mut Vec<crate::IconPrim>,
         rects: &mut Vec<RectWidget>,
         overlay_rects: &mut Vec<RectWidget>,
         tray_items: &HashMap<String, TrayItem>,
@@ -202,6 +203,7 @@ impl StatusModule for WindowModule {
         bar_h: f32,
         _scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
+        _icon_prims: &mut Vec<crate::IconPrim>,
         _rects: &mut Vec<RectWidget>,
         _overlay_rects: &mut Vec<RectWidget>,
         _tray_items: &HashMap<String, TrayItem>,
@@ -265,6 +267,7 @@ impl StatusModule for ClockModule {
         bar_h: f32,
         _scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
+        _icon_prims: &mut Vec<crate::IconPrim>,
         _rects: &mut Vec<RectWidget>,
         _overlay_rects: &mut Vec<RectWidget>,
         _tray_items: &HashMap<String, TrayItem>,
@@ -281,95 +284,139 @@ impl StatusModule for ClockModule {
     }
 }
 
-pub struct BatteryModule;
+/// A stat module's readout as a cce-icons glyph with its value superimposed
+/// — the glyph IS the unit, so the number is bare: "87" on the battery, not
+/// "Bat 87%". The glyph is tinted the readout's color and ghosted under the
+/// number (`module { icon_alpha }`); see `icons.rs` for why the tint is done
+/// here rather than through `cce_ui::upload_icon`.
+///
+/// The pre-glyph text form rides along as the FALLBACK: `tinted_icon` returns
+/// `None` when the icon set is missing or unparsable, and a readout that
+/// silently loses its glyph would be a bare number nobody can attribute — so
+/// it degrades to the old "Cpu 45%" instead.
+pub(crate) struct IconReadout {
+    pub icon: &'static str,
+    /// The number drawn over the glyph; `None` draws the glyph alone (a
+    /// value the reader could not produce — cpu with no /proc/stat, a
+    /// sink without a level).
+    pub number: Option<String>,
+    pub color: [f32; 4],
+    pub fallback: String,
+    /// Widest-plausible fallback text, for the stable slot width.
+    pub fallback_template: &'static str,
+}
 
-impl BatteryModule {
-    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
-        match stats {
-            Some(s) if !s.battery.is_empty() => &s.battery,
-            _ => "Bat 100%",
-        }
+/// The widest number a percentage readout shows.
+const NUMBER_TEMPLATE: &str = "100";
+
+impl IconReadout {
+    /// The glyph as an uploaded texture plus its LOGICAL size, at
+    /// `module { icon_size }` on the longer side. `None` = no glyph, text
+    /// fallback.
+    fn glyph(&self) -> Option<(u32, f32, f32)> {
+        let scale = cce_ui::scale::scale_factor();
+        let px = (crate::read_icon_size_from_config() * scale).round().max(1.0) as u32;
+        let (image, w, h) = crate::icons::tinted_icon(self.icon, px, crate::icons::tint_of(self.color))?;
+        Some((image, w as f32 / scale, h as f32 / scale))
     }
-}
 
-impl StatusModule for BatteryModule {
-    fn name(&self) -> &'static str { "battery" }
+    /// The number's font size — `module { icon_font_size }`, else a
+    /// fraction of the module font.
+    fn number_size(font_size: f32) -> f32 {
+        crate::read_icon_font_size_from_config(font_size)
+    }
 
-    fn width(
-        &self,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        _tray_items: &HashMap<String, TrayItem>,
-        padding: f32,
-    ) -> f32 {
-        stable_text_width(font_system, Self::live_text(stats), "Bat 100%", font_size, font_family, padding)
+    /// Stable slot width: the glyph, the widest number and the live number
+    /// — whichever is widest — plus padding. Normally the glyph wins, which
+    /// is what makes the slot stable; a number wider than the glyph would
+    /// overhang it symmetrically, and the slot allows for that.
+    fn width(&self, font_system: &mut FontSystem, font_family: &str, font_size: f32, padding: f32) -> f32 {
+        match self.glyph() {
+            Some((_, gw, _)) => {
+                let ns = Self::number_size(font_size);
+                let tmpl = Label::new_with_family(font_system, NUMBER_TEMPLATE, ns, [0.0, 0.0, 0.0, 1.0], font_family).w;
+                let live = self.number.as_deref().map_or(0.0, |n| {
+                    Label::new_with_family(font_system, n, ns, [0.0, 0.0, 0.0, 1.0], font_family).w
+                });
+                gw.max(tmpl).max(live) + 2.0 * padding
+            }
+            None => stable_text_width(font_system, &self.fallback, self.fallback_template, font_size, font_family, padding),
+        }
     }
 
-    fn content_width(
-        &self,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        _tray_items: &HashMap<String, TrayItem>,
-        padding: f32,
-    ) -> f32 {
-        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
+    /// Live content width: the glyph or the live number, whichever is wider,
+    /// plus padding.
+    fn content_width(&self, font_system: &mut FontSystem, font_family: &str, font_size: f32, padding: f32) -> f32 {
+        match self.glyph() {
+            Some((_, gw, _)) => {
+                let ns = Self::number_size(font_size);
+                let live = self.number.as_deref().map_or(0.0, |n| {
+                    Label::new_with_family(font_system, n, ns, [0.0, 0.0, 0.0, 1.0], font_family).w
+                });
+                gw.max(live) + 2.0 * padding
+            }
+            None => live_text_width(font_system, &self.fallback, font_size, font_family, padding),
+        }
     }
 
     fn render(
         &self,
         x: f32,
-        _w: f32,
-        stats: &Option<SystemStats>,
-        _title: &str,
         font_system: &mut FontSystem,
         font_family: &str,
         font_size: f32,
-        normal_color: [f32; 4],
         bar_h: f32,
-        _scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
-        _rects: &mut Vec<RectWidget>,
-        _overlay_rects: &mut Vec<RectWidget>,
-        _tray_items: &HashMap<String, TrayItem>,
-        _tray_item_bounds: &mut Vec<TrayIconBounds>,
-        _box_bg_color: Option<[f32; 4]>,
-        _status_box_radius: f32,
-        _rounded_boxes: &mut Vec<RoundedBox>,
+        icon_prims: &mut Vec<crate::IconPrim>,
         padding: f32,
     ) {
-        if let Some(ref s) = stats {
-            if !s.battery.is_empty() {
-                let bat_color = if !s.battery_charging && s.battery_capacity > 10 {
-                    normal_color
-                } else {
-                    color::TEXT_ACCENT
-                };
-                let label = Label::new_with_family(font_system, &s.battery, font_size, bat_color, font_family);
+        match self.glyph() {
+            Some((image, gw, gh)) => {
+                let ns = Self::number_size(font_size);
+                let label = self
+                    .number
+                    .as_deref()
+                    .map(|n| Label::new_with_family(font_system, n, ns, self.color, font_family));
+                // Glyph and number share one center: the wider of the two
+                // spans the content, and both are centered on it. The same
+                // `module { text_raise }` lift every text run gets via
+                // `centered_text_y` is applied to the glyph too, so the pair
+                // stays concentric and level with the neighboring modules.
+                let span = gw.max(label.as_ref().map_or(0.0, |l| l.w));
+                let cx = x + padding + span / 2.0;
+                let gy = (bar_h - gh) / 2.0 - crate::config::read_text_raise_from_config();
+                icon_prims.push(crate::IconPrim {
+                    image,
+                    x: cx - gw / 2.0,
+                    y: gy,
+                    w: gw,
+                    h: gh,
+                    alpha: crate::read_icon_alpha_from_config(),
+                });
+                if let Some(label) = label {
+                    let lw = label.w;
+                    crate::draw_label(text_prims, label, cx - lw / 2.0, centered_text_y(bar_h, ns));
+                }
+            }
+            None => {
+                let label = Label::new_with_family(font_system, &self.fallback, font_size, self.color, font_family);
                 crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
             }
         }
     }
 }
 
-pub struct VolumeModule;
-
-impl VolumeModule {
-    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
-        match stats {
-            Some(s) if !s.volume.is_empty() => &s.volume,
-            _ => "Vol 100%",
-        }
-    }
+/// A module that reads out as an [`IconReadout`]: it only has to say which
+/// glyph, which number and which color, and the blanket `StatusModule` impl
+/// below does the shared layout. `None` hides the module (width 0) — a
+/// machine with no battery or backlight has nothing to read out.
+pub(crate) trait IconStat {
+    const NAME: &'static str;
+    fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout>;
 }
 
-impl StatusModule for VolumeModule {
-    fn name(&self) -> &'static str { "volume" }
+impl<T: IconStat> StatusModule for T {
+    fn name(&self) -> &'static str { T::NAME }
 
     fn width(
         &self,
@@ -381,7 +428,10 @@ impl StatusModule for VolumeModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        stable_text_width(font_system, Self::live_text(stats), "Vol 100%", font_size, font_family, padding)
+        // The color only tints the glyph, and the width is the same in any
+        // tint; the readout's own color is applied at render.
+        T::readout(stats, color::TEXT_FG)
+            .map_or(0.0, |r| r.width(font_system, font_family, font_size, padding))
     }
 
     fn content_width(
@@ -394,7 +444,8 @@ impl StatusModule for VolumeModule {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
+        T::readout(stats, color::TEXT_FG)
+            .map_or(0.0, |r| r.content_width(font_system, font_family, font_size, padding))
     }
 
     fn render(
@@ -410,6 +461,7 @@ impl StatusModule for VolumeModule {
         bar_h: f32,
         _scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
+        icon_prims: &mut Vec<crate::IconPrim>,
         _rects: &mut Vec<RectWidget>,
         _overlay_rects: &mut Vec<RectWidget>,
         _tray_items: &HashMap<String, TrayItem>,
@@ -419,89 +471,82 @@ impl StatusModule for VolumeModule {
         _rounded_boxes: &mut Vec<RoundedBox>,
         padding: f32,
     ) {
-        if let Some(ref s) = stats {
-            if !s.volume.is_empty() {
-                let is_muted = s.volume_muted;
-                let color_val = if is_muted {
-                    crate::read_disabled_color_from_config().unwrap_or(color::TEXT_DIM)
-                } else {
-                    normal_color
-                };
-                let label = Label::new_with_family(font_system, &s.volume, font_size, color_val, font_family);
-                crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
-            }
+        if let Some(r) = T::readout(stats, normal_color) {
+            r.render(x, font_system, font_family, font_size, bar_h, text_prims, icon_prims, padding);
         }
     }
 }
 
-pub struct BrightnessModule;
+pub struct BatteryModule;
 
-impl BrightnessModule {
-    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
-        match stats {
-            Some(s) if !s.brightness.is_empty() => &s.brightness,
-            _ => "Bri 100%",
-        }
+impl IconStat for BatteryModule {
+    const NAME: &'static str = "battery";
+
+    fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
+        let (cap, charging) = match stats {
+            Some(s) => s.battery?,
+            None => (100, false),
+        };
+        // Accent while charging or nearly flat — the one cue left for
+        // charging now that the "⚡" prefix has gone with the text form.
+        let color = if !charging && cap > 10 { normal_color } else { color::TEXT_ACCENT };
+        Some(IconReadout {
+            icon: "battery",
+            number: Some(cap.to_string()),
+            color,
+            fallback: format!("{} {cap}%", if charging { "⚡" } else { "Bat" }),
+            fallback_template: "Bat 100%",
+        })
     }
 }
 
-impl StatusModule for BrightnessModule {
-    fn name(&self) -> &'static str { "brightness" }
+pub struct VolumeModule;
 
-    fn width(
-        &self,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        _tray_items: &HashMap<String, TrayItem>,
-        padding: f32,
-    ) -> f32 {
-        stable_text_width(font_system, Self::live_text(stats), "Bri 100%", font_size, font_family, padding)
-    }
+impl IconStat for VolumeModule {
+    const NAME: &'static str = "volume";
 
-    fn content_width(
-        &self,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        _tray_items: &HashMap<String, TrayItem>,
-        padding: f32,
-    ) -> f32 {
-        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
+    fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
+        let (pct, muted) = match stats {
+            Some(s) => s.volume?,
+            None => (Some(100), false),
+        };
+        let color = if muted {
+            crate::read_disabled_color_from_config().unwrap_or(color::TEXT_DIM)
+        } else {
+            normal_color
+        };
+        let fallback = match (muted, pct) {
+            (_, Some(p)) => format!("Vol {p}%"),
+            (true, None) => "Vol Muted".to_string(),
+            (false, None) => "Vol N/A".to_string(),
+        };
+        Some(IconReadout {
+            icon: if muted { "volume-muted" } else { "volume" },
+            number: pct.map(|p| p.to_string()),
+            color,
+            fallback,
+            fallback_template: "Vol 100%",
+        })
     }
+}
 
-    fn render(
-        &self,
-        x: f32,
-        _w: f32,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        normal_color: [f32; 4],
-        bar_h: f32,
-        _scale_factor: f64,
-        text_prims: &mut Vec<crate::TextPrim>,
-        _rects: &mut Vec<RectWidget>,
-        _overlay_rects: &mut Vec<RectWidget>,
-        _tray_items: &HashMap<String, TrayItem>,
-        _tray_item_bounds: &mut Vec<TrayIconBounds>,
-        _box_bg_color: Option<[f32; 4]>,
-        _status_box_radius: f32,
-        _rounded_boxes: &mut Vec<RoundedBox>,
-        padding: f32,
-    ) {
-        if let Some(ref s) = stats {
-            if !s.brightness.is_empty() {
-                let label = Label::new_with_family(font_system, &s.brightness, font_size, normal_color, font_family);
-                crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
-            }
-        }
+pub struct BrightnessModule;
+
+impl IconStat for BrightnessModule {
+    const NAME: &'static str = "brightness";
+
+    fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
+        let pct = match stats {
+            Some(s) => s.brightness?,
+            None => 100,
+        };
+        Some(IconReadout {
+            icon: "brightness",
+            number: Some(pct.to_string()),
+            color: normal_color,
+            fallback: format!("Bri {pct}%"),
+            fallback_template: "Bri 100%",
+        })
     }
 }
 
@@ -567,6 +612,7 @@ impl StatusModule for MemoryModule {
         bar_h: f32,
         _scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
+        _icon_prims: &mut Vec<crate::IconPrim>,
         _rects: &mut Vec<RectWidget>,
         _overlay_rects: &mut Vec<RectWidget>,
         _tray_items: &HashMap<String, TrayItem>,
@@ -585,70 +631,21 @@ impl StatusModule for MemoryModule {
 
 pub struct CpuModule;
 
-impl CpuModule {
-    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
-        match stats {
-            Some(s) if !s.cpu.is_empty() => &s.cpu,
-            _ => "Cpu 0%",
-        }
-    }
-}
-
-impl StatusModule for CpuModule {
-    fn name(&self) -> &'static str { "cpu" }
+impl IconStat for CpuModule {
+    const NAME: &'static str = "cpu";
 
-    fn width(
-        &self,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        _tray_items: &HashMap<String, TrayItem>,
-        padding: f32,
-    ) -> f32 {
-        stable_text_width(font_system, Self::live_text(stats), "Cpu 100%", font_size, font_family, padding)
-    }
-
-    fn content_width(
-        &self,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        _tray_items: &HashMap<String, TrayItem>,
-        padding: f32,
-    ) -> f32 {
-        live_text_width(font_system, Self::live_text(stats), font_size, font_family, padding)
-    }
-
-    fn render(
-        &self,
-        x: f32,
-        _w: f32,
-        stats: &Option<SystemStats>,
-        _title: &str,
-        font_system: &mut FontSystem,
-        font_family: &str,
-        font_size: f32,
-        normal_color: [f32; 4],
-        bar_h: f32,
-        _scale_factor: f64,
-        text_prims: &mut Vec<crate::TextPrim>,
-        _rects: &mut Vec<RectWidget>,
-        _overlay_rects: &mut Vec<RectWidget>,
-        _tray_items: &HashMap<String, TrayItem>,
-        _tray_item_bounds: &mut Vec<TrayIconBounds>,
-        _box_bg_color: Option<[f32; 4]>,
-        _status_box_radius: f32,
-        _rounded_boxes: &mut Vec<RoundedBox>,
-        padding: f32,
-    ) {
-        if let Some(ref s) = stats {
-            let label = Label::new_with_family(font_system, &s.cpu, font_size, normal_color, font_family);
-            crate::draw_label(text_prims, label, x + padding, centered_text_y(bar_h, font_size));
-        }
+    fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
+        let pct = match stats {
+            Some(s) => s.cpu_pct,
+            None => Some(0),
+        };
+        Some(IconReadout {
+            icon: "cpu",
+            number: pct.map(|p| p.to_string()),
+            color: normal_color,
+            fallback: pct.map_or("Cpu N/A".to_string(), |p| format!("Cpu {p}%")),
+            fallback_template: "Cpu 100%",
+        })
     }
 }
 
@@ -688,6 +685,7 @@ impl StatusModule for TrayModule {
         bar_h: f32,
         scale_factor: f64,
         text_prims: &mut Vec<crate::TextPrim>,
+        _icon_prims: &mut Vec<crate::IconPrim>,
         _rects: &mut Vec<RectWidget>,
         overlay_rects: &mut Vec<RectWidget>,
         tray_items: &HashMap<String, TrayItem>,
@@ -893,6 +891,7 @@ impl StatusModule for LightSourceModule {
         bar_h: f32,
         _scale_factor: f64,
         _text_prims: &mut Vec<crate::TextPrim>,
+        _icon_prims: &mut Vec<crate::IconPrim>,
         _rects: &mut Vec<RectWidget>,
         _overlay_rects: &mut Vec<RectWidget>,
         _tray_items: &HashMap<String, TrayItem>,
diff --git a/src/stats.rs b/src/stats.rs
index 1c96f56..dfc8f54 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -46,45 +46,45 @@ pub(crate) fn read_memory_usage() -> Option<String> {
     }
 }
 
-pub(crate) fn read_battery_details() -> Option<(String, i32, bool)> {
+/// The first battery's `(capacity %, charging)`; `None` on a machine
+/// without one.
+pub(crate) fn read_battery_details() -> Option<(i32, bool)> {
     for bat in &["BAT0", "BAT1"] {
         let cap_path = format!("/sys/class/power_supply/{}/capacity", bat);
         let status_path = format!("/sys/class/power_supply/{}/status", bat);
         if let Ok(cap_str) = std::fs::read_to_string(&cap_path) {
-            let cap_trimmed = cap_str.trim();
-            let cap = cap_trimmed.parse::<i32>().unwrap_or(0);
+            let cap = cap_str.trim().parse::<i32>().unwrap_or(0);
             let status = std::fs::read_to_string(&status_path).unwrap_or_default();
             let is_charging = status.trim() == "Charging";
-            let charge_symbol = if is_charging { "⚡" } else { "Bat" };
-            return Some((format!("{} {}%", charge_symbol, cap_trimmed), cap, is_charging));
+            return Some((cap, is_charging));
         }
     }
     None
 }
 
-pub(crate) fn read_brightness() -> Option<String> {
+/// The first backlight's level as a whole percentage; `None` without one.
+pub(crate) fn read_brightness() -> Option<i32> {
     let dir = std::fs::read_dir("/sys/class/backlight").ok()?;
-    for entry in dir {
-        if let Ok(entry) = entry {
-            let path = entry.path();
-            let cur_path = path.join("brightness");
-            let max_path = path.join("max_brightness");
-            if cur_path.exists() && max_path.exists() {
-                let cur_str = std::fs::read_to_string(cur_path).ok()?;
-                let max_str = std::fs::read_to_string(max_path).ok()?;
-                let cur = cur_str.trim().parse::<f32>().ok()?;
-                let max = max_str.trim().parse::<f32>().ok()?;
-                if max > 0.0 {
-                    let pct = (cur / max * 100.0).round() as i32;
-                    return Some(format!("Bri {}%", pct));
-                }
+    for entry in dir.flatten() {
+        let path = entry.path();
+        let cur_path = path.join("brightness");
+        let max_path = path.join("max_brightness");
+        if cur_path.exists() && max_path.exists() {
+            let cur_str = std::fs::read_to_string(cur_path).ok()?;
+            let max_str = std::fs::read_to_string(max_path).ok()?;
+            let cur = cur_str.trim().parse::<f32>().ok()?;
+            let max = max_str.trim().parse::<f32>().ok()?;
+            if max > 0.0 {
+                return Some((cur / max * 100.0).round() as i32);
             }
         }
     }
     None
 }
 
-pub(crate) async fn read_volume() -> Option<(String, bool)> {
+/// The default sink's `(level %, muted)`; `None` when pactl is unavailable
+/// or fails. The level is `None` when pactl answered without a percentage.
+pub(crate) async fn read_volume() -> Option<(Option<u32>, bool)> {
     let vol_output = match tokio::process::Command::new("pactl")
         .args(["get-sink-volume", "@DEFAULT_SINK@"])
         .output()
@@ -128,36 +128,20 @@ pub(crate) async fn read_volume() -> Option<(String, bool)> {
         }
     }
 
-    match (muted, pct) {
-        (true, Some(p)) => Some((format!("Vol {}%", p), true)),
-        (true, None) => Some(("Vol Muted".to_string(), true)),
-        (false, Some(p)) => Some((format!("Vol {}%", p), false)),
-        (false, None) => Some(("Vol N/A".to_string(), false)),
-    }
+    Some((pct, muted))
 }
 
 pub(crate) fn get_initial_stats() -> SystemStats {
     let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
     let memory = read_memory_usage().unwrap_or_else(|| "Mem N/A".to_string());
 
-    let (battery_str, battery_capacity, battery_charging) = if let Some((s, cap, chg)) = read_battery_details() {
-        (s, cap, chg)
-    } else {
-        ("".to_string(), 0, false)
-    };
-    let (volume, volume_muted) = pollster::block_on(read_volume()).unwrap_or_else(|| ("".to_string(), false));
-    let brightness = read_brightness().unwrap_or_default();
-
     SystemStats {
         clock,
         memory,
-        cpu: "Cpu 0%".to_string(),
-        battery: battery_str,
-        battery_capacity,
-        battery_charging,
-        volume,
-        volume_muted,
-        brightness,
+        cpu_pct: Some(0),
+        battery: read_battery_details(),
+        volume: pollster::block_on(read_volume()),
+        brightness: read_brightness(),
     }
 }
 
@@ -169,38 +153,27 @@ pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEv
         let clock = chrono::Local::now().format("%A, %B %d, %Y %I:%M %p").to_string();
         let memory = read_memory_usage().unwrap_or_else(|| "Mem N/A".to_string());
         
-        let cpu_str = if let Some(current_cpu) = read_cpu_ticks() {
+        let cpu_pct = if let Some(current_cpu) = read_cpu_ticks() {
             let total_diff = current_cpu.0 - last_cpu.0;
             let idle_diff = current_cpu.1 - last_cpu.1;
             last_cpu = current_cpu;
             if total_diff > 0 {
                 let usage = 100.0 - (idle_diff as f32 * 100.0 / total_diff as f32);
-                format!("Cpu {:.0}%", usage)
+                Some(usage.round().clamp(0.0, 100.0) as u8)
             } else {
-                "Cpu 0%".to_string()
+                Some(0)
             }
         } else {
-            "Cpu N/A".to_string()
-        };
-
-        let (battery_str, battery_capacity, battery_charging) = if let Some((s, cap, chg)) = read_battery_details() {
-            (s, cap, chg)
-        } else {
-            ("".to_string(), 0, false)
+            None
         };
-        let (volume, volume_muted) = read_volume().await.unwrap_or_else(|| ("".to_string(), false));
-        let brightness = read_brightness().unwrap_or_default();
 
         let stats = SystemStats {
             clock,
             memory,
-            cpu: cpu_str,
-            battery: battery_str,
-            battery_capacity,
-            battery_charging,
-            volume,
-            volume_muted,
-            brightness,
+            cpu_pct,
+            battery: read_battery_details(),
+            volume: read_volume().await,
+            brightness: read_brightness(),
         };
         log::debug!("[spawn_system_stats] stats: {:?}", stats);
         let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));