diff --git a/Cargo.lock b/Cargo.lock index 711b1b8..067087b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,29 +932,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pastey" version = "0.1.1" @@ -1022,15 +999,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "relm4" version = "0.11.0" @@ -1261,7 +1229,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 7d2d370..0096670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ name = "breadbar" version = "0.1.0" edition = "2021" +description = "Minimal status bar and notification daemon for Hyprland on Wayland" +license = "MIT" +authors = ["Breadway "] +repository = "https://github.com/breadway/breadbar" +keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] +categories = ["gui"] [dependencies] gtk4 = { version = "0.11", features = ["v4_12"] } @@ -10,6 +16,11 @@ relm4 = { version = "0.11", features = ["macros"] } hyprland = { version = "0.4.0-beta.3", features = ["tokio"] } futures-lite = "2" zbus = { version = "5", default-features = false, features = ["tokio"] } -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/Pasted image.png b/Pasted image.png deleted file mode 100644 index 0bff4e5..0000000 Binary files a/Pasted image.png and /dev/null differ diff --git a/src/bar/clock.rs b/src/bar/clock.rs index aaa5c65..76639d5 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -11,8 +11,7 @@ pub fn spawn_ticker(sender: ComponentSender) { loop { sender.input(AppInput::ClockTick); // Sleep until the top of the next minute — display is HH:MM only. - let secs = gtk4::glib::DateTime::now_local() - .map_or(0, |dt| dt.second()); + let secs = gtk4::glib::DateTime::now_local().map_or(0, |dt| dt.second()); let wait = (60 - secs.rem_euclid(60)) as u64; tokio::time::sleep(std::time::Duration::from_secs(wait.max(1))).await; } diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 7a73c85..8a6b5d1 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -8,11 +8,29 @@ use std::{ LazyLock, Mutex, OnceLock, }, }; +use tokio::sync::OnceCell as AsyncOnce; + +static WIFI_IFACE: OnceLock> = OnceLock::new(); +static BT_CONN: AsyncOnce = AsyncOnce::const_new(); +static BT_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(BT_OFF)); +static BT_TICK: AtomicU8 = AtomicU8::new(0); pub const WIFI_STRONG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Strong.svg"); pub const WIFI_MEDIUM: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Medium.svg"); pub const WIFI_WEAK: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg"); -pub const WIFI_OFF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Connecting.svg"); +pub const WIFI_OFF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg"); + +pub const BAT_HIGH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg"); +pub const BAT_MID: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg"); +pub const BAT_LOW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg"); +pub const AC_POWER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/AC Power.svg"); + +pub const BT_OFF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Off.svg"); +pub const BT_ON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth.svg"); +pub const BT_CONNECTED: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/Bluetooth Connected.svg" +); #[derive(Debug)] pub struct Stats { @@ -20,6 +38,9 @@ pub struct Stats { pub mem: String, pub power: String, pub bat: String, + pub bat_icon: &'static str, + pub ac_connected: bool, + pub bt_icon: &'static str, pub wifi_ssid: String, pub wifi_icon: &'static str, } @@ -31,6 +52,7 @@ struct CpuSnapshot { static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); +static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); @@ -38,16 +60,21 @@ static WIFI_TICK: AtomicU8 = AtomicU8::new(0); 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 { + let mut total = 0u64; + let mut idle = 0u64; + let mut count = 0usize; + for (i, s) in line.split_whitespace().skip(1).enumerate() { + if let Ok(v) = s.parse::() { + total += v; + if i == 3 || i == 4 { + idle += v; + } + count += 1; + } + } + if count < 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(); @@ -65,13 +92,23 @@ fn read_ram() -> u64 { let text = fs::read_to_string("/proc/meminfo").unwrap_or_default(); let mut total = 0u64; let mut avail = 0u64; + let mut found = 0u8; 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), + Some("MemTotal:") => { + total = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + found += 1; + } + Some("MemAvailable:") => { + avail = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + found += 1; + } _ => {} } + if found == 2 { + break; + } } total.saturating_sub(avail) } @@ -83,7 +120,10 @@ fn bat_path() -> Option<&'static PathBuf> { .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"))) + .find(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("BAT")) + }) }) .as_ref() } @@ -116,26 +156,103 @@ fn read_battery() -> Option { .ok() } -async fn read_wifi() -> (String, &'static str) { - let dev_out = tokio::process::Command::new("iw") - .arg("dev") - .output() - .await - .ok(); - let dev_stdout = match dev_out { - Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), - }; +fn bat_level_icon(pct: u8) -> &'static str { + if pct >= 67 { + BAT_HIGH + } else if pct >= 34 { + BAT_MID + } else { + BAT_LOW + } +} - let iface = dev_stdout - .lines() - .find_map(|l| l.trim().strip_prefix("Interface ").map(str::to_string)); - let Some(iface) = iface else { +fn read_ac() -> bool { + AC_PATH + .get_or_init(|| { + fs::read_dir("/sys/class/power_supply") + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + fs::read_to_string(p.join("type")) + .map(|t| t.trim() == "Mains") + .unwrap_or(false) + }) + }) + .as_ref() + .and_then(|p| fs::read_to_string(p.join("online")).ok()) + .map(|s| s.trim() == "1") + .unwrap_or(false) +} + +fn bt_rfkill_on() -> 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 read_bt() -> &'static str { + if !bt_rfkill_on() { + return BT_OFF; + } + bt_connected_icon().await.unwrap_or(BT_ON) +} + +async fn bt_connected_icon() -> Option<&'static str> { + let conn = BT_CONN + .get_or_try_init(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 connected = objects + .values() + .filter_map(|ifaces| ifaces.get("org.bluez.Device1")) + .any(|props| { + props + .get("Connected") + .and_then(|v| bool::try_from(v.clone()).ok()) + .unwrap_or(false) + }); + Some(if connected { BT_CONNECTED } else { BT_ON }) +} + +fn wifi_iface() -> Option<&'static str> { + WIFI_IFACE + .get_or_init(|| { + fs::read_dir("/sys/class/net") + .ok()? + .filter_map(|e| e.ok()) + .find(|e| e.path().join("wireless").is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + }) + .as_deref() +} + +async fn read_wifi() -> (String, &'static str) { + let Some(iface) = wifi_iface() else { return ("—".into(), WIFI_OFF); }; let link_out = tokio::process::Command::new("iw") - .args(["dev", &iface, "link"]) + .args(["dev", iface, "link"]) .output() .await .ok(); @@ -172,11 +289,24 @@ 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 bat = read_battery().map_or_else(|| " —".into(), |p| format!("{p:3}%")); - // Refresh WiFi every 8 cycles (~16 s); cache the result in between. + let pct = read_battery(); + let bat = pct.map_or_else(|| " —".into(), |p| format!("{p:3}%")); + 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. + let bt_icon = { + let tick = BT_TICK.fetch_add(1, Ordering::Relaxed); + if tick.is_multiple_of(8) { + let fresh = read_bt().await; + *BT_CACHE.lock().unwrap() = fresh; + fresh + } else { + *BT_CACHE.lock().unwrap() + } + }; let (wifi_ssid, wifi_icon) = { let tick = WIFI_TICK.fetch_add(1, Ordering::Relaxed); - if tick % 8 == 0 { + if tick.is_multiple_of(8) { let fresh = read_wifi().await; *WIFI_CACHE.lock().unwrap() = fresh.clone(); fresh @@ -193,6 +323,9 @@ pub async fn poll() -> Stats { }, power, bat, + bat_icon, + ac_connected, + bt_icon, wifi_ssid, wifi_icon, } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 1c4f257..f3e2209 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -44,7 +44,9 @@ pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Bu } btn.connect_clicked(move |_| { use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id(id))); + let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( + id, + ))); }); btn } diff --git a/src/main.rs b/src/main.rs index 9e4827a..8abaa65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,13 +19,19 @@ pub struct App { active_ws: WorkspaceId, time_str: String, workspace_box: gtk4::Box, + button_map: std::collections::HashMap, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, bat_lbl: gtk4::Label, + bat_img: gtk4::Image, + bat_textures: std::collections::HashMap, + ac_img: gtk4::Image, + bt_img: gtk4::Image, + bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - // Pre-loaded textures indexed by the WIFI_* constant pointer values. + // Pre-loaded textures indexed by constant pointer values. wifi_textures: std::collections::HashMap, } @@ -84,16 +90,45 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); - let cpu_lbl = stat_label(4); - let mem_lbl = stat_label(4); - let pwr_lbl = stat_label(5); - let bat_lbl = stat_label(4); + let cpu_lbl = stat_label(); + let mem_lbl = stat_label(); + let pwr_lbl = stat_label(); + let bat_lbl = stat_label(); let wifi_lbl = gtk4::Label::new(None); + wifi_lbl.add_css_class("stat-label"); + wifi_lbl.add_css_class("wifi-label"); wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); - wifi_lbl.set_max_width_chars(12); - let wifi_img = gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); + wifi_lbl.set_max_width_chars(22); + wifi_lbl.set_xalign(0.0); + let wifi_img = + gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); + + use bar::stats::{ + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, + WIFI_OFF, WIFI_STRONG, WIFI_WEAK, + }; + let bat_textures: std::collections::HashMap = + [BAT_HIGH, BAT_MID, BAT_LOW] + .into_iter() + .map(|p| (p.as_ptr() as usize, svg_texture(p))) + .collect(); + // BAT_MID was just inserted into bat_textures above — key is always present. + let bat_img = gtk4::Image::from_paintable(Some( + bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), + )); + let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + ac_img.set_visible(false); + + let bt_textures: std::collections::HashMap = + [BT_OFF, BT_ON, BT_CONNECTED] + .into_iter() + .map(|p| (p.as_ptr() as usize, svg_texture(p))) + .collect(); + // BT_OFF was just inserted into bt_textures above — key is always present. + let bt_img = gtk4::Image::from_paintable(Some( + bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), + )); - use bar::stats::{WIFI_OFF, WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK}; let wifi_textures = [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) @@ -104,10 +139,16 @@ impl SimpleComponent for App { active_ws: 1, time_str: bar::clock::current(), workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + button_map: std::collections::HashMap::new(), cpu_lbl: cpu_lbl.clone(), mem_lbl: mem_lbl.clone(), pwr_lbl: pwr_lbl.clone(), bat_lbl: bat_lbl.clone(), + bat_img: bat_img.clone(), + bat_textures, + ac_img: ac_img.clone(), + bt_img: bt_img.clone(), + bt_textures, wifi_lbl: wifi_lbl.clone(), wifi_img: wifi_img.clone(), wifi_textures, @@ -115,13 +156,25 @@ impl SimpleComponent for App { let widgets = view_output!(); model.workspace_box = widgets.workspace_box.clone(); - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - stats_box.set_margin_end(8); + let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + stats_box.add_css_class("stats-box"); stats_box.append(&stat_pair(asset!("CPU.svg"), &cpu_lbl)); stats_box.append(&stat_pair(asset!("RAM Usage.svg"), &mem_lbl)); stats_box.append(&stat_pair(asset!("Power Draw.svg"), &pwr_lbl)); - stats_box.append(&stat_pair(asset!("Battery.svg"), &bat_lbl)); - let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + bat_box.add_css_class("stat-pair"); + bat_img.add_css_class("stat-icon"); + bat_lbl.add_css_class("stat-label"); + ac_img.add_css_class("stat-icon"); + bat_box.append(&bat_img); + bat_box.append(&bat_lbl); + bat_box.append(&ac_img); + stats_box.append(&bat_box); + bt_img.add_css_class("bt-icon"); + stats_box.append(&bt_img); + let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + wifi_pair.add_css_class("stat-pair"); + wifi_img.add_css_class("stat-icon"); wifi_pair.append(&wifi_img); wifi_pair.append(&wifi_lbl); stats_box.append(&wifi_pair); @@ -145,8 +198,13 @@ impl SimpleComponent for App { self.rebuild_buttons(); } AppInput::ActiveWorkspace(id) => { + if let Some(old) = self.button_map.get(&self.active_ws) { + old.remove_css_class("active"); + } self.active_ws = id; - self.rebuild_buttons(); + if let Some(btn) = self.button_map.get(&self.active_ws) { + btn.add_css_class("active"); + } } AppInput::ClockTick => { self.time_str = bar::clock::current(); @@ -156,6 +214,13 @@ impl SimpleComponent for App { self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); self.bat_lbl.set_label(&stats.bat); + if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { + self.bat_img.set_paintable(Some(tex)); + } + self.ac_img.set_visible(stats.ac_connected); + if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { + self.bt_img.set_paintable(Some(tex)); + } self.wifi_lbl.set_label(&stats.wifi_ssid); if let Some(tex) = self.wifi_textures.get(&(stats.wifi_icon.as_ptr() as usize)) { self.wifi_img.set_paintable(Some(tex)); @@ -166,20 +231,25 @@ impl SimpleComponent for App { } impl App { - fn rebuild_buttons(&self) { + fn rebuild_buttons(&mut self) { while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } + self.button_map.clear(); for ws in &self.workspaces { - self.workspace_box - .append(&bar::workspaces::make_button(ws.id, &ws.name, self.active_ws)); + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + self.workspace_box.append(&btn); + self.button_map.insert(ws.id, btn); } } } fn stat_pair(icon_path: &str, label: >k4::Label) -> gtk4::Box { - let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - pair.append(>k4::Image::from_paintable(Some(&svg_texture(icon_path)))); + let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + pair.add_css_class("stat-pair"); + let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_path))); + img.add_css_class("stat-icon"); + pair.append(&img); pair.append(label); pair } @@ -187,15 +257,16 @@ fn stat_pair(icon_path: &str, label: >k4::Label) -> gtk4::Box { fn svg_texture(path: &str) -> gtk4::gdk::Texture { let svg = std::fs::read_to_string(path) .unwrap_or_default() - .replace("currentColor", "white"); + .replace("currentColor", "white") + .replace(r#"width="24" height="24""#, r#"width="16" height="16""#); let bytes = gtk4::glib::Bytes::from_owned(svg.into_bytes()); gtk4::gdk::Texture::from_bytes(&bytes).expect("svg load") } -fn stat_label(width_chars: i32) -> gtk4::Label { +fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); - lbl.set_width_chars(width_chars); - lbl.set_xalign(1.0); + lbl.add_css_class("stat-label"); + lbl.set_xalign(0.0); lbl } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 5ae7817..b197038 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -5,7 +5,13 @@ use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; pub enum NotifEvent { - Show { id: u32, app_name: String, summary: String, body: String, timeout_ms: u32 }, + Show { + id: u32, + app_name: String, + summary: String, + body: String, + timeout_ms: u32, + }, Close(u32), } @@ -16,6 +22,8 @@ struct NotifServer { #[zbus::interface(name = "org.freedesktop.Notifications")] impl NotifServer { + // The org.freedesktop.Notifications spec mandates exactly these 8 parameters. + #[allow(clippy::too_many_arguments)] async fn notify( &self, app_name: &str, @@ -32,7 +40,11 @@ impl NotifServer { } else { self.next_id.fetch_add(1, Ordering::Relaxed) }; - let timeout_ms = if expire_timeout <= 0 { 5000 } else { expire_timeout as u32 }; + let timeout_ms = if expire_timeout <= 0 { + 5000 + } else { + expire_timeout as u32 + }; let _ = self .tx .send(NotifEvent::Show { @@ -55,7 +67,12 @@ impl NotifServer { } fn get_server_information(&self) -> (String, String, String, String) { - ("breadbar".into(), "breadway".into(), "0.1.0".into(), "1.2".into()) + ( + "breadbar".into(), + "breadway".into(), + "0.1.0".into(), + "1.2".into(), + ) } } @@ -63,7 +80,11 @@ pub fn spawn() { let (tx, rx) = mpsc::channel(32); relm4::spawn(async move { - let server = NotifServer { tx, next_id: AtomicU32::new(1) }; + let server = NotifServer { + tx, + next_id: AtomicU32::new(1), + }; + // Builder failures here would only occur with invalid static strings — safe to unwrap. let _conn = zbus::connection::Builder::session() .unwrap() .name("org.freedesktop.Notifications") @@ -72,7 +93,7 @@ pub fn spawn() { .unwrap() .build() .await - .expect("failed to claim org.freedesktop.Notifications"); + .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); std::future::pending::<()>().await }); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 0cfa054..c8a7e50 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -21,7 +21,13 @@ pub async fn run(mut rx: Receiver) { while let Some(event) = rx.recv().await { match event { - NotifEvent::Show { id, app_name, summary, body, timeout_ms } => { + NotifEvent::Show { + id, + app_name, + summary, + body, + timeout_ms, + } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); diff --git a/src/theme.rs b/src/theme.rs index e5db59c..534e903 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,11 @@ use gtk4::CssProvider; use serde::Deserialize; +use std::cell::RefCell; + +thread_local! { + static PROVIDER: RefCell> = const { RefCell::new(None) }; + static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; +} #[derive(Deserialize)] struct WalColors { @@ -29,11 +35,16 @@ fn hex_to_rgba(hex: &str, alpha: f32) -> String { fn load_css() -> String { let home = std::env::var("HOME").unwrap_or_default(); - let text = std::fs::read_to_string(format!("{home}/.cache/wal/colors.json")) - .unwrap_or_default(); + let text = + std::fs::read_to_string(format!("{home}/.cache/wal/colors.json")).unwrap_or_default(); let (bg, surface, fg, accent) = if let Ok(wal) = serde_json::from_str::(&text) { - (wal.special.background, wal.colors.color0, wal.colors.color15, wal.colors.color1) + ( + wal.special.background, + wal.colors.color0, + wal.colors.color15, + wal.colors.color1, + ) } else { ( "#1e1e2e".to_string(), @@ -44,14 +55,18 @@ fn load_css() -> String { }; format!( - "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 12px; }}\ + "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ window.breadbar {{ background-color: {bg_rgba}; border-radius: 0; }}\ + label {{ color: {fg}; }}\ .workspace-btn {{ background: transparent; color: {fg}; opacity: 0.45;\ border-radius: 0 0 8px 8px; border: none; outline: none; box-shadow: none;\ min-width: 24px; padding: 2px 8px; }}\ .workspace-btn:hover {{ opacity: 0.8; }}\ .workspace-btn.active {{ background: {accent}; opacity: 1; }}\ - label {{ color: {fg}; }}\ + .stats-box {{ margin-right: 8px; }}\ + .stat-pair {{ margin-right: 8px; }}\ + .stat-icon {{ margin-right: 3px; }}\ + .bt-icon {{ margin-right: 8px; }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); }}\ .notification-card {{ background: {surface}; border-radius: 6px;\ padding: 10px; margin-bottom: 4px; }}\ @@ -67,11 +82,50 @@ fn load_css() -> String { } pub fn apply() { - let provider = CssProvider::new(); - provider.load_from_string(&load_css()); - gtk4::style_context_add_provider_for_display( - >k4::gdk::Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + let css = load_css(); + let display = gtk4::gdk::Display::default().expect("no display"); + + PROVIDER.with(|cell| { + let mut guard = cell.borrow_mut(); + if let Some(p) = guard.as_ref() { + p.load_from_string(&css); + } else { + let p = CssProvider::new(); + p.load_from_string(&css); + gtk4::style_context_add_provider_for_display( + &display, + &p, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + *guard = Some(p); + } + }); + + // User override: ~/.config/breadbar/style.css — send SIGHUP to reload. + let home = std::env::var("HOME").unwrap_or_default(); + let user_path = format!("{home}/.config/breadbar/style.css"); + USER_PROVIDER.with(|cell| { + let mut guard = cell.borrow_mut(); + match std::fs::read_to_string(&user_path) { + Ok(user_css) => { + if let Some(p) = guard.as_ref() { + p.load_from_string(&user_css); + } else { + let p = CssProvider::new(); + p.load_from_string(&user_css); + gtk4::style_context_add_provider_for_display( + &display, + &p, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + *guard = Some(p); + } + } + Err(_) => { + if let Some(p) = guard.as_ref() { + p.load_from_string(""); + } + } + } + }); }