diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 8b13789..78b0c22 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -1 +1,16 @@ +use crate::{App, AppInput}; +use relm4::ComponentSender; +pub fn current() -> String { + let dt = gtk4::glib::DateTime::now_local().expect("local time"); + format!("{:02}:{:02}", dt.hour(), dt.minute()) +} + +pub fn spawn_ticker(sender: ComponentSender) { + relm4::spawn(async move { + loop { + sender.input(AppInput::ClockTick); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + }); +} diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 8b13789..bad7c9f 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -1 +1,127 @@ +use crate::{App, AppInput}; +use relm4::ComponentSender; +use std::{fs, path::PathBuf, sync::Mutex}; +struct CpuSnapshot { + total: u64, + idle: u64, +} + +static PREV_CPU: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn read_cpu() -> f32 { + let text = fs::read_to_string("/proc/stat").unwrap_or_default(); + let line = text.lines().next().unwrap_or_default(); + let vals: Vec = line + .split_whitespace() + .skip(1) + .filter_map(|s| s.parse().ok()) + .collect(); + if vals.len() < 5 { + return 0.0; + } + let idle = vals[3] + vals.get(4).copied().unwrap_or(0); + let total: u64 = vals.iter().sum(); + + let state = PREV_CPU.get_or_init(|| Mutex::new(CpuSnapshot { total, idle })); + let mut prev = state.lock().unwrap(); + let dtotal = total.saturating_sub(prev.total); + let didle = idle.saturating_sub(prev.idle); + *prev = CpuSnapshot { total, idle }; + + if dtotal == 0 { + return 0.0; + } + (dtotal - didle) as f32 / dtotal as f32 * 100.0 +} + +fn read_ram() -> f32 { + let text = fs::read_to_string("/proc/meminfo").unwrap_or_default(); + let mut total = 0u64; + let mut avail = 0u64; + for line in text.lines() { + let mut parts = line.split_whitespace(); + match parts.next() { + Some("MemTotal:") => total = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0), + Some("MemAvailable:") => avail = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0), + _ => {} + } + } + if total == 0 { + return 0.0; + } + (total - avail) as f32 / total as f32 * 100.0 +} + +fn bat_path() -> Option { + fs::read_dir("/sys/class/power_supply") + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name() + .map_or(false, |n| n.to_string_lossy().starts_with("BAT")) + }) +} + +fn read_power() -> Option { + let path = bat_path()?; + if let Ok(v) = fs::read_to_string(path.join("power_now")) { + if let Ok(uw) = v.trim().parse::() { + return Some(uw as f32 / 1_000_000.0); + } + } + let ua: u64 = fs::read_to_string(path.join("current_now")) + .ok()? + .trim() + .parse() + .ok()?; + let uv: u64 = fs::read_to_string(path.join("voltage_now")) + .ok()? + .trim() + .parse() + .ok()?; + Some((ua as f64 * uv as f64 / 1e12) as f32) +} + +fn read_battery() -> Option { + fs::read_to_string(bat_path()?.join("capacity")) + .ok()? + .trim() + .parse() + .ok() +} + +async fn read_wifi() -> String { + let out = tokio::process::Command::new("iw") + .arg("dev") + .output() + .await + .ok(); + let stdout = match out { + Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), + _ => return "—".into(), + }; + stdout + .lines() + .find_map(|l| l.trim().strip_prefix("ssid ").map(str::to_string)) + .unwrap_or_else(|| "—".into()) +} + +pub async fn poll() -> String { + let cpu = read_cpu(); + let ram = read_ram(); + let power = read_power().map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); + let bat = read_battery().map_or_else(|| "—".into(), |p| format!("{p}%")); + let wifi = read_wifi().await; + format!("CPU {cpu:.0}% MEM {ram:.0}% {power} BAT {bat} {wifi}") +} + +pub fn spawn_poller(sender: ComponentSender) { + relm4::spawn(async move { + loop { + sender.input(AppInput::StatsUpdate(poll().await)); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }); +} diff --git a/src/main.rs b/src/main.rs index c8b544c..403ed91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,9 @@ use relm4::prelude::*; pub struct App { workspaces: Vec, active_ws: WorkspaceId, - // Stored handle so update() can manipulate the live widget directly. + time_str: String, + stats_str: String, + // GObject handle — manipulated directly in update() to avoid update_view conflicts. workspace_box: gtk4::Box, } @@ -19,6 +21,8 @@ pub struct App { pub enum AppInput { WorkspaceList(Vec), ActiveWorkspace(WorkspaceId), + ClockTick, + StatsUpdate(String), } #[relm4::component(pub)] @@ -49,7 +53,8 @@ impl SimpleComponent for App { #[wrap(Some)] set_center_widget = >k::Label { - set_label: "00:00", + #[watch] + set_label: &model.time_str, }, #[wrap(Some)] @@ -59,7 +64,8 @@ impl SimpleComponent for App { set_margin_end: 8, gtk::Label { - set_label: "— — — —", + #[watch] + set_label: &model.stats_str, } }, } @@ -78,19 +84,21 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); - // Placeholder until view_output! gives us the real handle. let mut model = App { workspaces: vec![], active_ws: 1, + time_str: bar::clock::current(), + stats_str: String::new(), workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), }; let widgets = view_output!(); - // Swap in the actual widget so update() can reach it. model.workspace_box = widgets.workspace_box.clone(); theme::apply(); - bar::workspaces::spawn_watcher(sender); + bar::workspaces::spawn_watcher(sender.clone()); + bar::clock::spawn_ticker(sender.clone()); + bar::stats::spawn_poller(sender); ComponentParts { model, widgets } } @@ -101,12 +109,19 @@ impl SimpleComponent for App { let mut sorted = list; sorted.sort_by_key(|w| w.id); self.workspaces = sorted; + self.rebuild_buttons(); } AppInput::ActiveWorkspace(id) => { self.active_ws = id; + self.rebuild_buttons(); + } + AppInput::ClockTick => { + self.time_str = bar::clock::current(); + } + AppInput::StatsUpdate(s) => { + self.stats_str = s; } } - self.rebuild_buttons(); } }