Design refresh: new icon assets, bluetooth popover, notification urgency styling, RAM/power draw in control panel

Adds real SVG icon assets replacing the icons-needed.txt checklist,
a bluetooth popover module, per-notification urgency (CSS-styled),
and app-name/summary dedup in notification cards. Rounds out the
control panel's stats section with RAM and power draw alongside the
existing CPU/GPU/net rows.
This commit is contained in:
Breadway 2026-07-17 14:36:33 +08:00
parent e8d5fd5a52
commit 86432d718a
22 changed files with 625 additions and 185 deletions

123
src/bar/bluetooth.rs Normal file
View file

@ -0,0 +1,123 @@
use crate::{App, AppInput};
use relm4::ComponentSender;
use std::fs;
#[derive(Debug, Clone)]
pub struct BtDevice {
pub address: String,
pub name: String,
pub connected: bool,
pub paired: bool,
}
#[derive(Debug, Clone)]
pub struct BtPopoverData {
pub powered: bool,
pub devices: Vec<BtDevice>,
}
/// Same rfkill scan `bar::stats` uses for the bar icon — kept independent
/// (rather than shared) since it's a two-line read and pulling in a shared
/// helper isn't worth the coupling.
fn powered() -> bool {
fs::read_dir("/sys/class/rfkill")
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
fs::read_to_string(p.join("type"))
.map(|t| t.trim() == "bluetooth")
.unwrap_or(false)
&& fs::read_to_string(p.join("state"))
.map(|s| s.trim() == "1")
.unwrap_or(false)
})
}
async fn fetch_devices() -> Vec<BtDevice> {
try_fetch_devices().await.unwrap_or_default()
}
async fn try_fetch_devices() -> Option<Vec<BtDevice>> {
let conn = zbus::Connection::system().await.ok()?;
let mgr = zbus::fdo::ObjectManagerProxy::builder(&conn)
.destination("org.bluez")
.ok()?
.path("/")
.ok()?
.build()
.await
.ok()?;
let objects = mgr.get_managed_objects().await.ok()?;
let mut devices: Vec<BtDevice> = objects
.values()
.filter_map(|ifaces| ifaces.get("org.bluez.Device1"))
.filter_map(|props| {
let paired = props
.get("Paired")
.and_then(|v| bool::try_from(v.clone()).ok())
.unwrap_or(false);
if !paired {
return None;
}
let address = props
.get("Address")
.and_then(|v| String::try_from(v.clone()).ok())?;
let name = props
.get("Alias")
.or_else(|| props.get("Name"))
.and_then(|v| String::try_from(v.clone()).ok())
.unwrap_or_else(|| address.clone());
let connected = props
.get("Connected")
.and_then(|v| bool::try_from(v.clone()).ok())
.unwrap_or(false);
Some(BtDevice { address, name, connected, paired })
})
.collect();
devices.sort_by(|a, b| b.connected.cmp(&a.connected).then(a.name.cmp(&b.name)));
Some(devices)
}
pub fn spawn_popover_load(sender: ComponentSender<App>) {
relm4::spawn(async move {
let devices = fetch_devices().await;
sender.input(AppInput::BtPopoverData(BtPopoverData {
powered: powered(),
devices,
}));
});
}
/// Fire-and-forget: toggle the adapter's rfkill soft-block.
pub fn spawn_set_powered(on: bool) {
relm4::spawn(async move {
let _ = tokio::process::Command::new("rfkill")
.args([if on { "unblock" } else { "block" }, "bluetooth"])
.output()
.await;
});
}
/// Fire-and-forget: connect a paired device by address via `bluetoothctl`.
pub fn spawn_connect(address: String) {
relm4::spawn(async move {
let _ = tokio::process::Command::new("bluetoothctl")
.args(["connect", &address])
.output()
.await;
});
}
/// Fire-and-forget: disconnect a device by address via `bluetoothctl`.
pub fn spawn_disconnect(address: String) {
relm4::spawn(async move {
let _ = tokio::process::Command::new("bluetoothctl")
.args(["disconnect", &address])
.output()
.await;
});
}

View file

@ -1,3 +1,4 @@
pub mod bluetooth;
pub mod clock;
pub mod control;
pub mod media;

View file

@ -37,11 +37,22 @@ pub const BT_CONNECTED: &str = include_str!(concat!(
"/assets/Bluetooth Connected.svg"
));
pub const ICON_VOLUME: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Volume.svg"));
pub const ICON_BRIGHTNESS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Brightness.svg"));
pub const ICON_LOCK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Lock.svg"));
pub const ICON_SLEEP: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Sleep.svg"));
pub const ICON_RESTART: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Restart.svg"));
pub const ICON_SHUTDOWN: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Shutdown.svg"));
pub const ICON_BT_SETTINGS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Settings.svg"));
#[derive(Debug)]
pub struct Stats {
pub cpu: String,
pub cpu_pct: f32,
pub mem: String,
pub mem_pct: f32,
pub power: String,
pub power_watts: f32,
pub bat: String,
pub bat_icon: &'static str,
pub ac_connected: bool,
@ -99,7 +110,8 @@ fn read_cpu() -> f32 {
(dtotal - didle) as f32 / dtotal as f32 * 100.0
}
fn read_ram() -> u64 {
/// Returns (used_kb, total_kb).
fn read_ram() -> (u64, u64) {
let text = fs::read_to_string("/proc/meminfo").unwrap_or_default();
let mut total = 0u64;
let mut avail = 0u64;
@ -121,7 +133,7 @@ fn read_ram() -> u64 {
break;
}
}
total.saturating_sub(avail)
(total.saturating_sub(avail), total)
}
fn bat_path() -> Option<&'static PathBuf> {
@ -392,8 +404,14 @@ fn read_crumbs_profile() -> Option<String> {
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:.1}W"));
let (mem, mem_total) = read_ram();
let mem_pct = if mem_total > 0 {
mem as f32 / mem_total as f32 * 100.0
} else {
0.0
};
let power_watts = read_power();
let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W"));
let pct = read_battery();
let bat = pct.map_or_else(|| "".into(), |p| format!("{p}%"));
let bat_icon = pct.map_or(BAT_MID, bat_level_icon);
@ -426,12 +444,15 @@ pub async fn poll() -> Stats {
let (net_rx_kbs, net_tx_kbs) = read_net_throughput();
Stats {
cpu: format!("{cpu:.0}%"),
cpu_pct: cpu,
mem: if mem >= 1024 * 1024 {
format!("{:.1}G", mem as f32 / (1024.0 * 1024.0))
} else {
format!("{}M", mem / 1024)
},
mem_pct,
power,
power_watts: power_watts.unwrap_or(0.0),
bat,
bat_icon,
ac_connected,