diff --git a/Cargo.lock b/Cargo.lock index 87ff995d..853809af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3002,9 +3002,9 @@ dependencies = [ [[package]] name = "system-tray" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8f7f19237d2149a8e4b56409c579b26a98748e3cfadba93762c4746c4c7ae2" +checksum = "a456e3e6cbd396f1a3a91f8f74d1fdcf2bde85c97afe174442c367f4749fc09b" dependencies = [ "anyhow", "byteorder", diff --git a/Cargo.toml b/Cargo.toml index f779b916..fe50c36b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,7 +125,7 @@ mpris = { version = "2.0.1", optional = true } sysinfo = { version = "0.29.11", optional = true } # tray -system-tray = { version = "0.1.4", optional = true } +system-tray = { version = "0.1.5", optional = true } # upower upower_dbus = { version = "0.3.2", optional = true } diff --git a/src/clients/system_tray.rs b/src/clients/system_tray.rs index 60c8b129..aec5c0a6 100644 --- a/src/clients/system_tray.rs +++ b/src/clients/system_tray.rs @@ -25,7 +25,7 @@ impl TrayEventReceiver { let id = format!("ironbar-{}", Ironbar::unique_id()); let (tx, rx) = mpsc::channel(16); - let (b_tx, b_rx) = broadcast::channel(16); + let (b_tx, b_rx) = broadcast::channel(64); let tray = StatusNotifierWatcher::new(rx).await?; let mut host = Box::pin(tray.create_notifier_host(&id)).await?; @@ -48,7 +48,7 @@ impl TrayEventReceiver { item, menu, } => { - debug!("Adding item with address '{address}'"); + debug!("Adding/updating item with address '{address}'"); tray.insert(address, (item, menu)); } NotifierItemMessage::Remove { address } => { diff --git a/src/macros.rs b/src/macros.rs index e71a5934..028fd196 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -62,8 +62,17 @@ macro_rules! glib_recv { glib::spawn_future_local(async move { // re-delcare in case ie `context.subscribe()` is passed directly let mut rx = $rx; - while let Ok($val) = rx.recv().await { - $expr + loop { + match rx.recv().await { + Ok($val) => $expr, + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!("Channel lagged behind by {count}, this may result in unexpected or broken behaviour"); + } + Err(err) => { + tracing::error!("{err:?}"); + break; + } + } } }); }}; diff --git a/src/modules/tray.rs b/src/modules/tray.rs deleted file mode 100644 index 35a8b57c..00000000 --- a/src/modules/tray.rs +++ /dev/null @@ -1,275 +0,0 @@ -use crate::clients::system_tray::TrayEventReceiver; -use crate::config::CommonConfig; -use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext}; -use crate::{glib_recv, spawn, try_send}; -use color_eyre::Result; -use glib::ffi::g_strfreev; -use glib::translate::ToGlibPtr; -use gtk::ffi::gtk_icon_theme_get_search_path; -use gtk::gdk_pixbuf::{Colorspace, InterpType}; -use gtk::prelude::*; -use gtk::{ - gdk_pixbuf, IconLookupFlags, IconTheme, Image, Label, Menu, MenuBar, MenuItem, - SeparatorMenuItem, -}; -use serde::Deserialize; -use std::collections::{HashMap, HashSet}; -use std::ffi::CStr; -use std::os::raw::{c_char, c_int}; -use std::ptr; -use system_tray::message::menu::{MenuItem as MenuItemInfo, MenuType}; -use system_tray::message::tray::StatusNotifierItem; -use system_tray::message::{NotifierItemCommand, NotifierItemMessage}; -use tokio::sync::mpsc; -use tokio::sync::mpsc::{Receiver, Sender}; - -#[derive(Debug, Deserialize, Clone)] -pub struct TrayModule { - #[serde(flatten)] - pub common: Option, -} - -/// Gets the GTK icon theme search paths by calling the FFI function. -/// Conveniently returns the result as a `HashSet`. -fn get_icon_theme_search_paths(icon_theme: &IconTheme) -> HashSet { - let mut gtk_paths: *mut *mut c_char = ptr::null_mut(); - let mut n_elements: c_int = 0; - let mut paths = HashSet::new(); - unsafe { - gtk_icon_theme_get_search_path( - icon_theme.to_glib_none().0, - &mut gtk_paths, - &mut n_elements, - ); - // n_elements is never negative (that would be weird) - for i in 0..n_elements as usize { - let c_str = CStr::from_ptr(*gtk_paths.add(i)); - if let Ok(str) = c_str.to_str() { - paths.insert(str.to_owned()); - } - } - - g_strfreev(gtk_paths); - } - - paths -} - -/// Attempts to get a GTK `Image` component -/// for the status notifier item's icon. -fn get_image_from_icon_name(item: &StatusNotifierItem, icon_theme: &IconTheme) -> Option { - if let Some(path) = item.icon_theme_path.as_ref() { - if !path.is_empty() && !get_icon_theme_search_paths(icon_theme).contains(path) { - icon_theme.append_search_path(path); - } - } - - item.icon_name.as_ref().and_then(|icon_name| { - let icon_info = icon_theme.lookup_icon(icon_name, 16, IconLookupFlags::empty()); - icon_info.map(|icon_info| Image::from_pixbuf(icon_info.load_icon().ok().as_ref())) - }) -} - -/// Attempts to get an image from the item pixmap. -/// -/// The pixmap is supplied in ARGB32 format, -/// which has 8 bits per sample and a bit stride of `4*width`. -fn get_image_from_pixmap(item: &StatusNotifierItem) -> Option { - const BITS_PER_SAMPLE: i32 = 8; // - - let pixmap = item - .icon_pixmap - .as_ref() - .and_then(|pixmap| pixmap.first())?; - - let bytes = glib::Bytes::from(&pixmap.pixels); - let row_stride = pixmap.width * 4; // - - let pixbuf = gdk_pixbuf::Pixbuf::from_bytes( - &bytes, - Colorspace::Rgb, - true, - BITS_PER_SAMPLE, - pixmap.width, - pixmap.height, - row_stride, - ); - - let pixbuf = pixbuf - .scale_simple(16, 16, InterpType::Bilinear) - .unwrap_or(pixbuf); - Some(Image::from_pixbuf(Some(&pixbuf))) -} - -/// Recursively gets GTK `MenuItem` components -/// for the provided submenu array. -fn get_menu_items( - menu: &[MenuItemInfo], - tx: &Sender, - id: &str, - path: &str, -) -> Vec { - menu.iter() - .map(|item_info| { - let item: Box> = match item_info.menu_type { - MenuType::Separator => Box::new(SeparatorMenuItem::new()), - MenuType::Standard => { - let mut builder = MenuItem::builder() - .label(item_info.label.as_str()) - .visible(item_info.visible) - .sensitive(item_info.enabled); - - if !item_info.submenu.is_empty() { - let menu = Menu::new(); - get_menu_items(&item_info.submenu, &tx.clone(), id, path) - .iter() - .for_each(|item| menu.add(item)); - - builder = builder.submenu(&menu); - } - - let item = builder.build(); - - let info = item_info.clone(); - let id = id.to_string(); - let path = path.to_string(); - - { - let tx = tx.clone(); - item.connect_activate(move |_item| { - try_send!( - tx, - NotifierItemCommand::MenuItemClicked { - submenu_id: info.id, - menu_path: path.clone(), - notifier_address: id.clone(), - } - ); - }); - } - - Box::new(item) - } - }; - - (*item).as_ref().clone() - }) - .collect() -} - -impl Module for TrayModule { - type SendMessage = NotifierItemMessage; - type ReceiveMessage = NotifierItemCommand; - - fn name() -> &'static str { - "tray" - } - - fn spawn_controller( - &self, - _info: &ModuleInfo, - context: &WidgetContext, - mut rx: Receiver, - ) -> Result<()> { - let tx = context.tx.clone(); - - let client = context.client::(); - - let (tray_tx, mut tray_rx) = client.subscribe(); - - // listen to tray updates - spawn(async move { - while let Ok(message) = tray_rx.recv().await { - tx.send(ModuleUpdateEvent::Update(message)).await?; - } - - Ok::<(), mpsc::error::SendError>>(()) - }); - - // send tray commands - spawn(async move { - while let Some(cmd) = rx.recv().await { - tray_tx.send(cmd).await?; - } - - Ok::<(), mpsc::error::SendError>(()) - }); - - Ok(()) - } - - fn into_widget( - self, - context: WidgetContext, - info: &ModuleInfo, - ) -> Result> { - let container = MenuBar::new(); - - { - let container = container.clone(); - let mut widgets = HashMap::new(); - let icon_theme = info.icon_theme.clone(); - - // listen for UI updates - glib_recv!(context.subscribe(), update => { - match update { - NotifierItemMessage::Update { - item, - address, - menu, - } => { - let addr = &address; - let menu_item = widgets.remove(address.as_str()).unwrap_or_else(|| { - let menu_item = MenuItem::new(); - menu_item.style_context().add_class("item"); - - get_image_from_icon_name(&item, &icon_theme) - .or_else(|| get_image_from_pixmap(&item)) - .map_or_else( - || { - let label = - Label::new(Some(item.title.as_ref().unwrap_or(addr))); - menu_item.add(&label); - }, - |image| { - image.set_widget_name(address.as_str()); - menu_item.add(&image); - }, - ); - - container.add(&menu_item); - menu_item.show_all(); - menu_item - }); - if let (Some(menu_opts), Some(menu_path)) = (menu, item.menu) { - let submenus = menu_opts.submenus; - if !submenus.is_empty() { - let menu = Menu::new(); - get_menu_items( - &submenus, - &context.controller_tx.clone(), - &address, - &menu_path, - ) - .iter() - .for_each(|item| menu.add(item)); - menu_item.set_submenu(Some(&menu)); - } - } - widgets.insert(address, menu_item); - } - NotifierItemMessage::Remove { address } => { - if let Some(widget) = widgets.get(&address) { - container.remove(widget); - } - } - }; - }); - }; - - Ok(ModuleParts { - widget: container, - popup: None, - }) - } -} diff --git a/src/modules/tray/diff.rs b/src/modules/tray/diff.rs new file mode 100644 index 00000000..25e27b69 --- /dev/null +++ b/src/modules/tray/diff.rs @@ -0,0 +1,104 @@ +use system_tray::message::menu::{MenuItem as MenuItemInfo, ToggleState}; + +/// Diff change type and associated info. +#[derive(Debug, Clone)] +pub enum Diff { + Add(MenuItemInfo), + Update(i32, MenuItemDiff), + Remove(i32), +} + +/// Diff info to be applied to an existing menu item as an update. +#[derive(Debug, Clone)] +pub struct MenuItemDiff { + /// Text of the item, + pub label: Option, + /// Whether the item can be activated or not. + pub enabled: Option, + /// True if the item is visible in the menu. + pub visible: Option, + /// Icon name of the item, following the freedesktop.org icon spec. + // pub icon_name: Option>, + /// Describe the current state of a "togglable" item. Can be one of: + /// - Some(true): on + /// - Some(false): off + /// - None: indeterminate + pub toggle_state: Option, + /// A submenu for this item, typically this would ve revealed to the user by hovering the current item + pub submenu: Vec, +} + +impl MenuItemDiff { + fn new(old: &MenuItemInfo, new: &MenuItemInfo) -> Self { + macro_rules! diff { + ($field:ident) => { + if old.$field == new.$field { + None + } else { + Some(new.$field) + } + }; + + (&$field:ident) => { + if &old.$field == &new.$field { + None + } else { + Some(new.$field.clone()) + } + }; + } + + Self { + label: diff!(&label), + enabled: diff!(enabled), + visible: diff!(visible), + // icon_name: diff!(&icon_name), + toggle_state: diff!(toggle_state), + submenu: get_diffs(&old.submenu, &new.submenu), + } + } + + /// Whether this diff contains any changes + fn has_diff(&self) -> bool { + self.label.is_some() + || self.enabled.is_some() + || self.visible.is_some() + // || self.icon_name.is_some() + || self.toggle_state.is_some() + || !self.submenu.is_empty() + } +} + +/// Gets a diff set between old and new state. +pub fn get_diffs(old: &[MenuItemInfo], new: &[MenuItemInfo]) -> Vec { + let mut diffs = vec![]; + + for new_item in new { + let old_item = old.iter().find(|&item| item.id == new_item.id); + + let diff = match old_item { + Some(old_item) => { + let item_diff = MenuItemDiff::new(old_item, new_item); + if item_diff.has_diff() { + Some(Diff::Update(old_item.id, item_diff)) + } else { + None + } + } + None => Some(Diff::Add(new_item.clone())), + }; + + if let Some(diff) = diff { + diffs.push(diff); + } + } + + for old_item in old { + let new_item = new.iter().find(|&item| item.id == old_item.id); + if new_item.is_none() { + diffs.push(Diff::Remove(old_item.id)); + } + } + + diffs +} diff --git a/src/modules/tray/icon.rs b/src/modules/tray/icon.rs new file mode 100644 index 00000000..850137f6 --- /dev/null +++ b/src/modules/tray/icon.rs @@ -0,0 +1,86 @@ +use glib::ffi::g_strfreev; +use glib::translate::ToGlibPtr; +use gtk::ffi::gtk_icon_theme_get_search_path; +use gtk::gdk_pixbuf::{Colorspace, InterpType}; +use gtk::prelude::IconThemeExt; +use gtk::{gdk_pixbuf, IconLookupFlags, IconTheme, Image}; +use std::collections::HashSet; +use std::ffi::CStr; +use std::os::raw::{c_char, c_int}; +use std::ptr; +use system_tray::message::tray::StatusNotifierItem; + +/// Gets the GTK icon theme search paths by calling the FFI function. +/// Conveniently returns the result as a `HashSet`. +fn get_icon_theme_search_paths(icon_theme: &IconTheme) -> HashSet { + let mut gtk_paths: *mut *mut c_char = ptr::null_mut(); + let mut n_elements: c_int = 0; + let mut paths = HashSet::new(); + unsafe { + gtk_icon_theme_get_search_path( + icon_theme.to_glib_none().0, + &mut gtk_paths, + &mut n_elements, + ); + // n_elements is never negative (that would be weird) + for i in 0..n_elements as usize { + let c_str = CStr::from_ptr(*gtk_paths.add(i)); + if let Ok(str) = c_str.to_str() { + paths.insert(str.to_owned()); + } + } + + g_strfreev(gtk_paths); + } + + paths +} + +/// Attempts to get a GTK `Image` component +/// for the status notifier item's icon. +pub(crate) fn get_image_from_icon_name( + item: &StatusNotifierItem, + icon_theme: &IconTheme, +) -> Option { + if let Some(path) = item.icon_theme_path.as_ref() { + if !path.is_empty() && !get_icon_theme_search_paths(icon_theme).contains(path) { + icon_theme.append_search_path(path); + } + } + + item.icon_name.as_ref().and_then(|icon_name| { + let icon_info = icon_theme.lookup_icon(icon_name, 16, IconLookupFlags::empty()); + icon_info.map(|icon_info| Image::from_pixbuf(icon_info.load_icon().ok().as_ref())) + }) +} + +/// Attempts to get an image from the item pixmap. +/// +/// The pixmap is supplied in ARGB32 format, +/// which has 8 bits per sample and a bit stride of `4*width`. +pub(crate) fn get_image_from_pixmap(item: &StatusNotifierItem) -> Option { + const BITS_PER_SAMPLE: i32 = 8; + + let pixmap = item + .icon_pixmap + .as_ref() + .and_then(|pixmap| pixmap.first())?; + + let bytes = glib::Bytes::from(&pixmap.pixels); + let row_stride = pixmap.width * 4; // + + let pixbuf = gdk_pixbuf::Pixbuf::from_bytes( + &bytes, + Colorspace::Rgb, + true, + BITS_PER_SAMPLE, + pixmap.width, + pixmap.height, + row_stride, + ); + + let pixbuf = pixbuf + .scale_simple(16, 16, InterpType::Bilinear) + .unwrap_or(pixbuf); + Some(Image::from_pixbuf(Some(&pixbuf))) +} diff --git a/src/modules/tray/interface.rs b/src/modules/tray/interface.rs new file mode 100644 index 00000000..3b9d988d --- /dev/null +++ b/src/modules/tray/interface.rs @@ -0,0 +1,314 @@ +use crate::modules::tray::diff::{Diff, MenuItemDiff}; +use crate::{spawn, try_send}; +use gtk::prelude::*; +use gtk::{CheckMenuItem, Image, Label, Menu, MenuItem, SeparatorMenuItem}; +use std::collections::HashMap; +use system_tray::message::menu::{MenuItem as MenuItemInfo, MenuType, ToggleState, ToggleType}; +use system_tray::message::NotifierItemCommand; +use tokio::sync::mpsc; + +/// Calls a method on the underlying widget, +/// passing in a single argument. +/// +/// This is useful to avoid matching on +/// `TrayMenuWidget` constantly. +/// +/// # Example +/// ```rust +/// call!(container, add, my_widget) +/// ``` +/// is the same as: +/// ``` +/// match &my_widget { +/// TrayMenuWidget::Separator(w) => { +/// container.add(w); +/// } +/// TrayMenuWidget::Standard(w) => { +/// container.add(w); +/// } +/// TrayMenuWidget::Checkbox(w) => { +/// container.add(w); +/// } +/// } +/// ``` +macro_rules! call { + ($parent:expr, $method:ident, $child:expr) => { + match &$child { + TrayMenuWidget::Separator(w) => { + $parent.$method(w); + } + TrayMenuWidget::Standard(w) => { + $parent.$method(w); + } + TrayMenuWidget::Checkbox(w) => { + $parent.$method(w); + } + } + }; +} + +/// Main tray icon to show on the bar +pub(crate) struct TrayMenu { + pub(crate) widget: MenuItem, + menu_widget: Menu, + image_widget: Option, + label_widget: Option