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

commita45cf11ddfaf7b6b746e87202d41ee28c79c4964
parentbbf0628215
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 08:08
stats: a wifi readout, between volume and battery

read_wifi finds wireless adapters under /sys/class/net and takes the
link quality from /proc/net/wireless (cfg80211's 0..=70 scale, so 70 is
100%), in the existing once-a-second loop. A disconnected adapter reads
like a muted sink -- wifi-off, dimmed, no number -- and a machine with
no adapter drops the readout from the row. `--module wifi` is valid on
its own too, though the compositor's RIGHT_ORDER does not know it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      | 33 +++++++++++++++++++++------------
 src/main.rs    | 34 ++++++++++++++++++++++++++++------
 src/modules.rs | 42 ++++++++++++++++++++++++++++++++++++++----
 src/stats.rs   | 47 +++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 134 insertions(+), 22 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2d6e212..c2f8430 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 ten implementations),
+`src/modules.rs` (the `StatusModule` trait and its eleven 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
@@ -19,7 +19,7 @@ cce-icons glyph textures), `src/listeners.rs` (status/switcher socket tasks).
 
 ```sh
 cargo build --release                 # standalone build (or `-p cce-status-interface` from the workspace root)
-cargo test                            # 37 tests: main.rs (parsers, droplet geometry), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
+cargo test                            # 38 tests: main.rs (parsers, droplet geometry), config.rs, tray.rs, the tray bridge's x11.rs and title.rs
 make install                          # release build, then `ccebuild install --no-build cce-status-interface`
 ```
 
@@ -117,8 +117,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`,
-  `stats`, `cpu`, `memory`, `brightness`, `volume`, `battery`, `clock`, `light_source`
-  (the daemon launches `stats`, not the five it combines).
+  `stats`, `cpu`, `memory`, `brightness`, `volume`, `wifi`, `battery`, `clock`,
+  `light_source` (the daemon launches `stats`, not the six it combines).
 - **`--trigger-switcher`** — one-shot: writes `trigger` to the switcher socket of the
   running instance and exits (used as a keybinding target).
 
@@ -169,17 +169,19 @@ configured bar thickness; every module renders along one axis using `bar_h`/`coo
 accordingly.
 
 **The stat modules read out as a glyph with the number beside it, not a
