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