summaryrefslogtreecommitdiff
path: root/src/tray.rs
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-29 09:10:42 +0200
committercashmere <cashmere@autistici.org>2026-07-29 09:10:42 +0200
commit5b77a6d8f19500eee21a497ce84db538ded05c00 (patch)
tree13e72121a7835fd0fed34f709b6e277e624a56dc /src/tray.rs
parente2f5e1267cc30b73202c85aacfaee0cd6b18193c (diff)
downloadi3bar-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.rs615
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()
+}