diff --git a/Cargo.lock b/Cargo.lock index 5835492..dd6d212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.1" +version = "0.1.2" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index 8191caa..4594c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/README.md b/README.md index 1e577e8..c823661 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Minimal status bar and notification daemon for [Hyprland](https://hyprland.org/) on Wayland. -A single Rust binary that provides a full-width top bar and a standards-compliant D-Bus notification daemon, with no system tray, no launcher, and no wallpaper logic. +A single Rust binary that provides a full-width top bar, a system tray, and a standards-compliant D-Bus notification daemon. No launcher, no wallpaper logic. ## Features @@ -10,7 +10,7 @@ A single Rust binary that provides a full-width top bar and a standards-complian - Left: live workspace buttons sourced from Hyprland IPC, active workspace highlighted - Centre: clock (`HH:MM`, updates at the top of each minute) -- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth state, WiFi SSID with signal strength +- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth state, WiFi SSID with signal strength, system tray (SNI) **Notification daemon**: @@ -105,6 +105,7 @@ Example — change the font size: | `src/bar/workspaces.rs` | Hyprland IPC event stream, workspace buttons | | `src/bar/clock.rs` | Minute-tick clock | | `src/bar/stats.rs` | Polling loop: CPU, RAM, power, battery, Bluetooth, WiFi | +| `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | | `src/theme.rs` | pywal reader, GTK CSS provider injection | diff --git a/src/bar/mod.rs b/src/bar/mod.rs index 24c75b5..7b0d41d 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,3 +1,4 @@ pub mod clock; pub mod stats; +pub mod tray; pub mod workspaces; diff --git a/src/bar/tray.rs b/src/bar/tray.rs new file mode 100644 index 0000000..421b7c8 --- /dev/null +++ b/src/bar/tray.rs @@ -0,0 +1,240 @@ +use crate::{App, AppInput}; +use futures_lite::StreamExt; +use gtk4::prelude::Cast; +use relm4::ComponentSender; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use zbus::{interface, object_server::SignalEmitter}; + +#[derive(Debug)] +pub enum TrayIconData { + Pixels { width: i32, height: i32, data: Vec }, + Name(String), +} + +#[derive(Debug)] +pub enum TrayUpdate { + Add { id: String, icon: Option, title: String }, + Remove { id: String }, +} + +struct WatcherState { + items: Vec, +} + +struct Watcher { + state: Arc>, + tx: tokio::sync::mpsc::UnboundedSender<(String, String)>, +} + +#[interface(name = "org.kde.StatusNotifierWatcher")] +impl Watcher { + async fn register_status_notifier_item( + &self, + service: String, + #[zbus(header)] header: zbus::message::Header<'_>, + #[zbus(signal_emitter)] ctx: SignalEmitter<'_>, + ) { + let sender_name = header.sender().map(|s| s.to_string()).unwrap_or_default(); + let (bus, path) = parse_service(&service, &sender_name); + let full = format!("{}{}", bus, path); + { + let mut state = self.state.lock().unwrap(); + if !state.items.contains(&full) { + state.items.push(full.clone()); + } + } + let _ = Self::status_notifier_item_registered(&ctx, &full).await; + let _ = self.tx.send((bus, path)); + } + + async fn register_status_notifier_host( + &self, + _service: String, + #[zbus(signal_emitter)] ctx: SignalEmitter<'_>, + ) { + let _ = Self::status_notifier_host_registered(&ctx).await; + } + + #[zbus(property)] + fn registered_status_notifier_items(&self) -> Vec { + self.state.lock().unwrap().items.clone() + } + + #[zbus(property)] + fn is_status_notifier_host_registered(&self) -> bool { + true + } + + #[zbus(property)] + fn protocol_version(&self) -> i32 { + 0 + } + + #[zbus(signal)] + async fn status_notifier_item_registered( + ctx: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_item_unregistered( + ctx: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_registered(ctx: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +fn parse_service(service: &str, sender: &str) -> (String, String) { + if service.starts_with('/') { + return (sender.to_string(), service.to_string()); + } + match service.find('/') { + Some(slash) => (service[..slash].to_string(), service[slash..].to_string()), + None => (service.to_string(), "/StatusNotifierItem".to_string()), + } +} + +async fn read_item( + conn: &zbus::Connection, + bus: &str, + path: &str, +) -> (Option, String) { + let Ok(proxy) = zbus::Proxy::new(conn, bus, path, "org.kde.StatusNotifierItem").await else { + return (None, String::new()); + }; + let icon = read_icon(&proxy).await; + let title = proxy.get_property::("Title").await.unwrap_or_default(); + (icon, title) +} + +async fn read_icon(proxy: &zbus::Proxy<'_>) -> Option { + let pixmaps: Vec<(i32, i32, Vec)> = + proxy.get_property("IconPixmap").await.unwrap_or_default(); + + if !pixmaps.is_empty() { + return pixmaps + .into_iter() + .filter(|(w, h, _)| *w > 0 && *h > 0) + .min_by_key(|(w, h, _)| (w.max(h) - 22).abs()) + .map(|(width, height, data)| TrayIconData::Pixels { width, height, data }); + } + + let name: String = proxy.get_property("IconName").await.ok()?; + if name.is_empty() { + return None; + } + Some(TrayIconData::Name(name)) +} + +/// Call `Activate(0, 0)` on the SNI item identified by `id` (`{bus}{path}`). +pub fn spawn_activate(id: String) { + relm4::spawn(async move { + let (bus, path) = match id.find('/') { + Some(slash) => (id[..slash].to_string(), id[slash..].to_string()), + None => (id, "/StatusNotifierItem".to_string()), + }; + let Ok(conn) = zbus::Connection::session().await else { return }; + let Ok(proxy) = zbus::Proxy::new(&conn, bus.as_str(), path.as_str(), "org.kde.StatusNotifierItem").await else { return }; + let _ = proxy.call_method("Activate", &(0i32, 0i32)).await; + }); +} + +pub fn spawn_watcher(sender: ComponentSender) { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>(); + // Maps bus name → item ids, shared between registration and cleanup tasks. + let bus_map: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let bus_map_cleanup = bus_map.clone(); + let sender_cleanup = sender.clone(); + + // Registration task — owns the watcher service and processes new items. + relm4::spawn(async move { + let watcher = Watcher { + state: Arc::new(Mutex::new(WatcherState { items: Vec::new() })), + tx, + }; + // Builder steps fail only on invalid static strings — safe to unwrap. + let conn = zbus::connection::Builder::session() + .unwrap() + .name("org.kde.StatusNotifierWatcher") + .unwrap() + .serve_at("/StatusNotifierWatcher", watcher) + .unwrap() + .build() + .await + .expect("failed to register org.kde.StatusNotifierWatcher"); + + while let Some((bus, path)) = rx.recv().await { + let (icon, title) = read_item(&conn, &bus, &path).await; + let id = format!("{}{}", bus, path); + bus_map.lock().unwrap().entry(bus).or_default().push(id.clone()); + sender.input(AppInput::TrayUpdate(TrayUpdate::Add { id, icon, title })); + } + }); + + // Cleanup task — watches NameOwnerChanged and removes items when their owner exits. + relm4::spawn(async move { + let Ok(conn) = zbus::Connection::session().await else { return }; + let Ok(proxy) = zbus::fdo::DBusProxy::new(&conn).await else { return }; + let Ok(mut stream) = proxy.receive_name_owner_changed().await else { return }; + while let Some(signal) = stream.next().await { + let Ok(args) = signal.args() else { continue }; + if args.new_owner().is_none() { + let gone = args.name().to_string(); + if let Some(ids) = bus_map_cleanup.lock().unwrap().remove(&gone) { + for id in ids { + sender_cleanup.input(AppInput::TrayUpdate(TrayUpdate::Remove { id })); + } + } + } + } + }); +} + +/// Convert SNI ARGB pixel data (network byte order) to a GTK4 `Image`. +/// Falls back to an icon-name lookup or a placeholder on failure. +pub fn make_tray_image(icon: Option<&TrayIconData>) -> gtk4::Image { + let img = match icon { + Some(TrayIconData::Pixels { width, height, data }) => pixels_to_image(*width, *height, data), + Some(TrayIconData::Name(name)) => { + let img = gtk4::Image::from_icon_name(name); + img.set_pixel_size(16); + Some(img) + } + None => None, + }; + img.unwrap_or_else(|| { + let img = gtk4::Image::from_icon_name("image-missing"); + img.set_pixel_size(16); + img + }) +} + +fn pixels_to_image(width: i32, height: i32, data: &[u8]) -> Option { + if data.len() != (width * height * 4) as usize { + return None; + } + // SNI delivers ARGB big-endian: bytes are [A, R, G, B] per pixel. + // GTK4 R8g8b8a8 expects [R, G, B, A] per pixel. + let mut rgba = Vec::with_capacity(data.len()); + for chunk in data.chunks_exact(4) { + rgba.push(chunk[1]); + rgba.push(chunk[2]); + rgba.push(chunk[3]); + rgba.push(chunk[0]); + } + let bytes = gtk4::glib::Bytes::from_owned(rgba); + let tex = gtk4::gdk::MemoryTexture::new( + width, + height, + gtk4::gdk::MemoryFormat::R8g8b8a8, + &bytes, + (width * 4) as usize, + ); + let tex: gtk4::gdk::Texture = tex.upcast(); + let img = gtk4::Image::from_paintable(Some(&tex)); + img.set_pixel_size(16); + Some(img) +} diff --git a/src/main.rs b/src/main.rs index 10cca56..f1d77b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,8 @@ pub struct App { wifi_img: gtk4::Image, // Pre-loaded textures indexed by constant pointer values. wifi_textures: std::collections::HashMap, + tray_box: gtk4::Box, + tray_items: std::collections::HashMap, } #[derive(Debug)] @@ -42,6 +44,7 @@ pub enum AppInput { ActiveWorkspace(WorkspaceId), ClockTick, StatsUpdate(bar::stats::Stats), + TrayUpdate(bar::tray::TrayUpdate), } #[relm4::component(pub)] @@ -153,6 +156,8 @@ impl SimpleComponent for App { wifi_lbl: wifi_lbl.clone(), wifi_img: wifi_img.clone(), wifi_textures, + tray_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + tray_items: std::collections::HashMap::new(), }; let widgets = view_output!(); model.workspace_box = widgets.workspace_box.clone(); @@ -179,12 +184,15 @@ impl SimpleComponent for App { wifi_pair.append(&wifi_img); wifi_pair.append(&wifi_lbl); stats_box.append(&wifi_pair); + model.tray_box.add_css_class("tray-box"); + stats_box.append(&model.tray_box); widgets.center_box.set_end_widget(Some(&stats_box)); theme::apply(); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); - bar::stats::spawn_poller(sender); + bar::stats::spawn_poller(sender.clone()); + bar::tray::spawn_watcher(sender.clone()); notifications::spawn(); osd::spawn(); @@ -228,6 +236,26 @@ impl SimpleComponent for App { self.wifi_img.set_paintable(Some(tex)); } } + AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { + if self.tray_items.contains_key(&id) { + return; + } + let btn = gtk4::Button::new(); + btn.add_css_class("tray-btn"); + btn.set_child(Some(&bar::tray::make_tray_image(icon.as_ref()))); + if !title.is_empty() { + btn.set_tooltip_text(Some(&title)); + } + let id_click = id.clone(); + btn.connect_clicked(move |_| bar::tray::spawn_activate(id_click.clone())); + self.tray_box.append(&btn); + self.tray_items.insert(id, btn); + } + AppInput::TrayUpdate(bar::tray::TrayUpdate::Remove { id }) => { + if let Some(btn) = self.tray_items.remove(&id) { + self.tray_box.remove(&btn); + } + } } } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b197038..b6c60b2 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -70,7 +70,7 @@ impl NotifServer { ( "breadbar".into(), "breadway".into(), - "0.1.0".into(), + env!("CARGO_PKG_VERSION").into(), "1.2".into(), ) } diff --git a/src/osd.rs b/src/osd.rs index 61f7ea4..74f0ec7 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -35,7 +35,7 @@ fn volume_watcher(tx: mpsc::Sender) { let stdout = child.stdout.take().unwrap(); let reader = BufReader::new(stdout); - for line in reader.lines().flatten() { + for line in reader.lines().map_while(Result::ok) { if line.contains("'change' on sink") { if let Some(evt) = query_volume() { let _ = tx.blocking_send(evt);