status bar
git clone https://git.lucas.co/cce-status-interface.git
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));