Release 0.2.0: media widget, wifi popover, control panel
This commit is contained in:
parent
570d4224a0
commit
3ae3eff59e
8 changed files with 1157 additions and 81 deletions
131
src/bar/control.rs
Normal file
131
src/bar/control.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use crate::{App, AppInput};
|
||||
use relm4::ComponentSender;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioSink {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlPanelData {
|
||||
pub volume: f64,
|
||||
pub brightness: f64,
|
||||
pub sinks: Vec<AudioSink>,
|
||||
}
|
||||
|
||||
async fn fetch_volume() -> f64 {
|
||||
let out = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::process::Command::new("wpctl")
|
||||
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match out {
|
||||
Ok(Ok(o)) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.strip_prefix("Volume:")
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.5)
|
||||
.clamp(0.0, 1.5),
|
||||
_ => 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_brightness() -> f64 {
|
||||
let cur = tokio::process::Command::new("brightnessctl")
|
||||
.arg("get")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
let max = tokio::process::Command::new("brightnessctl")
|
||||
.arg("max")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok())
|
||||
.unwrap_or(255.0);
|
||||
if max == 0.0 {
|
||||
0.5
|
||||
} else {
|
||||
(cur / max).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_sinks() -> Vec<AudioSink> {
|
||||
let default = tokio::process::Command::new("pactl")
|
||||
.args(["info"])
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.find(|l| l.starts_with("Default Sink:"))
|
||||
.map(|l| l.trim_start_matches("Default Sink:").trim().to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let out = tokio::process::Command::new("pactl")
|
||||
.args(["-f", "json", "list", "sinks"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let Ok(o) = out else { return vec![] };
|
||||
let arr: Vec<serde_json::Value> = serde_json::from_slice(&o.stdout).unwrap_or_default();
|
||||
arr.into_iter()
|
||||
.filter_map(|v| {
|
||||
let name = v["name"].as_str()?.to_string();
|
||||
let description = v["description"].as_str().unwrap_or(&name).to_string();
|
||||
let is_default = name == default;
|
||||
Some(AudioSink { name, description, is_default })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn spawn_load(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
let (volume, brightness, sinks) =
|
||||
tokio::join!(fetch_volume(), fetch_brightness(), fetch_sinks());
|
||||
sender.input(AppInput::ControlPanelData(ControlPanelData {
|
||||
volume,
|
||||
brightness,
|
||||
sinks,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_set_volume(v: f64) {
|
||||
relm4::spawn(async move {
|
||||
let pct = format!("{:.0}%", (v * 100.0).clamp(0.0, 150.0));
|
||||
let _ = tokio::process::Command::new("wpctl")
|
||||
.args(["set-volume", "@DEFAULT_AUDIO_SINK@", &pct])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_set_brightness(v: f64) {
|
||||
relm4::spawn(async move {
|
||||
let pct = format!("{:.0}%", (v * 100.0).clamp(1.0, 100.0));
|
||||
let _ = tokio::process::Command::new("brightnessctl")
|
||||
.args(["set", &pct])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_set_sink(name: String) {
|
||||
relm4::spawn(async move {
|
||||
let _ = tokio::process::Command::new("pactl")
|
||||
.args(["set-default-sink", &name])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
85
src/bar/media.rs
Normal file
85
src/bar/media.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use crate::{App, AppInput};
|
||||
use relm4::ComponentSender;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaState {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub playing: bool,
|
||||
pub has_player: bool,
|
||||
}
|
||||
|
||||
async fn fetch() -> MediaState {
|
||||
let none = || MediaState {
|
||||
title: String::new(),
|
||||
artist: String::new(),
|
||||
playing: false,
|
||||
has_player: false,
|
||||
};
|
||||
|
||||
let status_out = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::process::Command::new("playerctl")
|
||||
.args(["status"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let status = match status_out {
|
||||
Ok(Ok(out)) if out.status.success() => {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
_ => return none(),
|
||||
};
|
||||
|
||||
if status == "Stopped" {
|
||||
return none();
|
||||
}
|
||||
|
||||
let playing = status == "Playing";
|
||||
|
||||
let meta_out = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::process::Command::new("playerctl")
|
||||
.args(["metadata", "--format", "{{artist}}\t{{title}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (artist, title) = match meta_out {
|
||||
Ok(Ok(out)) if out.status.success() => {
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let mut parts = s.splitn(2, '\t');
|
||||
let a = parts.next().unwrap_or("").to_string();
|
||||
let t = parts.next().unwrap_or("").to_string();
|
||||
(a, t)
|
||||
}
|
||||
_ => (String::new(), String::new()),
|
||||
};
|
||||
|
||||
MediaState {
|
||||
title,
|
||||
artist,
|
||||
playing,
|
||||
has_player: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_poller(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
sender.input(AppInput::MediaUpdate(fetch().await));
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_cmd(cmd: &'static str) {
|
||||
relm4::spawn(async move {
|
||||
let _ = tokio::process::Command::new("playerctl")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
pub mod clock;
|
||||
pub mod control;
|
||||
pub mod media;
|
||||
pub mod stats;
|
||||
pub mod tray;
|
||||
pub mod wifi;
|
||||
pub mod workspaces;
|
||||
|
|
|
|||
107
src/bar/stats.rs
107
src/bar/stats.rs
|
|
@ -11,6 +11,8 @@ use std::{
|
|||
use tokio::sync::OnceCell as AsyncOnce;
|
||||
|
||||
static WIFI_IFACE: OnceLock<Option<String>> = OnceLock::new();
|
||||
static NET_PREV: LazyLock<Mutex<Option<(u64, u64, std::time::Instant)>>> =
|
||||
LazyLock::new(|| Mutex::new(None));
|
||||
static BT_CONN: AsyncOnce<zbus::Connection> = AsyncOnce::const_new();
|
||||
static BT_CACHE: LazyLock<Mutex<&'static str>> = LazyLock::new(|| Mutex::new(BT_OFF));
|
||||
static BT_TICK: AtomicU8 = AtomicU8::new(0);
|
||||
|
|
@ -46,6 +48,11 @@ pub struct Stats {
|
|||
pub bt_icon: &'static str,
|
||||
pub wifi_ssid: String,
|
||||
pub wifi_icon: &'static str,
|
||||
pub wifi_profile: Option<String>,
|
||||
pub cpu_temp: Option<f32>,
|
||||
pub gpu_usage: Option<u8>,
|
||||
pub net_rx_kbs: f32,
|
||||
pub net_tx_kbs: f32,
|
||||
}
|
||||
|
||||
struct CpuSnapshot {
|
||||
|
|
@ -288,12 +295,97 @@ async fn read_wifi() -> (String, &'static str) {
|
|||
(ssid, icon)
|
||||
}
|
||||
|
||||
fn read_cpu_temp() -> Option<f32> {
|
||||
for entry in fs::read_dir("/sys/class/hwmon").ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let name = fs::read_to_string(path.join("name")).ok()?;
|
||||
if name.trim() == "k10temp" {
|
||||
let raw = fs::read_to_string(path.join("temp1_input")).ok()?;
|
||||
return Some(raw.trim().parse::<f32>().ok()? / 1000.0);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_gpu_usage() -> Option<u8> {
|
||||
for entry in fs::read_dir("/sys/class/drm").ok()?.flatten() {
|
||||
let path = entry.path().join("device/gpu_busy_percent");
|
||||
if path.exists() {
|
||||
return fs::read_to_string(&path).ok()?.trim().parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_net_throughput() -> (f32, f32) {
|
||||
let text = match fs::read_to_string("/proc/net/dev") {
|
||||
Ok(t) => t,
|
||||
Err(_) => return (0.0, 0.0),
|
||||
};
|
||||
let mut total_rx = 0u64;
|
||||
let mut total_tx = 0u64;
|
||||
for line in text.lines().skip(2) {
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let iface = line[..colon].trim();
|
||||
if matches!(iface, "lo")
|
||||
|| iface.starts_with("docker")
|
||||
|| iface.starts_with("veth")
|
||||
|| iface.starts_with("br-")
|
||||
|| iface.starts_with("virbr")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line[colon + 1..].split_whitespace().collect();
|
||||
if fields.len() >= 9 {
|
||||
total_rx += fields[0].parse::<u64>().unwrap_or(0);
|
||||
total_tx += fields[8].parse::<u64>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let mut guard = NET_PREV.lock().unwrap();
|
||||
let result = if let Some((last_rx, last_tx, last_t)) = *guard {
|
||||
let dt = now.duration_since(last_t).as_secs_f32().max(0.001);
|
||||
let rx = total_rx.saturating_sub(last_rx) as f32 / 1024.0 / dt;
|
||||
let tx = total_tx.saturating_sub(last_tx) as f32 / 1024.0 / dt;
|
||||
(rx, tx)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
*guard = Some((total_rx, total_tx, now));
|
||||
result
|
||||
}
|
||||
|
||||
fn read_crumbs_profile() -> Option<String> {
|
||||
let state_home = std::env::var_os("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::env::var_os("HOME")
|
||||
.map(|h| PathBuf::from(h).join(".local/state"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
});
|
||||
let text = fs::read_to_string(state_home.join("breadcrumbs/state.toml")).ok()?;
|
||||
for line in text.lines() {
|
||||
if let Some(rest) = line.trim().strip_prefix("profile") {
|
||||
let val = rest
|
||||
.trim_start_matches(|c: char| c == ' ' || c == '=')
|
||||
.trim_matches('"');
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn poll() -> Stats {
|
||||
let cpu = read_cpu();
|
||||
let mem = read_ram();
|
||||
let power = read_power().map_or_else(|| " —W".into(), |w| format!("{w:4.1}W"));
|
||||
let power = read_power().map_or_else(|| "—W".into(), |w| format!("{w:.1}W"));
|
||||
let pct = read_battery();
|
||||
let bat = pct.map_or_else(|| " —".into(), |p| format!("{p:3}%"));
|
||||
let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%"));
|
||||
let bat_icon = pct.map_or(BAT_MID, bat_level_icon);
|
||||
let ac_connected = read_ac();
|
||||
// BT and WiFi both refresh every 8 cycles (~16 s); cache in between.
|
||||
|
|
@ -317,8 +409,12 @@ pub async fn poll() -> Stats {
|
|||
WIFI_CACHE.lock().unwrap().clone()
|
||||
}
|
||||
};
|
||||
let wifi_profile = read_crumbs_profile();
|
||||
let cpu_temp = read_cpu_temp();
|
||||
let gpu_usage = read_gpu_usage();
|
||||
let (net_rx_kbs, net_tx_kbs) = read_net_throughput();
|
||||
Stats {
|
||||
cpu: format!("{cpu:3.0}%"),
|
||||
cpu: format!("{cpu:.0}%"),
|
||||
mem: if mem >= 1024 * 1024 {
|
||||
format!("{:.1}G", mem as f32 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
|
|
@ -331,6 +427,11 @@ pub async fn poll() -> Stats {
|
|||
bt_icon,
|
||||
wifi_ssid,
|
||||
wifi_icon,
|
||||
wifi_profile,
|
||||
cpu_temp,
|
||||
gpu_usage,
|
||||
net_rx_kbs,
|
||||
net_tx_kbs,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
144
src/bar/wifi.rs
Normal file
144
src/bar/wifi.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use crate::{App, AppInput};
|
||||
use relm4::ComponentSender;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrumbsStatus {
|
||||
pub profile: String,
|
||||
pub ssid: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub internet: bool,
|
||||
pub captive_portal: bool,
|
||||
pub tailscale_ok: bool,
|
||||
pub tailscale_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanEntry {
|
||||
pub ssid: String,
|
||||
pub signal: u8, // 0–100 percentage
|
||||
pub saved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WifiPopoverData {
|
||||
pub profiles: Vec<(String, bool)>, // (name, is_active)
|
||||
pub scan: Vec<ScanEntry>,
|
||||
}
|
||||
|
||||
async fn fetch_status() -> Option<CrumbsStatus> {
|
||||
let out = tokio::time::timeout(
|
||||
Duration::from_secs(8),
|
||||
tokio::process::Command::new("breadcrumbs")
|
||||
.args(["status", "--json"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
Some(CrumbsStatus {
|
||||
profile: v["profile"].as_str().unwrap_or("").to_string(),
|
||||
ssid: v["ssid"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
|
||||
ip: v["ip"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
|
||||
internet: v["internet"].as_bool().unwrap_or(true),
|
||||
captive_portal: v["captive_portal"].is_string(),
|
||||
tailscale_ok: v["tailscale"]["ok"].as_bool().unwrap_or(true),
|
||||
tailscale_required: v["tailscale"]["required"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_profile_list() -> Vec<(String, bool)> {
|
||||
let Ok(Ok(out)) = tokio::time::timeout(
|
||||
Duration::from_secs(4),
|
||||
tokio::process::Command::new("breadcrumbs")
|
||||
.args(["profile", "list"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let active = line.starts_with('*');
|
||||
let name = line.trim_start_matches(['*', ' ']).trim().to_string();
|
||||
if name.is_empty() { None } else { Some((name, active)) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn fetch_scan() -> Vec<ScanEntry> {
|
||||
let Ok(Ok(out)) = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::process::Command::new("breadcrumbs")
|
||||
.args(["scan-list", "--json"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let arr: Vec<serde_json::Value> =
|
||||
serde_json::from_slice(&out.stdout).unwrap_or_default();
|
||||
arr.into_iter()
|
||||
.filter_map(|v| {
|
||||
let ssid = v["ssid"].as_str()?.to_string();
|
||||
if ssid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let signal = v["signal"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
let saved = v["saved"].as_bool().unwrap_or(false);
|
||||
Some(ScanEntry { ssid, signal, saved })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Background poller — updates internet/TS status every 30 s.
|
||||
pub fn spawn_status_poller(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
if let Some(status) = fetch_status().await {
|
||||
sender.input(AppInput::CrumbsStatus(status));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when the popover opens — loads profiles + scan in parallel.
|
||||
pub fn spawn_popover_load(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan());
|
||||
sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan }));
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget: set the active breadcrumbs profile (applies it).
|
||||
pub fn spawn_profile_set(name: String) {
|
||||
relm4::spawn(async move {
|
||||
let _ = tokio::process::Command::new("breadcrumbs")
|
||||
.args(["profile", "set", &name])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`.
|
||||
pub fn spawn_join(ssid: String) {
|
||||
relm4::spawn(async move {
|
||||
let _ = tokio::process::Command::new("breadcrumbs")
|
||||
.args(["join", &ssid])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue