diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-29 09:10:42 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-29 09:10:42 +0200 |
| commit | 5b77a6d8f19500eee21a497ce84db538ded05c00 (patch) | |
| tree | 13e72121a7835fd0fed34f709b6e277e624a56dc /src/tray.rs | |
| parent | e2f5e1267cc30b73202c85aacfaee0cd6b18193c (diff) | |
| download | i3bar-mango-5b77a6d8f19500eee21a497ce84db538ded05c00.tar.gz | |
add collapsible SNI system tray, rename project to i3bar-mango
Tray: the bar acts as org.kde.StatusNotifierWatcher plus host (zbus),
rendering items on the right edge with icon/name; [tray] config toggles
expanded, show_icon, show_name, show_description. Clicks map to
Activate/SecondaryActivate/ContextMenu.
Rename: package, binary, config dir, layer-shell namespace and DBus
names are now i3bar-mango; mango is the default feature and the first
WM provider tried.
Diffstat (limited to 'src/tray.rs')
| -rw-r--r-- | src/tray.rs | 615 |
1 files changed, 615 insertions, 0 deletions
diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..69fa9e2 --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,615 @@ +use std::os::fd::{AsRawFd, OwnedFd}; +use std::sync::Arc; + +use parking_lot::Mutex; +use std::time::Duration; + +use futures_channel::mpsc; +use futures_lite::stream::StreamExt; +use pangocairo::cairo; +use zbus::object_server::SignalEmitter; +use zbus::{Connection, interface, proxy}; + +#[derive(Clone)] +pub struct TrayItem { + pub key: String, + pub title: String, + pub icon_name: String, + pub icon_theme_path: String, + pub tooltip: String, + pub pixmap: Option<cairo::ImageSurface>, +} + +unsafe impl Send for TrayItem {} + +pub enum TrayCommand { + Activate { key: String }, + SecondaryActivate { key: String }, + ContextMenu { key: String, x: i32, y: i32 }, +} + +pub struct TrayHandle { + items: Arc<Mutex<Vec<TrayItem>>>, + commands: mpsc::UnboundedSender<TrayCommand>, +} + +impl TrayHandle { + pub fn spawn(wake: OwnedFd) -> TrayHandle { + let items = Arc::new(Mutex::new(Vec::new())); + let (commands, commands_rx) = mpsc::unbounded(); + let worker_items = items.clone(); + std::thread::spawn(move || worker(wake, worker_items, commands_rx)); + TrayHandle { items, commands } + } + + pub fn items(&self) -> Vec<TrayItem> { + self.items.lock().clone() + } + + pub fn send(&self, cmd: TrayCommand) { + let _ = self.commands.unbounded_send(cmd); + } +} + +#[derive(Clone, Copy)] +enum SignalKind { + Title, + Icon, + Status, + ToolTip, + IconThemePath, +} + +enum Event { + Register { + bus_name: String, + path: String, + announced: String, + }, + Vanished(String), + Signal { + key: String, + kind: SignalKind, + }, +} + +enum Input { + Event(Event), + Command(TrayCommand), +} + +enum Method { + Activate, + SecondaryActivate, + ContextMenu, +} + +#[derive(Default)] +struct WatcherShared { + registered: Vec<String>, + hosts: Vec<String>, +} + +struct WatcherService { + shared: Arc<Mutex<WatcherShared>>, + events: mpsc::UnboundedSender<Event>, +} + +#[interface(name = "org.kde.StatusNotifierWatcher")] +impl WatcherService { + async fn register_status_notifier_item( + &self, + service: &str, + #[zbus(header)] header: zbus::message::Header<'_>, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> zbus::fdo::Result<()> { + let sender = header.sender().map(|name| name.as_str()).unwrap_or(""); + let (bus_name, path) = parse_registration(service, sender); + let announced = bus_name.clone(); + { + let mut shared = self.shared.lock(); + if shared.registered.contains(&announced) { + return Ok(()); + } + shared.registered.push(announced.clone()); + } + emitter.status_notifier_item_registered(&announced).await?; + self.registered_status_notifier_items_changed(&emitter).await?; + let _ = self.events.unbounded_send(Event::Register { + bus_name, + path, + announced, + }); + Ok(()) + } + + async fn register_status_notifier_host( + &self, + service: &str, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> zbus::fdo::Result<()> { + { + let mut shared = self.shared.lock(); + if shared.hosts.iter().any(|host| host == service) { + return Ok(()); + } + shared.hosts.push(service.to_string()); + } + emitter.status_notifier_host_registered().await?; + self.is_status_notifier_host_registered_changed(&emitter).await?; + Ok(()) + } + + #[zbus(property)] + fn registered_status_notifier_items(&self) -> Vec<String> { + self.shared.lock().registered.clone() + } + + #[zbus(property)] + fn is_status_notifier_host_registered(&self) -> bool { + !self.shared.lock().hosts.is_empty() + } + + #[zbus(property(emits_changed_signal = "const"))] + fn protocol_version(&self) -> i32 { + 0 + } + + #[zbus(signal)] + async fn status_notifier_item_registered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_item_unregistered( + emitter: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_registered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_unregistered(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +#[proxy( + interface = "org.kde.StatusNotifierItem", + gen_blocking = false, + assume_defaults = false +)] +trait StatusNotifierItem { + fn activate(&self, x: i32, y: i32) -> zbus::Result<()>; + fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>; + fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>; + + #[zbus(property(emits_changed_signal = "false"))] + fn id(&self) -> zbus::Result<String>; + + #[zbus(property(emits_changed_signal = "false"))] + fn title(&self) -> zbus::Result<String>; + + #[zbus(property(emits_changed_signal = "false"))] + fn status(&self) -> zbus::Result<String>; + + #[zbus(property(emits_changed_signal = "false"))] + fn icon_name(&self) -> zbus::Result<String>; + + #[zbus(property(emits_changed_signal = "false"))] + fn icon_theme_path(&self) -> zbus::Result<String>; + + #[zbus(property(emits_changed_signal = "false"))] + fn tool_tip(&self) -> zbus::Result<(String, Vec<(i32, i32, Vec<u8>)>, String, String)>; + + #[zbus(property(emits_changed_signal = "false"))] + fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>; + + #[zbus(signal)] + fn new_title(&self); + + #[zbus(signal)] + fn new_icon(&self); + + #[zbus(signal)] + fn new_status(&self); + + #[zbus(signal)] + fn new_tool_tip(&self); + + #[zbus(signal)] + fn new_icon_theme_path(&self); +} + +struct ActiveItem { + key: String, + bus_name: String, + unique_name: String, + announced: String, + status: String, + id: String, + proxy: StatusNotifierItemProxy<'static>, + item: TrayItem, + _tasks: Vec<zbus::Task<()>>, +} + +fn worker( + wake: OwnedFd, + snapshot: Arc<Mutex<Vec<TrayItem>>>, + mut commands: mpsc::UnboundedReceiver<TrayCommand>, +) { + loop { + match futures_lite::future::block_on(run(&wake, &snapshot, &mut commands)) { + Ok(()) => break, + Err(err) => eprintln!("i3bar-mango: tray: {err}; retrying in 5s"), + } + std::thread::sleep(Duration::from_secs(5)); + } +} + +async fn run( + wake: &OwnedFd, + snapshot: &Arc<Mutex<Vec<TrayItem>>>, + commands: &mut mpsc::UnboundedReceiver<TrayCommand>, +) -> zbus::Result<()> { + let shared = Arc::new(Mutex::new(WatcherShared::default())); + let (events_tx, mut events_rx) = mpsc::unbounded::<Event>(); + + let conn = zbus::connection::Builder::session()? + .serve_at( + "/StatusNotifierWatcher", + WatcherService { + shared: shared.clone(), + events: events_tx.clone(), + }, + )? + .name("org.kde.StatusNotifierWatcher")? + .name(format!( + "org.kde.StatusNotifierHost-i3bar-mango-{}", + std::process::id() + ))? + .build() + .await?; + + let dbus = zbus::fdo::DBusProxy::new(&conn).await?; + + let owner_stream = dbus.receive_name_owner_changed().await?; + let tx = events_tx.clone(); + conn.executor() + .spawn( + async move { + futures_lite::pin!(owner_stream); + while let Some(signal) = owner_stream.next().await { + if let Ok(args) = signal.args() { + if args.new_owner().is_none() { + let _ = tx.unbounded_send(Event::Vanished(args.name().to_string())); + } + } + } + }, + "tray-name-owner", + ) + .detach(); + + let mut items: Vec<ActiveItem> = Vec::new(); + + loop { + let input = futures_lite::future::or( + async { events_rx.next().await.map(Input::Event) }, + async { commands.next().await.map(Input::Command) }, + ) + .await; + + match input { + None => return Ok(()), + Some(Input::Command(cmd)) => { + let (key, method, x, y) = match cmd { + TrayCommand::Activate { key } => (key, Method::Activate, 0, 0), + TrayCommand::SecondaryActivate { key } => (key, Method::SecondaryActivate, 0, 0), + TrayCommand::ContextMenu { key, x, y } => (key, Method::ContextMenu, x, y), + }; + if let Some(active) = items.iter().find(|item| item.key == key) { + let proxy = active.proxy.clone(); + conn.executor() + .spawn( + async move { + let result = match method { + Method::Activate => proxy.activate(0, 0).await, + Method::SecondaryActivate => { + proxy.secondary_activate(0, 0).await + } + Method::ContextMenu => proxy.context_menu(x, y).await, + }; + if let Err(err) = result { + eprintln!("i3bar-mango: tray: command failed: {err}"); + } + }, + "tray-command", + ) + .detach(); + } + } + Some(Input::Event(Event::Register { + bus_name, + path, + announced, + })) => { + let key = format!("{bus_name}/{path}"); + if items.iter().any(|item| item.key == key) { + continue; + } + let builder = StatusNotifierItemProxy::builder(&conn) + .destination(bus_name.clone()) + .and_then(|builder| builder.path(path.clone())); + let proxy = match builder { + Ok(builder) => match builder.build().await { + Ok(proxy) => proxy, + Err(err) => { + eprintln!("i3bar-mango: tray: failed to reach {key}: {err}"); + unregister(&conn, &shared, &announced).await; + continue; + } + }, + Err(err) => { + eprintln!("i3bar-mango: tray: invalid registration {key}: {err}"); + unregister(&conn, &shared, &announced).await; + continue; + } + }; + let id = match proxy.id().await { + Ok(id) => id, + Err(err) => { + eprintln!("i3bar-mango: tray: failed to read properties of {key}: {err}"); + unregister(&conn, &shared, &announced).await; + continue; + } + }; + let title = proxy.title().await.unwrap_or_default(); + let icon_name = proxy.icon_name().await.unwrap_or_default(); + let icon_theme_path = proxy.icon_theme_path().await.unwrap_or_default(); + let status = proxy.status().await.unwrap_or_default(); + let tooltip = proxy + .tool_tip() + .await + .map(|tooltip| strip_html(&tooltip.3)) + .unwrap_or_default(); + let pixmap = match proxy.icon_pixmap().await { + Ok(pixmaps) => best_pixmap(&pixmaps) + .and_then(|(width, height, data)| pixmap_to_surface(*width, *height, data)), + Err(_) => None, + }; + let title = if title.is_empty() { id.clone() } else { title }; + let unique_name = if bus_name.starts_with(':') { + bus_name.clone() + } else { + match zbus::names::BusName::try_from(bus_name.as_str()) { + Ok(name) => dbus + .get_name_owner(name) + .await + .map(|owner| owner.to_string()) + .unwrap_or_default(), + Err(_) => String::new(), + } + }; + let tasks = spawn_signal_tasks(&conn, &proxy, &key, &events_tx).await; + items.push(ActiveItem { + key: key.clone(), + bus_name, + unique_name, + announced, + status, + id, + proxy, + item: TrayItem { + key, + title, + icon_name, + icon_theme_path, + tooltip, + pixmap, + }, + _tasks: tasks, + }); + update_snapshot(&items, snapshot, wake); + } + Some(Input::Event(Event::Vanished(name))) => { + let removed = if let Some(pos) = items + .iter() + .position(|item| item.bus_name == name || item.unique_name == name) + { + Some(items.remove(pos).announced) + } else { + let is_host = shared.lock().hosts.iter().any(|host| *host == name); + if is_host { + let mut state = shared.lock(); + state.hosts.retain(|host| *host != name); + if let Ok(iface) = conn + .object_server() + .interface::<_, WatcherService>("/StatusNotifierWatcher") + .await + { + let _ = iface.status_notifier_host_unregistered().await; + let guard = iface.get().await; + let _ = guard + .is_status_notifier_host_registered_changed(iface.signal_emitter()) + .await; + } + None + } else if shared.lock().registered.iter().any(|n| *n == name) { + Some(name) + } else { + None + } + }; + if let Some(announced) = removed { + unregister(&conn, &shared, &announced).await; + update_snapshot(&items, snapshot, wake); + } + } + Some(Input::Event(Event::Signal { key, kind })) => { + let Some(index) = items.iter().position(|item| item.key == key) else { + continue; + }; + let active = &mut items[index]; + match kind { + SignalKind::Title => { + if let Ok(title) = active.proxy.title().await { + active.item.title = if title.is_empty() { + active.id.clone() + } else { + title + }; + } + } + SignalKind::Icon => { + if let Ok(icon_name) = active.proxy.icon_name().await { + active.item.icon_name = icon_name; + } + if let Ok(pixmaps) = active.proxy.icon_pixmap().await { + active.item.pixmap = best_pixmap(&pixmaps).and_then( + |(width, height, data)| pixmap_to_surface(*width, *height, data), + ); + } + } + SignalKind::Status => { + if let Ok(status) = active.proxy.status().await { + active.status = status; + } + } + SignalKind::ToolTip => { + if let Ok(tooltip) = active.proxy.tool_tip().await { + active.item.tooltip = strip_html(&tooltip.3); + } + } + SignalKind::IconThemePath => { + if let Ok(path) = active.proxy.icon_theme_path().await { + active.item.icon_theme_path = path; + } + } + } + update_snapshot(&items, snapshot, wake); + } + } + } +} + +async fn unregister(conn: &Connection, shared: &Arc<Mutex<WatcherShared>>, announced: &str) { + { + let mut state = shared.lock(); + let Some(pos) = state.registered.iter().position(|name| name == announced) else { + return; + }; + state.registered.remove(pos); + } + if let Ok(iface) = conn + .object_server() + .interface::<_, WatcherService>("/StatusNotifierWatcher") + .await + { + let _ = iface.status_notifier_item_unregistered(announced).await; + let guard = iface.get().await; + let _ = guard + .registered_status_notifier_items_changed(iface.signal_emitter()) + .await; + } +} + +async fn spawn_signal_tasks( + conn: &Connection, + proxy: &StatusNotifierItemProxy<'static>, + key: &str, + events: &mpsc::UnboundedSender<Event>, +) -> Vec<zbus::Task<()>> { + let mut tasks = Vec::new(); + macro_rules! forward { + ($stream:expr, $kind:expr) => { + if let Ok(stream) = $stream.await { + let key = key.to_string(); + let events = events.clone(); + tasks.push(conn.executor().spawn( + async move { + futures_lite::pin!(stream); + while stream.next().await.is_some() { + let _ = events.unbounded_send(Event::Signal { + key: key.clone(), + kind: $kind, + }); + } + }, + "tray-signal", + )); + } + }; + } + forward!(proxy.receive_new_title(), SignalKind::Title); + forward!(proxy.receive_new_icon(), SignalKind::Icon); + forward!(proxy.receive_new_status(), SignalKind::Status); + forward!(proxy.receive_new_tool_tip(), SignalKind::ToolTip); + forward!(proxy.receive_new_icon_theme_path(), SignalKind::IconThemePath); + tasks +} + +fn update_snapshot(items: &[ActiveItem], snapshot: &Arc<Mutex<Vec<TrayItem>>>, wake: &OwnedFd) { + let visible: Vec<TrayItem> = items + .iter() + .filter(|item| item.status != "Passive") + .map(|item| item.item.clone()) + .collect(); + *snapshot.lock() = visible; + let result = unsafe { libc::write(wake.as_raw_fd(), [1u8].as_ptr().cast(), 1) }; + if result < 0 { + eprintln!( + "i3bar-mango: tray: wake pipe write failed: {}", + std::io::Error::last_os_error() + ); + } +} + +fn parse_registration(arg: &str, sender: &str) -> (String, String) { + if arg.starts_with('/') { + (sender.to_string(), arg.to_string()) + } else if let Some((name, path)) = arg.split_once('/') { + (name.to_string(), format!("/{path}")) + } else { + (arg.to_string(), "/StatusNotifierItem".to_string()) + } +} + +fn best_pixmap<'a>(pixmaps: &'a [(i32, i32, Vec<u8>)]) -> Option<&'a (i32, i32, Vec<u8>)> { + pixmaps + .iter() + .filter(|(width, height, data)| { + *width > 0 && *height > 0 && data.len() >= (*width * *height * 4) as usize + }) + .min_by_key(|(width, height, _)| { + let size = (*width).max(*height); + if size >= 24 { size } else { i32::MAX - size } + }) +} + +fn pixmap_to_surface(width: i32, height: i32, data: &[u8]) -> Option<cairo::ImageSurface> { + let stride = width * 4; + let mut buffer = vec![0u8; (height * stride) as usize]; + for (src, dst) in data.chunks_exact(4).zip(buffer.chunks_exact_mut(4)) { + let a = u32::from(src[0]); + let r = (u32::from(src[1]) * a + 127) / 255; + let g = (u32::from(src[2]) * a + 127) / 255; + let b = (u32::from(src[3]) * a + 127) / 255; + dst.copy_from_slice(&((a << 24) | (r << 16) | (g << 8) | b).to_ne_bytes()); + } + cairo::ImageSurface::create_for_data(buffer, cairo::Format::ARgb32, width, height, stride).ok() +} + +fn strip_html(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut in_tag = false; + for ch in text.chars() { + match ch { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + _ if !in_tag => out.push(ch), + _ => {} + } + } + out.trim().to_string() +} |
