Release 0.2.0: media widget, wifi popover, control panel

This commit is contained in:
Breadway 2026-06-24 06:43:52 +08:00
parent 570d4224a0
commit 3ae3eff59e
8 changed files with 1157 additions and 81 deletions

131
src/bar/control.rs Normal file
View 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
View 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;
});
}

View file

@ -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;

View file

@ -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
View 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, // 0100 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;
});
}