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

commit5388e6205afe70d2d21febe4a6e5766f95b3b0db
parent6f5ee5751f
authorLucas Galante <[email protected]>
date2026-09-16 11:11
feat(stats): numbers beside the glyphs, all readouts in one segment

The readout is now glyph then number, module { icon_gap } apart, at the
module font and regular weight — the superimposed form's ghosting, bold
weight and digit pocket go (icon_alpha now defaults to 1, icon_weight is
opt-in, icon_pocket is removed). The launcher runs one combined segment,
`stats`, holding cpu, memory, brightness, volume and battery in a single
bubble, module { icon_spacing } apart, in the compositor's old right-side
order; the five single names stay valid --module values. The compositor
orders `stats` between tray and clock (cce-window-manager@bead0c3).

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

 CLAUDE.md      |  70 ++++++++-------
 src/config.rs  |  46 +++++-----
 src/main.rs    |  45 ++--------
 src/modules.rs | 268 ++++++++++++++++++++++++++++++++++++++-------------------
 4 files changed, 251 insertions(+), 178 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 90c1ee2..a8f1e40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ is one crate in the multi-repo `cce` workspace — see `../cce-compositor/WORKSP
 the workspace layout, the multi-repo git rules (commit here, never `git init` at the root),
 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/modules.rs` (the `StatusModule` trait and its ten 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 —
 numbers, not strings; the modules do the formatting), `src/icons.rs` (tinted
@@ -36,7 +36,8 @@ One binary, three modes, selected by CLI args in `main()`:
   resets it). This is the normal production mode: each module is its own process and
   its own Wayland surface.
 - **`--module <name>`** — a single-module bar segment. Valid names: `window`, `tray`,
-  `cpu`, `memory`, `brightness`, `volume`, `battery`, `clock`, `light_source`.
+  `stats`, `cpu`, `memory`, `brightness`, `volume`, `battery`, `clock`, `light_source`
+  (the daemon launches `stats`, not the five it combines).
 - **`--trigger-switcher`** — one-shot: writes `trigger` to the switcher socket of the
   running instance and exits (used as a keybinding target).
 
@@ -86,27 +87,31 @@ 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 stat modules read out as a glyph, not a label.** `cpu`, `memory`,
-`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 number is bold (`module { icon_weight }`,
-the one run in the bar that sets `TextPrim`'s weight field) and sits in a
-POCKET: a feathered `Prim::Glow` pool in the number's contrast color, hugging
-the digits and drawn between the glyph and the number (`IconPrim::pocket`,
-`module { icon_pocket }`). Same-hue was the problem — white digits on a white
-ghost had only the alpha gap for contrast — and the pocket is the bubble
-scrim's answer applied locally: darken the ground under the digits rather
-than outline the letterforms. The muted sink swaps to `volume-muted`, the
-charging battery to `battery-charging` (the bolt runs the glyph's full
-height so its tips clear the digit pocket); the battery also keeps its
-accent color while charging or under 10%. 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 stat modules read out as a glyph with the number beside it, not a
+label.** `cpu`, `memory`, `brightness`, `volume` and `battery` are
+`IconStat` implementations: each names a cce-icons glyph, the bare number
+and a color, and `IconReadout` draws the glyph (tinted that color, at
+`module { icon_alpha }`) with the number `module { icon_gap }` to its right
+— no unit symbol, since the glyph IS the unit ("87" beside the battery, not
+"Bat 87%"). **The launcher runs them as ONE segment**, `stats`
+(`StatsModule`): every readout in a single bubble, `module { icon_spacing }`
+apart, in the order cpu, memory, brightness, volume, battery — the order the
+compositor's `RIGHT_ORDER` gave the five separate segments, and `stats` has
+its own slot there between `tray` and `clock` (cce-window-manager
+2026-09-16). The five single names stay valid `--module` values for a bar
+that wants them apart; a blanket `impl<T: IconStat> StatusModule for T`
+lays a lone readout out through the same `readouts_width` /
+`render_readouts` the combined segment uses. (Superimposing the number on a
+ghosted glyph, with a bold weight and a dark pocket under the digits, was
+tried first on 2026-09-16 and replaced the same day by the side-by-side
+form; `icon_weight` survives as an opt-in, the pocket is gone.) The muted
+sink swaps to `volume-muted`, the charging battery to `battery-charging`;
+the battery also keeps its accent color while charging or under 10%. A
+reader with nothing (no battery, no backlight, no pactl) returns `None`
+and drops out of the row — a lone module with nothing 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
@@ -118,9 +123,9 @@ 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` reads as a percentage of the total in use (used = total
+stable width sizes every number at the "100" template, so a value crossing
+a digit boundary never resizes the surface, and the bubble eases to the
+live row. `memory` reads as a percentage of the total in use (used = total
 less free, buffers and page cache) since 2026-09-16 — the "Mem 10/62G"
 gigabyte form went with the label.
 
@@ -237,12 +242,13 @@ 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 { 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 { icon_weight }`
-(OpenType weight of that number, default 700) and `module { icon_pocket }`
-(opacity 0-1 of the dark pocket under the digits, default 0.6, 0 = off) and `module { text_raise }` (lifts module text above vertical center, logical
+default bar height − 6) and `module { icon_font_size }` (the number beside
+the glyph, default `module { font_size }`) and `module { icon_gap }` (glyph
+to number, logical px, default 4) and `module { icon_spacing }` (between
+readouts in the `stats` bubble, default `module { spacing }`) and
+`module { icon_alpha }` (glyph opacity 0-1, default 1) and
+`module { icon_weight }` (OpenType weight of the number, unset = regular)
+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 943a63b..fc6ffe9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -201,36 +201,38 @@ pub(crate) fn read_icon_size_from_config() -> f32 {
         .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.
+/// `module { icon_font_size }` — the size of the number beside a glyph.
+/// Defaults to the module font size, so the readouts match the clock.
 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)
+    cfg_f32("/module/icon_font_size").unwrap_or(font_size).max(1.0)
 }
 
-/// `module { icon_weight }` — OpenType weight of the number on a glyph
-/// (default 700, bold; 400 = the face's regular). Stroke width is what a
-/// digit of that size has to spare, so the number is heavier than the
-/// labels around it.
-pub(crate) fn read_icon_weight_from_config() -> Option<u16> {
-    let w = cfg_f32("/module/icon_weight").unwrap_or(700.0).clamp(1.0, 1000.0) as u16;
-    Some(w)
+/// `module { icon_gap }` — logical px between a glyph and its number
+/// (default 4). Narrower than `icon_spacing` so each pair reads as one.
+pub(crate) fn read_icon_gap_from_config() -> f32 {
+    cfg_f32("/module/icon_gap").unwrap_or(4.0).max(0.0)
+}
+
+/// `module { icon_spacing }` — logical px between readouts sharing a bubble
+/// (the `stats` segment). Defaults to the gap between segments, so the
+/// combined bubble is spaced like the strip it replaced.
+pub(crate) fn read_icon_spacing_from_config() -> f32 {
+    cfg_f32("/module/icon_spacing")
+        .unwrap_or_else(read_status_module_spacing_from_config)
+        .max(0.0)
 }
 
-/// `module { icon_pocket }` — opacity 0-1 (default 0.6) of the feathered
-/// pool under the digits, in the number's contrast color: the notch the
-/// number sits in, cut into the glyph so the digits are never read against
-/// the glyph's own tint. 0 disables it.
-pub(crate) fn read_icon_pocket_from_config() -> f32 {
-    cfg_f32("/module/icon_pocket").unwrap_or(0.6).clamp(0.0, 1.0)
+/// `module { icon_weight }` — OpenType weight of the number beside a glyph
+/// (700 = bold); unset leaves the face's regular, like every other label.
+pub(crate) fn read_icon_weight_from_config() -> Option<u16> {
+    cfg_f32("/module/icon_weight").map(|w| w.clamp(1.0, 1000.0) as u16)
 }
 
-/// `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.
+/// `module { icon_alpha }` — opacity 0-1 of the glyph (default 1). The
+/// glyph is tinted the number's color, so this is the one lever for making
+/// it read lighter than the digits.
 pub(crate) fn read_icon_alpha_from_config() -> f32 {
-    cfg_f32("/module/icon_alpha").unwrap_or(0.4).clamp(0.0, 1.0)
+    cfg_f32("/module/icon_alpha").unwrap_or(1.0).clamp(0.0, 1.0)
 }
 
 /// `module { text_contrast }` — adaptive contrast strength (0 = off, the
diff --git a/src/main.rs b/src/main.rs
index f7490a5..5dd3a80 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,7 +14,7 @@ pub(crate) use listeners::*;
 pub(crate) use stats::*;
 pub(crate) use tray::*;
 
-use modules::{StatusModule, WindowModule, ClockModule, BatteryModule, VolumeModule, BrightnessModule, MemoryModule, CpuModule, TrayModule, LightSourceModule};
+use modules::{StatusModule, WindowModule, ClockModule, BatteryModule, VolumeModule, BrightnessModule, MemoryModule, CpuModule, StatsModule, TrayModule, LightSourceModule};
 
 use std::collections::HashMap;
 use cce_ui::cosmic_text::{
@@ -86,23 +86,6 @@ pub struct IconPrim {
     pub w: f32,
     pub h: f32,
     pub alpha: f32,
-    /// The dark pocket the number sits in, cut into the glyph — see `Pocket`.
-    pub pocket: Option<Pocket>,
-}
-
-/// A feathered pool under a readout's digits (`cce_ui`'s `Prim::Glow`, the
-/// same ground treatment the bubble scrim is): solid through the core rect,
-/// fading to nothing across `feather` px outside it. Drawn between the glyph
-/// and the number, so the digits sit in a notch of the number's contrast
-/// color rather than on the glyph's own tint. Logical px.
-#[derive(Debug, Clone, Copy)]
-pub struct Pocket {
-    pub x: f32,
-    pub y: f32,
-    pub w: f32,
-    pub h: f32,
-    pub feather: f32,
-    pub color: [f32; 4],
 }
 
 /// The subset of [`SystemStats`] a given module actually paints, as a comparable
@@ -121,6 +104,7 @@ fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
         Some("volume") => Some(format!("{:?}", s.volume)),
         Some("brightness") => Some(format!("{:?}", s.brightness)),
         Some("window") | Some("tray") | Some("light_source") => None,
+        // `stats` paints every reader's value; so does an unknown name.
         _ => Some(format!(
             "{}|{:?}|{:?}|{:?}|{:?}|{:?}",
             s.clock, s.memory, s.cpu_pct, s.battery, s.volume, s.brightness
@@ -1195,13 +1179,6 @@ impl StatusApp {
                 ip.y = old_x;
                 ip.w = old_h;
                 ip.h = old_w;
-                if let Some(pk) = &mut ip.pocket {
-                    let (old_x, old_y, old_w, old_h) = (pk.x, pk.y, pk.w, pk.h);
-                    pk.x = old_y;
-                    pk.y = old_x;
-                    pk.w = old_h;
-                    pk.h = old_w;
-                }
             }
             // Rotate tray_item_bounds
             for tib in &mut self.tray_item_bounds {
@@ -1468,6 +1445,7 @@ impl cce_ui::engine::Application for StatusApp {
             "brightness" => Box::new(BrightnessModule),
             "volume" => Box::new(VolumeModule),
             "battery" => Box::new(BatteryModule),
+            "stats" => Box::new(StatsModule),
             "clock" => Box::new(ClockModule),
             "light_source" => Box::new(LightSourceModule),
             _ => panic!("Unknown module: {}", name),
@@ -1502,7 +1480,7 @@ impl cce_ui::engine::Application for StatusApp {
             tokio::spawn(spawn_status_tray(sender.clone()));
         }
         let has_stats = selected_module.as_ref().map_or(true, |(name, _)| {
-            name == "cpu" || name == "memory" || name == "brightness" || name == "volume" || name == "battery" || name == "clock"
+            name == "stats" || name == "cpu" || name == "memory" || name == "brightness" || name == "volume" || name == "battery" || name == "clock"
         });
         if has_stats {
             tokio::spawn(spawn_system_stats(sender.clone()));
@@ -1955,14 +1933,9 @@ 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.
+        // Readout glyphs: post-scrim so the pool grounds them like any run.
         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);
-            if let Some(pk) = ip.pocket {
-                let core = Rect { x: pk.x, y: pk.y, width: pk.w, height: pk.h };
-                pc.glow(core, pk.h.min(pk.w) / 2.0, pk.feather, pk.color);
-            }
         }
 
         for (text, tsize, x, y, color, font, bounds, layout, _run_w, weight) in &self.text_prims {
@@ -2367,10 +2340,10 @@ fn main() {
             let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT");
             let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM");
             
-            let modules = vec![
-                "window", "tray", "cpu", "memory", "brightness",
-                "volume", "battery", "clock", "light_source"
-            ];
+            // `stats` is the combined readout segment (cpu, memory,
+            // brightness, volume, battery in one bubble); the five single
+            // names remain valid `--module` values but are not launched.
+            let modules = vec!["window", "tray", "stats", "clock", "light_source"];
             let current_exe = std::env::current_exe().unwrap_or_else(|_| {
                 std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
                     .join(".local/bin/cce-status-interface")
diff --git a/src/modules.rs b/src/modules.rs
index a84b53a..7216627 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -284,11 +284,10 @@ impl StatusModule for ClockModule {
     }
 }
 
-/// 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`.
+/// A stat module's readout: a cce-icons glyph with its value beside it —
+/// the glyph IS the unit, so the number is bare ("87" next to the battery,
+/// not "Bat 87%"). The glyph is tinted the readout's color; 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
@@ -296,7 +295,7 @@ impl StatusModule for ClockModule {
 /// 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
+    /// The number drawn beside 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>,
@@ -320,8 +319,8 @@ impl IconReadout {
         Some((image, w as f32 / scale, h as f32 / scale))
     }
 
-    /// The number's font size — `module { icon_font_size }`, else a
-    /// fraction of the module font.
+    /// The number's font size — `module { icon_font_size }`, else the
+    /// module font.
     fn number_size(font_size: f32) -> f32 {
         crate::read_icon_font_size_from_config(font_size)
     }
@@ -332,35 +331,29 @@ impl IconReadout {
         buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / cce_ui::scale::scale_factor()
     }
 
-    /// 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 {
+    /// This item's width — glyph, gap and number — with the number as the
+    /// live value (`template` false) or the widest plausible one (`template`
+    /// true, for the stable slot). In text fallback, the fallback string or
+    /// its template.
+    fn item_width(&self, font_system: &mut FontSystem, font_family: &str, font_size: f32, template: bool) -> f32 {
         match self.glyph() {
             Some((_, gw, _)) => {
                 let ns = Self::number_size(font_size);
-                let tmpl = Self::number_width(font_system, NUMBER_TEMPLATE, ns, font_family);
-                let live = self.number.as_deref().map_or(0.0, |n| Self::number_width(font_system, n, ns, font_family));
-                gw.max(tmpl).max(live) + 2.0 * padding
+                let nw = if template {
+                    Self::number_width(font_system, NUMBER_TEMPLATE, ns, font_family)
+                } else {
+                    self.number.as_deref().map_or(0.0, |n| Self::number_width(font_system, n, ns, font_family))
+                };
+                if nw > 0.0 { gw + crate::read_icon_gap_from_config() + nw } else { gw }
             }
-            None => stable_text_width(font_system, &self.fallback, self.fallback_template, 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| Self::number_width(font_system, n, ns, font_family));
-                gw.max(live) + 2.0 * padding
+            None => {
+                let text = if template { self.fallback_template } else { self.fallback.as_str() };
+                Label::new_with_family(font_system, text, font_size, [0.0, 0.0, 0.0, 1.0], font_family).w
             }
-            None => live_text_width(font_system, &self.fallback, font_size, font_family, padding),
         }
     }
 
+    /// Draw the item with its left edge at `x`; returns the width drawn.
     fn render(
         &self,
         x: f32,
@@ -370,78 +363,94 @@ impl IconReadout {
         bar_h: f32,
         text_prims: &mut Vec<crate::TextPrim>,
         icon_prims: &mut Vec<crate::IconPrim>,
-        padding: f32,
-    ) {
+    ) -> f32 {
         match self.glyph() {
             Some((image, gw, gh)) => {
-                let ns = Self::number_size(font_size);
-                let weight = crate::read_icon_weight_from_config();
-                let number = self
-                    .number
-                    .as_deref()
-                    .map(|n| (n.to_string(), Self::number_width(font_system, n, ns, 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(number.as_ref().map_or(0.0, |(_, w)| *w));
-                let cx = x + padding + span / 2.0;
+                // The same `module { text_raise }` lift every text run gets
+                // via `centered_text_y` is applied to the glyph too, so
+                // glyph and number stay level with each other and with the
+                // neighboring modules' text.
                 let gy = (bar_h - gh) / 2.0 - crate::config::read_text_raise_from_config();
-                let rgb = crate::icons::tint_of(self.color);
-                let mut pocket = None;
-                if let Some((text, nw)) = number {
-                    let nx = cx - nw / 2.0;
-                    let ny = centered_text_y(bar_h, ns);
-                    // The pocket hugs the digits, not the line box: in a
-                    // line box of exactly `ns`, digits run from about the
-                    // cap line at 0.1em down to the baseline near 0.85em.
-                    // Its feather reaches out from that core, so the notch
-                    // in the glyph is a little larger than the digits.
-                    let pocket_alpha = crate::read_icon_pocket_from_config();
-                    if pocket_alpha > 0.0 {
-                        let c = crate::treatment_rgb(rgb);
-                        pocket = Some(crate::Pocket {
-                            x: nx - 1.0,
-                            y: ny + ns * 0.1,
-                            w: nw + 2.0,
-                            h: ns * 0.75,
-                            feather: ns * 0.3,
-                            color: [c[0], c[1], c[2], pocket_alpha],
-                        });
-                    }
+                icon_prims.push(crate::IconPrim {
+                    image,
+                    x,
+                    y: gy,
+                    w: gw,
+                    h: gh,
+                    alpha: crate::read_icon_alpha_from_config(),
+                });
+                let mut w = gw;
+                if let Some(n) = self.number.as_deref() {
+                    let ns = Self::number_size(font_size);
+                    let nw = Self::number_width(font_system, n, ns, font_family);
+                    let nx = x + gw + crate::read_icon_gap_from_config();
                     text_prims.push((
-                        text,
+                        n.to_string(),
                         ns,
                         nx,
-                        ny,
-                        rgb,
+                        centered_text_y(bar_h, ns),
+                        crate::icons::tint_of(self.color),
                         Some(font_family.to_string()),
                         None,
                         None,
                         Some(nw),
-                        weight,
+                        crate::read_icon_weight_from_config(),
                     ));
+                    w = nx + nw - x;
                 }
-                icon_prims.push(crate::IconPrim {
-                    image,
-                    x: cx - gw / 2.0,
-                    y: gy,
-                    w: gw,
-                    h: gh,
-                    alpha: crate::read_icon_alpha_from_config(),
-                    pocket,
-                });
+                w
             }
             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));
+                crate::draw_label(text_prims, label, x, centered_text_y(bar_h, font_size))
             }
         }
     }
 }
 
-/// A module that reads out as an [`IconReadout`]: it only has to say which
+/// Width of a row of readouts in one bubble: `padding` inside each end,
+/// `module { icon_spacing }` between items. 0 for an empty row (the module
+/// hides). `template` sizes every number at its widest, for the stable
+/// slot; the live row is what the bubble hugs.
+fn readouts_width(
+    items: &[IconReadout],
+    font_system: &mut FontSystem,
+    font_family: &str,
+    font_size: f32,
+    padding: f32,
+    template: bool,
+) -> f32 {
+    if items.is_empty() {
+        return 0.0;
+    }
+    let spacing = crate::read_icon_spacing_from_config();
+    let sum: f32 = items.iter().map(|r| r.item_width(font_system, font_family, font_size, template)).sum();
+    sum + spacing * (items.len() as f32 - 1.0) + 2.0 * padding
+}
+
+/// Draw a row of readouts starting at the bubble's left edge `x`.
+fn render_readouts(
+    items: &[IconReadout],
+    x: f32,
+    font_system: &mut FontSystem,
+    font_family: &str,
+    font_size: f32,
+    bar_h: f32,
+    text_prims: &mut Vec<crate::TextPrim>,
+    icon_prims: &mut Vec<crate::IconPrim>,
+    padding: f32,
+) {
+    let spacing = crate::read_icon_spacing_from_config();
+    let mut cx = x + padding;
+    for (i, r) in items.iter().enumerate() {
+        if i > 0 {
+            cx += spacing;
+        }
+        cx += r.render(cx, font_system, font_family, font_size, bar_h, text_prims, icon_prims);
+    }
+}
+
+/// A module that reads out as one [`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.
@@ -465,8 +474,9 @@ impl<T: IconStat> StatusModule for T {
     ) -> f32 {
         // 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))
+        let items: Vec<_> = T::readout(stats, color::TEXT_FG).into_iter().collect();
+        readouts_width(&items, font_system, font_family, font_size, padding, true)
+            .max(readouts_width(&items, font_system, font_family, font_size, padding, false))
     }
 
     fn content_width(
@@ -479,8 +489,8 @@ impl<T: IconStat> StatusModule for T {
         _tray_items: &HashMap<String, TrayItem>,
         padding: f32,
     ) -> f32 {
-        T::readout(stats, color::TEXT_FG)
-            .map_or(0.0, |r| r.content_width(font_system, font_family, font_size, padding))
+        let items: Vec<_> = T::readout(stats, color::TEXT_FG).into_iter().collect();
+        readouts_width(&items, font_system, font_family, font_size, padding, false)
     }
 
     fn render(
@@ -506,9 +516,91 @@ impl<T: IconStat> StatusModule for T {
         _rounded_boxes: &mut Vec<RoundedBox>,
         padding: f32,
     ) {
-        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);
-        }
+        let items: Vec<_> = T::readout(stats, normal_color).into_iter().collect();
+        render_readouts(&items, x, font_system, font_family, font_size, bar_h, text_prims, icon_prims, padding);
+    }
+}
+
+/// The combined readout segment: every `IconStat` module's readout in one
+/// bubble, in the order the compositor used to lay the five separate
+/// segments out (cpu, memory, brightness, volume, battery). A reader with
+/// nothing (no battery, no backlight) simply drops out of the row. This is
+/// what the launcher daemon runs; the five single names stay valid for a
+/// bar configured to run them separately.
+pub struct StatsModule;
+
+impl StatsModule {
+    fn readouts(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Vec<IconReadout> {
+        [
+            CpuModule::readout(stats, normal_color),
+            MemoryModule::readout(stats, normal_color),
+            BrightnessModule::readout(stats, normal_color),
+            VolumeModule::readout(stats, normal_color),
+            BatteryModule::readout(stats, normal_color),
+        ]
+        .into_iter()
+        .flatten()
+        .collect()
+    }
+}
+
+impl StatusModule for StatsModule {
+    fn name(&self) -> &'static str { "stats" }
+
+    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 {
+        let items = Self::readouts(stats, color::TEXT_FG);
+        readouts_width(&items, font_system, font_family, font_size, padding, true)
+            .max(readouts_width(&items, font_system, font_family, font_size, padding, false))
+    }
+
+    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 {
+        let items = Self::readouts(stats, color::TEXT_FG);
+        readouts_width(&items, font_system, font_family, font_size, padding, false)
+    }
+
+    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>,
+        icon_prims: &mut Vec<crate::IconPrim>,
+        _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,
+    ) {
+        let items = Self::readouts(stats, normal_color);
+        render_readouts(&items, x, font_system, font_family, font_size, bar_h, text_prims, icon_prims, padding);
     }
 }