This commit is contained in:
2026-03-13 15:32:43 +01:00
commit 311f517f67
17 changed files with 1833 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
use crate::config::Config;
use crate::modules::WaybarModule;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::{Result, anyhow};
use std::process::Command;
pub struct AudioModule;
impl WaybarModule for AudioModule {
fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result<WaybarOutput> {
let target_type = args.first().unwrap_or(&"sink");
let action = args.get(1).unwrap_or(&"show");
match *action {
"cycle" => {
self.cycle_device(target_type)?;
return Ok(WaybarOutput {
text: String::new(),
tooltip: None,
class: None,
percentage: None,
});
}
"show" | _ => {
self.get_status(target_type)
}
}
}
}
impl AudioModule {
fn get_status(&self, target_type: &str) -> Result<WaybarOutput> {
let target = if target_type == "sink" { "@DEFAULT_AUDIO_SINK@" } else { "@DEFAULT_AUDIO_SOURCE@" };
// Get volume and mute status via wpctl (faster than pactl for this)
let output = Command::new("wpctl")
.args(["get-volume", target])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Output format: "Volume: 0.50" or "Volume: 0.50 [MUTED]"
let parts: Vec<&str> = stdout.trim().split_whitespace().collect();
if parts.len() < 2 {
return Err(anyhow!("Could not parse wpctl output: {}", stdout));
}
let vol_val: f64 = parts[1].parse().unwrap_or(0.0);
let vol = (vol_val * 100.0).round() as u8;
let display_vol = std::cmp::min(vol, 100);
let muted = stdout.contains("[MUTED]");
let description = self.get_description(target_type)?;
let name = if description.len() > 20 {
format!("{}...", &description[..17])
} else {
description.clone()
};
let (text, class) = if muted {
let icon = if target_type == "sink" { "" } else { "" };
(format!("{} {}", name, icon), "muted")
} else {
let icon = if target_type == "sink" {
if display_vol <= 30 { "" }
else if display_vol <= 60 { "" }
else { "" }
} else {
""
};
(format!("{} {}% {}", name, display_vol, icon), "unmuted")
};
Ok(WaybarOutput {
text,
tooltip: Some(description),
class: Some(class.to_string()),
percentage: Some(display_vol),
})
}
fn get_description(&self, target_type: &str) -> Result<String> {
// Get the default device name
let info_output = Command::new("pactl").arg("info").output()?;
let info_stdout = String::from_utf8_lossy(&info_output.stdout);
let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" };
let default_dev = info_stdout.lines()
.find(|l| l.contains(search_key))
.and_then(|l| l.split(':').nth(1))
.map(|s| s.trim())
.ok_or_else(|| anyhow!("Default {} not found", target_type))?;
// Get the description of that device
let list_cmd = if target_type == "sink" { "sinks" } else { "sources" };
let list_output = Command::new("pactl").args(["list", list_cmd]).output()?;
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
let mut current_name = String::new();
for line in list_stdout.lines() {
if line.trim().starts_with("Name: ") {
current_name = line.split(':').nth(1).unwrap_or("").trim().to_string();
}
if current_name == default_dev && line.trim().starts_with("Description: ") {
return Ok(line.split(':').nth(1).unwrap_or("").trim().to_string());
}
}
Ok(default_dev.to_string())
}
fn cycle_device(&self, target_type: &str) -> Result<()> {
let list_cmd = if target_type == "sink" { "sinks" } else { "sources" };
let output = Command::new("pactl").args(["list", "short", list_cmd]).output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut devices: Vec<String> = stdout.lines()
.filter_map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
if parts.len() >= 2 {
let name = parts[1].to_string();
if target_type == "source" && name.contains(".monitor") {
None
} else {
Some(name)
}
} else {
None
}
})
.collect();
if devices.is_empty() { return Ok(()); }
let info_output = Command::new("pactl").arg("info").output()?;
let info_stdout = String::from_utf8_lossy(&info_output.stdout);
let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" };
let current_dev = info_stdout.lines()
.find(|l| l.contains(search_key))
.and_then(|l| l.split(':').nth(1))
.map(|s| s.trim())
.unwrap_or("");
let current_index = devices.iter().position(|d| d == current_dev).unwrap_or(0);
let next_index = (current_index + 1) % devices.len();
let next_dev = &devices[next_index];
let set_cmd = if target_type == "sink" { "set-default-sink" } else { "set-default-source" };
Command::new("pactl").args([set_cmd, next_dev]).status()?;
Ok(())
}
}
+53
View File
@@ -0,0 +1,53 @@
use crate::config::Config;
use crate::modules::WaybarModule;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::Result;
use sysinfo::Disks;
pub struct BtrfsModule;
impl WaybarModule for BtrfsModule {
fn run(&self, _config: &Config, _state: &SharedState, _args: &[&str]) -> Result<WaybarOutput> {
let disks = Disks::new_with_refreshed_list();
let mut total_used: f64 = 0.0;
let mut total_size: f64 = 0.0;
for disk in &disks {
if disk.file_system().to_string_lossy().to_lowercase().contains("btrfs") {
let size = disk.total_space() as f64;
let available = disk.available_space() as f64;
total_size += size;
total_used += size - available;
}
}
if total_size == 0.0 {
return Ok(WaybarOutput {
text: "No BTRFS".to_string(),
tooltip: None,
class: Some("normal".to_string()),
percentage: None,
});
}
let used_gb = total_used / 1024.0 / 1024.0 / 1024.0;
let size_gb = total_size / 1024.0 / 1024.0 / 1024.0;
let percentage = (total_used / total_size) * 100.0;
let class = if percentage > 95.0 {
"max"
} else if percentage > 80.0 {
"high"
} else {
"normal"
};
Ok(WaybarOutput {
text: format!("{:.0}G / {:.0}G", used_gb, size_gb),
tooltip: Some(format!("BTRFS Usage: {:.1}%", percentage)),
class: Some(class.to_string()),
percentage: Some(percentage as u8),
})
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::config::Config;
use crate::modules::WaybarModule;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::Result;
pub struct CpuModule;
impl WaybarModule for CpuModule {
fn run(&self, _config: &Config, state: &SharedState, _args: &[&str]) -> Result<WaybarOutput> {
let (usage, temp, model) = {
if let Ok(state_lock) = state.read() {
(
state_lock.cpu.usage,
state_lock.cpu.temp,
state_lock.cpu.model.clone(),
)
} else {
(0.0, 0.0, String::from("Unknown"))
}
};
let text = format!("{:.1}% {:.1}C", usage, temp);
let class = if usage > 95.0 {
"max"
} else if usage > 75.0 {
"high"
} else {
"normal"
};
Ok(WaybarOutput {
text: format!("CPU: {}", text),
tooltip: Some(model),
class: Some(class.to_string()),
percentage: Some(usage as u8),
})
}
}
+46
View File
@@ -0,0 +1,46 @@
use crate::config::Config;
use crate::modules::WaybarModule;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::Result;
use sysinfo::Disks;
pub struct DiskModule;
impl WaybarModule for DiskModule {
fn run(&self, _config: &Config, _state: &SharedState, args: &[&str]) -> Result<WaybarOutput> {
let mountpoint = args.first().unwrap_or(&"/");
let disks = Disks::new_with_refreshed_list();
for disk in &disks {
if disk.mount_point().to_string_lossy() == *mountpoint {
let total = disk.total_space() as f64;
let available = disk.available_space() as f64;
let used = total - available;
let used_gb = used / 1024.0 / 1024.0 / 1024.0;
let total_gb = total / 1024.0 / 1024.0 / 1024.0;
let free_gb = available / 1024.0 / 1024.0 / 1024.0;
let percentage = if total > 0.0 { (used / total) * 100.0 } else { 0.0 };
let class = if percentage > 95.0 {
"max"
} else if percentage > 80.0 {
"high"
} else {
"normal"
};
return Ok(WaybarOutput {
text: format!("{} {:.1}G/{:.1}G", mountpoint, used_gb, total_gb),
tooltip: Some(format!("Used: {:.1}G\nTotal: {:.1}G\nFree: {:.1}G", used_gb, total_gb, free_gb)),
class: Some(class.to_string()),
percentage: Some(percentage as u8),
});
}
}
Err(anyhow::anyhow!("Mountpoint {} not found", mountpoint))
}
}
+52
View File
@@ -0,0 +1,52 @@
use crate::state::SharedState;
use sysinfo::{Components, System};
pub struct HardwareDaemon {
sys: System,
components: Components,
}
impl HardwareDaemon {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_all();
let components = Components::new_with_refreshed_list();
Self { sys, components }
}
pub fn poll(&mut self, state: SharedState) {
self.sys.refresh_cpu_usage();
self.sys.refresh_memory();
self.components.refresh(true);
let cpu_usage = self.sys.global_cpu_usage();
let cpu_model = self.sys.cpus().first().map(|c| c.brand().to_string()).unwrap_or_else(|| "Unknown".to_string());
// Try to find a reasonable CPU temperature
// Often 'coretemp' or 'k10temp' depending on AMD/Intel
let mut cpu_temp = 0.0;
for component in &self.components {
let label = component.label().to_lowercase();
if label.contains("tctl") || label.contains("cpu") || label.contains("package") || label.contains("temp1") {
if let Some(temp) = component.temperature() {
cpu_temp = temp as f64;
if cpu_temp > 0.0 { break; }
}
}
}
let total_mem = self.sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
// Accurate used memory matching htop/free (Total - Available)
let available_mem = self.sys.available_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
let used_mem = total_mem - available_mem;
if let Ok(mut state_lock) = state.write() {
state_lock.cpu.usage = cpu_usage as f64;
state_lock.cpu.temp = cpu_temp as f64;
state_lock.cpu.model = cpu_model;
state_lock.memory.total_gb = total_mem;
state_lock.memory.used_gb = used_mem;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
use crate::config::Config;
use crate::modules::WaybarModule;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::Result;
pub struct MemoryModule;
impl WaybarModule for MemoryModule {
fn run(&self, _config: &Config, state: &SharedState, _args: &[&str]) -> Result<WaybarOutput> {
let (used_gb, total_gb) = {
if let Ok(state_lock) = state.read() {
(
state_lock.memory.used_gb,
state_lock.memory.total_gb,
)
} else {
(0.0, 0.0)
}
};
let ratio = if total_gb > 0.0 { (used_gb / total_gb) * 100.0 } else { 0.0 };
let class = if ratio > 95.0 {
"max"
} else if ratio > 75.0 {
"high"
} else {
"normal"
};
Ok(WaybarOutput {
text: format!("{:.2}/{:.2}GB", used_gb, total_gb),
tooltip: None,
class: Some(class.to_string()),
percentage: Some(ratio as u8),
})
}
}
+17
View File
@@ -0,0 +1,17 @@
pub mod network;
pub mod cpu;
pub mod memory;
pub mod hardware;
pub mod disk;
pub mod btrfs;
pub mod audio;
use crate::config::Config;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::Result;
pub trait WaybarModule {
fn run(&self, config: &Config, state: &SharedState, args: &[&str]) -> Result<WaybarOutput>;
}
+179
View File
@@ -0,0 +1,179 @@
use crate::config::Config;
use crate::modules::WaybarModule;
use crate::output::WaybarOutput;
use crate::state::SharedState;
use anyhow::Result;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, warn};
pub struct NetworkModule;
pub struct NetworkDaemon {
last_time: u64,
last_rx_bytes: u64,
last_tx_bytes: u64,
}
impl NetworkDaemon {
pub fn new() -> Self {
Self {
last_time: 0,
last_rx_bytes: 0,
last_tx_bytes: 0,
}
}
pub fn poll(&mut self, state: SharedState) {
if let Ok(interface) = get_primary_interface() {
if !interface.is_empty() {
let time_now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Ok((rx_bytes_now, tx_bytes_now)) = get_bytes(&interface) {
if self.last_time > 0 && time_now > self.last_time {
let time_diff = time_now - self.last_time;
let rx_bps = (rx_bytes_now.saturating_sub(self.last_rx_bytes)) / time_diff;
let tx_bps = (tx_bytes_now.saturating_sub(self.last_tx_bytes)) / time_diff;
let rx_mbps = (rx_bps as f64) / 1024.0 / 1024.0;
let tx_mbps = (tx_bps as f64) / 1024.0 / 1024.0;
debug!(interface, rx = rx_mbps, tx = tx_mbps, "Network stats updated");
if let Ok(mut state_lock) = state.write() {
state_lock.network.rx_mbps = rx_mbps;
state_lock.network.tx_mbps = tx_mbps;
}
}
self.last_time = time_now;
self.last_rx_bytes = rx_bytes_now;
self.last_tx_bytes = tx_bytes_now;
}
} else {
warn!("No primary network interface found during poll");
}
}
}
}
impl WaybarModule for NetworkModule {
fn run(&self, config: &Config, state: &SharedState, _args: &[&str]) -> Result<WaybarOutput> {
let interface = get_primary_interface()?;
if interface.is_empty() {
return Ok(WaybarOutput {
text: "No connection".to_string(),
tooltip: None,
class: None,
percentage: None,
});
}
let ip = get_ip_address(&interface).unwrap_or_else(|| String::from("No IP"));
let (rx_mbps, tx_mbps) = {
if let Ok(state_lock) = state.read() {
(state_lock.network.rx_mbps, state_lock.network.tx_mbps)
} else {
(0.0, 0.0)
}
};
let mut output_text = config
.network
.format
.replace("{interface}", &interface)
.replace("{ip}", &ip)
.replace("{rx}", &format!("{:.2}", rx_mbps))
.replace("{tx}", &format!("{:.2}", tx_mbps));
if interface.starts_with("tun")
|| interface.starts_with("wg")
|| interface.starts_with("ppp")
|| interface.starts_with("pvpn")
{
output_text = format!("{}", output_text);
}
Ok(WaybarOutput {
text: output_text,
tooltip: Some(format!("Interface: {}\nIP: {}", interface, ip)),
class: Some(interface),
percentage: None,
})
}
}
fn get_primary_interface() -> Result<String> {
let output = std::process::Command::new("ip")
.args(["route", "list"])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut defaults = Vec::new();
for line in stdout.lines() {
if line.starts_with("default") {
let parts: Vec<&str> = line.split_whitespace().collect();
let mut dev = "";
let mut metric = 0;
for i in 0..parts.len() {
if parts[i] == "dev" && i + 1 < parts.len() {
dev = parts[i + 1];
}
if parts[i] == "metric" && i + 1 < parts.len() {
metric = parts[i + 1].parse::<i32>().unwrap_or(0);
}
}
if !dev.is_empty() {
defaults.push((metric, dev.to_string()));
}
}
}
defaults.sort_by_key(|k| k.0);
if let Some((_, dev)) = defaults.first() {
Ok(dev.clone())
} else {
Ok(String::new())
}
}
fn get_ip_address(interface: &str) -> Option<String> {
let output = std::process::Command::new("ip")
.args(["-4", "addr", "show", interface])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.trim().starts_with("inet ") {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() > 1 {
let ip_cidr = parts[1];
let ip = ip_cidr.split('/').next().unwrap_or(ip_cidr);
return Some(ip.to_string());
}
}
}
None
}
fn get_bytes(interface: &str) -> Result<(u64, u64)> {
let rx_path = format!("/sys/class/net/{}/statistics/rx_bytes", interface);
let tx_path = format!("/sys/class/net/{}/statistics/tx_bytes", interface);
let rx = fs::read_to_string(&rx_path)
.unwrap_or_else(|_| "0".to_string())
.trim()
.parse()
.unwrap_or(0);
let tx = fs::read_to_string(&tx_path)
.unwrap_or_else(|_| "0".to_string())
.trim()
.parse()
.unwrap_or(0);
Ok((rx, tx))
}