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

commit6f5ee5751f1cdc5f876666c6b72268ebfc351849
parentd64bcde414
authorLucas Galante <[email protected]>
date2026-09-16 10:24
feat(memory): the memory module is an icon readout, in percent

The reader now returns memory in use as a whole percentage of the total
(used = total less free, buffers and page cache) instead of the
"Mem 10/62G" string, and the module joins the IconStat set on the new
cce-icons memory glyph, with "Mem 16%" as its text fallback.

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

 CLAUDE.md      | 12 ++++----
 src/main.rs    |  8 ++++--
 src/modules.rs | 87 ++++++++++------------------------------------------------
 src/stats.rs   | 21 ++++++++------
 4 files changed, 39 insertions(+), 89 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f8325a1..90c1ee2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,7 +86,7 @@ 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`,
+**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
@@ -120,7 +120,9 @@ 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.
+surface. `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.
 
 ## Events and IPC
 
@@ -141,9 +143,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.
+  `SystemStats` carries numbers (`cpu_pct`, `memory`, `battery: (capacity,
+  charging)`, `volume: (level, muted)`, `brightness`), each `Option` where
+  the source can be absent; only the clock arrives 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`).
diff --git a/src/main.rs b/src/main.rs
index cea0362..f7490a5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -58,7 +58,9 @@ pub struct TrayIconBounds {
 #[derive(Debug, Clone)]
 pub struct SystemStats {
     pub clock: String,
-    pub memory: String,
+    /// Memory in use as a whole percentage of the total; `None` when
+    /// /proc/meminfo is unreadable.
+    pub memory: Option<u8>,
     /// CPU busy share as a whole percentage; `None` when /proc/stat is
     /// unreadable.
     pub cpu_pct: Option<u8>,
@@ -114,13 +116,13 @@ fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
     match module {
         Some("clock") => Some(s.clock.clone()),
         Some("cpu") => Some(format!("{:?}", s.cpu_pct)),
-        Some("memory") => Some(s.memory.clone()),
+        Some("memory") => Some(format!("{:?}", s.memory)),
         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_pct, s.battery, s.volume, s.brightness
         )),
     }
diff --git a/src/modules.rs b/src/modules.rs
index e912504..a84b53a 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -587,80 +587,21 @@ impl IconStat for BrightnessModule {
 
 pub struct MemoryModule;
 
-impl MemoryModule {
-    fn live_text<'a>(stats: &'a Option<SystemStats>) -> &'a str {
-        match stats {
-            Some(s) if !s.memory.is_empty() => &s.memory,
-            _ => "Mem 0/0G",
-        }
-    }
-}
-
-impl StatusModule for MemoryModule {
-    fn name(&self) -> &'static str { "memory" }
-
-    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 text = Self::live_text(stats);
-        // "Mem 17/62G" → "Mem 62/62G": used pinned to the total, the
-        // widest this machine's readout gets.
-        let template = text
-            .rsplit('/')
-            .next()
-            .and_then(|total| total.strip_suffix('G'))
-            .map(|total| format!("Mem {total}/{total}G"))
-            .unwrap_or_else(|| text.to_string());
-        stable_text_width(font_system, text, &template, font_size, font_family, padding)
-    }
+impl IconStat for MemoryModule {
+    const NAME: &'static str = "memory";
 
-    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>,
-        _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,
-    ) {
-        if let Some(ref s) = stats {
-            let label = Label::new_with_family(font_system, &s.memory, 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.memory,
+            None => Some(0),
+        };
+        Some(IconReadout {
+            icon: "memory",
+            number: pct.map(|p| p.to_string()),
+            color: normal_color,
+            fallback: pct.map_or("Mem N/A".to_string(), |p| format!("Mem {p}%")),
+            fallback_template: "Mem 100%",
+        })
     }
 }
 
diff --git a/src/stats.rs b/src/stats.rs
index dfc8f54..4675818 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -21,26 +21,31 @@ pub(crate) fn read_cpu_ticks() -> Option<(u64, u64)> {
     None
 }
 
-pub(crate) fn read_memory_usage() -> Option<String> {
+/// Memory in use as a whole percentage of the total — used being total less
+/// free, buffers and page cache, so what an application could not have
+/// without the kernel dropping cache first. `None` when /proc/meminfo is
+/// unreadable.
+pub(crate) fn read_memory_usage() -> Option<u8> {
     let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
     let mut total = 0.0;
     let mut free = 0.0;
     let mut buffers = 0.0;
     let mut cached = 0.0;
     for line in meminfo.lines() {
+        let kib = |line: &str| line.split_whitespace().nth(1).and_then(|v| v.parse::<f32>().ok());
         if line.starts_with("MemTotal:") {
-            total = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+            total = kib(line)?;
         } else if line.starts_with("MemFree:") {
-            free = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+            free = kib(line)?;
         } else if line.starts_with("Buffers:") {
-            buffers = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+            buffers = kib(line)?;
         } else if line.starts_with("Cached:") {
-            cached = line.split_whitespace().nth(1)?.parse::<f32>().ok()? / 1024.0 / 1024.0;
+            cached = kib(line)?;
         }
     }
     if total > 0.0 {
         let used = total - free - buffers - cached;
-        Some(format!("Mem {:.0}/{:.0}G", used, total))
+        Some((used / total * 100.0).round().clamp(0.0, 100.0) as u8)
     } else {
         None
     }
@@ -133,7 +138,7 @@ pub(crate) async fn read_volume() -> Option<(Option<u32>, bool)> {
 
 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 memory = read_memory_usage();
 
     SystemStats {
         clock,
@@ -151,7 +156,7 @@ pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEv
     loop {
         log::debug!("[spawn_system_stats] loop iteration start");
         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 memory = read_memory_usage();
         
         let cpu_pct = if let Some(current_cpu) = read_cpu_ticks() {
             let total_diff = current_cpu.0 - last_cpu.0;