-label.** `cpu`, `memory`, `brightness`, `volume` and `battery` are
+label.** `cpu`, `memory`, `brightness`, `volume`, `wifi` 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
+apart, in the order cpu, memory, brightness, volume, wifi, battery — the
+order the compositor's `RIGHT_ORDER` gave the five separate segments, with
+wifi (added 2026-10-02, after that order) beside volume, 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
+2026-09-16). `RIGHT_ORDER` has no `wifi`, so a lone `--module wifi` segment
+sorts last, past the clock. The 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
@@ -187,8 +189,11 @@ 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`
+the battery also keeps its accent color while charging or under 10%. Wifi
+is the link's signal strength, and a disconnected adapter reads like a
+muted sink: `wifi-off`, dimmed, no number. A
+reader with nothing (no battery, no backlight, no pactl, no wireless
+adapter) 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
@@ -235,9 +240,13 @@ listen to tray D-Bus, etc.:
   topics ends in `unreachable!()` — adding a subscription means adding its arm
   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.
+  `/sys/class/power_supply/BAT*`, `/sys/class/backlight`, `pactl` for volume/mute,
+  and `/sys/class/net/*/wireless` + `/proc/net/wireless` for wifi (`read_wifi`:
+  the link is connected when its `operstate` is `up`, and the quality column is
+  on cfg80211's 0..=70 scale, so 70 is 100%).
   `SystemStats` carries numbers (`cpu_pct`, `memory`, `battery: (capacity,
-  charging)`, `volume: (level, muted)`, `brightness`), each `Option` where
+  charging)`, `volume: (level, muted)`, `brightness`, `wifi: (signal,
+  connected)`), each `Option` where
   the source can be absent; only the clock arrives pre-formatted. The loop is
   once a second, which is fine for a clock or a load average and far too slow
   for the two values a KEYPRESS moves — so the backlight and the sink have a
diff --git a/src/main.rs b/src/main.rs
index 5123310..9984004 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, StatsModule, TrayModule, LightSourceModule};
+use modules::{StatusModule, WindowModule, ClockModule, BatteryModule, VolumeModule, BrightnessModule, MemoryModule, CpuModule, WifiModule, StatsModule, TrayModule, LightSourceModule};
 
 use std::collections::HashMap;
 use cce_ui::cosmic_text::{
@@ -71,6 +71,9 @@ pub struct SystemStats {
     pub volume: Option<(Option<u32>, bool)>,
     /// Backlight level as a whole percentage; `None` without a backlight.
     pub brightness: Option<i32>,
+    /// `(signal %, connected)`; `None` without a wireless interface. The
+    /// signal is `None` while disconnected.
+    pub wifi: Option<(Option<u8>, bool)>,
 }
 
 /// A bundled cce-icons glyph placed in the bar: `image` is the tinted
@@ -103,11 +106,12 @@ fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
         Some("battery") => Some(format!("{:?}", s.battery)),
         Some("volume") => Some(format!("{:?}", s.volume)),
         Some("brightness") => Some(format!("{:?}", s.brightness)),
+        Some("wifi") => Some(format!("{:?}", s.wifi)),
         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
+            "{}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
+            s.clock, s.memory, s.cpu_pct, s.battery, s.volume, s.brightness, s.wifi
         )),
     }
 }
@@ -120,7 +124,7 @@ fn stats_signature(module: Option<&str>, s: &SystemStats) -> Option<String> {
 fn paints_stat(module: Option<&str>, field: &str) -> bool {
     match module {
         // The other single-stat modules; stats-blind modules.
-        Some("clock") | Some("cpu") | Some("memory") | Some("battery") => false,
+        Some("clock") | Some("cpu") | Some("memory") | Some("battery") | Some("wifi") => false,
         Some("window") | Some("tray") | Some("light_source") => false,
         Some("brightness") => field == "brightness",
         Some("volume") => field == "volume",
@@ -1329,6 +1333,7 @@ impl cce_ui::engine::Application for StatusApp {
             "brightness" => Box::new(BrightnessModule),
             "volume" => Box::new(VolumeModule),
             "battery" => Box::new(BatteryModule),
+            "wifi" => Box::new(WifiModule),
             "stats" => Box::new(StatsModule),
             "clock" => Box::new(ClockModule),
             "light_source" => Box::new(LightSourceModule),
@@ -1353,7 +1358,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 == "stats" || 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 == "wifi" || name == "clock"
         });
         if has_stats {
             tokio::spawn(spawn_system_stats(sender.clone()));
@@ -2152,7 +2157,7 @@ fn main() {
             let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM");
             
             // `stats` is the combined readout segment (cpu, memory,
-            // brightness, volume, battery in one bubble); the five single
+            // brightness, volume, wifi, battery in one bubble); the six 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(|_| {
@@ -2561,6 +2566,7 @@ mod tests {
             battery: Some((1, false)),
             volume: Some((Some(10), false)),
             brightness: Some(10),
+            wifi: Some((Some(80), true)),
         };
         for field in ["brightness", "volume"] {
             for module in [
@@ -2572,6 +2578,7 @@ mod tests {
                 Some("cpu"),
                 Some("memory"),
                 Some("battery"),
+                Some("wifi"),
                 Some("window"),
                 Some("tray"),
                 Some("light_source"),
@@ -2605,4 +2612,19 @@ mod tests {
         assert!(!is_sink_event("Event 'new' on source-output #7"));
         assert!(!is_sink_event(""));
     }
+
+    /// The signal is read off the interface's own row, the quality column's
+    /// trailing dot and all, on cfg80211's 0..=70 scale; the two header rows
+    /// and other interfaces' rows are not mistaken for it.
+    #[test]
+    fn wifi_signal_reads_the_interfaces_quality() {
+        let table = "Inter-| sta-|   Quality        |   Discarded packets               | Missed | WE\n \
+                      face | tus | link level noise |  nwid  crypt   frag  retry   misc | beacon | 22\n \
+                     wlan1: 0000   35.  -75.  -256        0      0      0      0      0        0\n\
+                     wlp0s20f3: 0000   63.  -47.  -256        0      0      0      0      2        0\n";
+        assert_eq!(stats::wifi_signal(table, "wlp0s20f3"), Some(90));
+        assert_eq!(stats::wifi_signal(table, "wlan1"), Some(50));
+        assert_eq!(stats::wifi_signal(table, "wlan0"), None);
+        assert_eq!(stats::wifi_signal("", "wlp0s20f3"), None);
+    }
 }
diff --git a/src/modules.rs b/src/modules.rs
index 0c0d6fd..b90ebcb 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -522,10 +522,11 @@ impl<T: IconStat> StatusModule for T {
 
 /// 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.
+/// segments out (cpu, memory, brightness, volume, battery), with wifi —
+/// which postdates that order — between volume and battery. A reader with
+/// nothing (no battery, no backlight, no wireless adapter) simply drops out
+/// of the row. This is what the launcher daemon runs; the single names stay
+/// valid for a bar configured to run them separately.
 pub struct StatsModule;
 
 impl StatsModule {
@@ -535,6 +536,7 @@ impl StatsModule {
             MemoryModule::readout(stats, normal_color),
             BrightnessModule::readout(stats, normal_color),
             VolumeModule::readout(stats, normal_color),
+            WifiModule::readout(stats, normal_color),
             BatteryModule::readout(stats, normal_color),
         ]
         .into_iter()
@@ -656,6 +658,38 @@ impl IconStat for VolumeModule {
     }
 }
 
+pub struct WifiModule;
+
+impl IconStat for WifiModule {
+    const NAME: &'static str = "wifi";
+
+    fn readout(stats: &Option<SystemStats>, normal_color: [f32; 4]) -> Option<IconReadout> {
+        let (signal, connected) = match stats {
+            Some(s) => s.wifi?,
+            None => (Some(100), true),
+        };
+        // Disconnected reads like a muted sink: the struck-through glyph,
+        // dimmed, with no number.
+        let color = if connected {
+            normal_color
+        } else {
+            crate::read_disabled_color_from_config().unwrap_or(color::TEXT_DIM)
+        };
+        let fallback = match (connected, signal) {
+            (false, _) => "Wifi Off".to_string(),
+            (true, Some(p)) => format!("Wifi {p}%"),
+            (true, None) => "Wifi N/A".to_string(),
+        };
+        Some(IconReadout {
+            icon: if connected { "wifi" } else { "wifi-off" },
+            number: signal.map(|p| p.to_string()),
+            color,
+            fallback,
+            fallback_template: "Wifi 100%",
+        })
+    }
+}
+
 pub struct BrightnessModule;
 
 impl IconStat for BrightnessModule {
diff --git a/src/stats.rs b/src/stats.rs
index 8a8f073..cf73183 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -87,6 +87,51 @@ pub(crate) fn read_brightness() -> Option<i32> {
     None
 }
 
+/// The top of the link-quality scale in `/proc/net/wireless`. cfg80211
+/// synthesizes the wireless-extensions stats for every mac80211 driver as
+/// `signal dBm + 110`, clamped to 0..=70, so 70 is the full-strength value
+/// on any modern adapter.
+const WIFI_QUALITY_MAX: f32 = 70.0;
+
+/// The Wi-Fi link as `(signal %, connected)`; `None` on a machine without a
+/// wireless interface, which hides the readout. With several adapters, a
+/// connected one wins over a disconnected one, then the stronger signal.
+/// The signal is `None` when the link is up but `/proc/net/wireless` has no
+/// quality for it.
+pub(crate) fn read_wifi() -> Option<(Option<u8>, bool)> {
+    let proc = std::fs::read_to_string("/proc/net/wireless").unwrap_or_default();
+    let mut best: Option<(Option<u8>, bool)> = None;
+    for entry in std::fs::read_dir("/sys/class/net").ok()?.flatten() {
+        let path = entry.path();
+        if !path.join("wireless").exists() && !path.join("phy80211").exists() {
+            continue;
+        }
+        let iface = entry.file_name().to_string_lossy().into_owned();
+        let connected = std::fs::read_to_string(path.join("operstate"))
+            .is_ok_and(|s| s.trim() == "up");
+        let signal = connected.then(|| wifi_signal(&proc, &iface)).flatten();
+        let cand = (signal, connected);
+        if best.is_none_or(|b| (cand.1, cand.0) > (b.1, b.0)) {
+            best = Some(cand);
+        }
+    }
+    best
+}
+
+/// `iface`'s link quality from `/proc/net/wireless` as a whole percentage.
+/// The table's rows are `  wlp0s20f3: 0000   63.  -47.  -256 ...` — the
+/// interface, a status word, then the quality with a trailing dot.
+pub(crate) fn wifi_signal(proc_net_wireless: &str, iface: &str) -> Option<u8> {
+    proc_net_wireless.lines().find_map(|line| {
+        let (name, rest) = line.split_once(':')?;
+        if name.trim() != iface {
+            return None;
+        }
+        let qual: f32 = rest.split_whitespace().nth(1)?.trim_end_matches('.').parse().ok()?;
+        Some((qual / WIFI_QUALITY_MAX * 100.0).round().clamp(0.0, 100.0) as u8)
+    })
+}
+
 /// 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)> {
@@ -147,6 +192,7 @@ pub(crate) fn get_initial_stats() -> SystemStats {
         battery: read_battery_details(),
         volume: pollster::block_on(read_volume()),
         brightness: read_brightness(),
+        wifi: read_wifi(),
     }
 }
 
@@ -179,6 +225,7 @@ pub(crate) async fn spawn_system_stats(sender: calloop::channel::Sender<CustomEv
             battery: read_battery_details(),
             volume: read_volume().await,
             brightness: read_brightness(),
+            wifi: read_wifi(),
         };
         log::debug!("[spawn_system_stats] stats: {:?}", stats);
         let _ = sender.send(CustomEvent::SystemStatsUpdated(stats));