From 762a0d08536353798d38732ebd28970f97556f51 Mon Sep 17 00:00:00 2001 From: Anthony Smith Date: Wed, 8 Jul 2026 05:49:48 -0400 Subject: [PATCH 1/3] Add Linux KSNI menu compatibility model --- Cargo.lock | 10 + Cargo.toml | 3 + README.md | 1 + src/items/check.rs | 75 ++++++- src/items/compat.rs | 66 ++++++ src/items/icon.rs | 109 ++++++++-- src/items/mod.rs | 4 + src/items/normal.rs | 64 +++++- src/items/predefined.rs | 41 +++- src/items/submenu.rs | 138 ++++++++++-- src/lib.rs | 29 ++- src/menu.rs | 56 ++++- src/platform_impl/gtk/mod.rs | 107 ++++++++- src/platform_impl/linux/mod.rs | 382 +++++++++++++++++++++++++++++++++ src/platform_impl/mod.rs | 40 ++++ 15 files changed, 1057 insertions(+), 68 deletions(-) create mode 100644 src/items/compat.rs create mode 100644 src/platform_impl/linux/mod.rs diff --git a/Cargo.lock b/Cargo.lock index cdfb60e0..38435eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1536,6 +1545,7 @@ dependencies = [ name = "muda" version = "0.19.3" dependencies = [ + "arc-swap", "crossbeam-channel", "dpi", "gtk", diff --git a/Cargo.toml b/Cargo.toml index c6fcc7f1..53e837af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,12 @@ include = ["README.md", "src/**/*.rs", "Cargo.toml", "LICENSE-APACHE", "LICENSE- default = ["libxdo", "gtk"] libxdo = ["dep:libxdo"] gtk = ["dep:gtk"] +linux-ksni = ["dep:arc-swap", "dep:png"] common-controls-v6 = [] serde = ["dep:serde", "dpi/serde", "keyboard-types/serde"] [dependencies] +arc-swap = { version = "1.7.1", optional = true } crossbeam-channel = "0.5" keyboard-types = "0.7" once_cell = "1" @@ -46,6 +48,7 @@ features = [ [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies] gtk = { version = "0.18", optional = true } libxdo = { version = "0.6.0", optional = true } +png = { version = "0.18", optional = true } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.1" diff --git a/README.md b/README.md index a72c5cb9..d7c99b74 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Menu Utilities library for Desktop Applications. - `libxdo`: Enables linking to `libxdo` on Linux or FreeBSD which is used for the predefined `Copy`, `Cut`, `Paste` and `SelectAll` menu item. - `serde`: Enables de/serializing the dpi types. - `gtk`: Enables the `gtk` crate dependency on Linux or FreeBSD. This is required for `muda` to function properly on Linux or FreeBSD. +- `linux-ksni`: Enables a GTK-free Linux menu compatibility model for StatusNotifierItem tray integrations. ## Dependencies (Linux Only) diff --git a/src/items/check.rs b/src/items/check.rs index 67c76931..65a9c205 100644 --- a/src/items/check.rs +++ b/src/items/check.rs @@ -4,6 +4,12 @@ use std::{cell::RefCell, mem, rc::Rc}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + use crate::{ accelerator::{Accelerator, KeyAccelerator}, sealed::IsMenuItemBase, @@ -20,6 +26,8 @@ use crate::{ pub struct CheckMenuItem { pub(crate) id: Rc, pub(crate) inner: Rc>, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) compat: Arc>, } impl IsMenuItemBase for CheckMenuItem {} @@ -38,6 +46,19 @@ impl IsMenuItem for CheckMenuItem { } impl CheckMenuItem { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_menu_item( + item: &crate::platform_impl::MenuChild, + ) -> crate::CompatMenuItem { + crate::CompatCheckmarkItem { + id: item.id().0.clone(), + label: super::strip_mnemonic(item.text()), + enabled: item.is_enabled(), + checked: item.is_checked(), + } + .into() + } + /// Create a new check menu item. /// /// - `text` could optionally contain an `&` before a character to assign this character as the mnemonic @@ -55,9 +76,14 @@ impl CheckMenuItem { accelerator.map(KeyAccelerator::from), None, ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { id: Rc::new(item.id().clone()), inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -73,15 +99,21 @@ impl CheckMenuItem { accelerator: Option, ) -> Self { let id = id.into(); + let item = crate::platform_impl::MenuChild::new_check( + text.as_ref(), + enabled, + checked, + accelerator.map(KeyAccelerator::from), + Some(id), + ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { - id: Rc::new(id.clone()), - inner: Rc::new(RefCell::new(crate::platform_impl::MenuChild::new_check( - text.as_ref(), - enabled, - checked, - accelerator.map(KeyAccelerator::from), - Some(id), - ))), + id: Rc::new(item.id().clone()), + inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -99,7 +131,14 @@ impl CheckMenuItem { /// an `&` before a character to assign this character as the mnemonic /// for this check menu item. To display a `&` without assigning a mnemenonic, use `&&`. pub fn set_text>(&self, text: S) { - self.inner.borrow_mut().set_text(text.as_ref()) + let mut inner = self.inner.borrow_mut(); + inner.set_text(text.as_ref()); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Get whether this check menu item is enabled or not. @@ -109,7 +148,14 @@ impl CheckMenuItem { /// Enable or disable this check menu item. pub fn set_enabled(&self, enabled: bool) { - self.inner.borrow_mut().set_enabled(enabled) + let mut inner = self.inner.borrow_mut(); + inner.set_enabled(enabled); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Set this check menu item accelerator. @@ -135,7 +181,14 @@ impl CheckMenuItem { /// Check or Uncheck this check menu item. pub fn set_checked(&self, checked: bool) { - self.inner.borrow_mut().set_checked(checked) + let mut inner = self.inner.borrow_mut(); + inner.set_checked(checked); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Convert this menu item into its menu ID. diff --git a/src/items/compat.rs b/src/items/compat.rs new file mode 100644 index 00000000..0d9395bb --- /dev/null +++ b/src/items/compat.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; + +#[derive(Debug, Clone)] +pub struct CompatStandardItem { + pub id: String, + pub label: String, + pub enabled: bool, + pub icon: Option>, +} + +#[derive(Debug, Clone)] +pub struct CompatCheckmarkItem { + pub id: String, + pub label: String, + pub enabled: bool, + pub checked: bool, +} + +#[derive(Debug, Clone)] +pub struct CompatSubMenuItem { + pub label: String, + pub enabled: bool, + pub submenu: Vec>>, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +pub enum CompatMenuItem { + Standard(CompatStandardItem), + Checkmark(CompatCheckmarkItem), + SubMenu(CompatSubMenuItem), + Separator, +} + +impl Default for CompatMenuItem { + fn default() -> Self { + CompatMenuItem::Separator + } +} + +impl From for CompatMenuItem { + fn from(item: CompatStandardItem) -> Self { + CompatMenuItem::Standard(item) + } +} + +impl From for CompatMenuItem { + fn from(item: CompatCheckmarkItem) -> Self { + CompatMenuItem::Checkmark(item) + } +} + +impl From for CompatMenuItem { + fn from(item: CompatSubMenuItem) -> Self { + CompatMenuItem::SubMenu(item) + } +} + +pub fn strip_mnemonic(text: impl AsRef) -> String { + text.as_ref() + .replace("&&", "[~~]") + .replace('&', "") + .replace("[~~]", "&") +} diff --git a/src/items/icon.rs b/src/items/icon.rs index d1ce5432..18b82709 100644 --- a/src/items/icon.rs +++ b/src/items/icon.rs @@ -4,6 +4,12 @@ use std::{cell::RefCell, mem, rc::Rc}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + use crate::{ accelerator::{Accelerator, KeyAccelerator}, icon::{Icon, NativeIcon}, @@ -20,6 +26,8 @@ use crate::{ pub struct IconMenuItem { pub(crate) id: Rc, pub(crate) inner: Rc>, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) compat: Arc>, } impl IsMenuItemBase for IconMenuItem {} @@ -38,6 +46,24 @@ impl IsMenuItem for IconMenuItem { } impl IconMenuItem { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_menu_item( + item: &crate::platform_impl::MenuChild, + ) -> crate::CompatMenuItem { + #[cfg(not(feature = "gtk"))] + let icon = item.icon_png(); + #[cfg(feature = "gtk")] + let icon = None; + + crate::CompatStandardItem { + id: item.id().0.clone(), + label: super::strip_mnemonic(item.text()), + enabled: item.is_enabled(), + icon, + } + .into() + } + /// Create a new icon menu item. /// /// - `text` could optionally contain an `&` before a character to assign this character as the mnemonic @@ -55,9 +81,14 @@ impl IconMenuItem { accelerator.map(KeyAccelerator::from), None, ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { id: Rc::new(item.id().clone()), inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -73,15 +104,21 @@ impl IconMenuItem { accelerator: Option, ) -> Self { let id = id.into(); + let item = crate::platform_impl::MenuChild::new_icon( + text.as_ref(), + enabled, + icon, + accelerator.map(KeyAccelerator::from), + Some(id), + ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { - id: Rc::new(id.clone()), - inner: Rc::new(RefCell::new(crate::platform_impl::MenuChild::new_icon( - text.as_ref(), - enabled, - icon, - accelerator.map(KeyAccelerator::from), - Some(id), - ))), + id: Rc::new(item.id().clone()), + inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -105,9 +142,14 @@ impl IconMenuItem { accelerator.map(KeyAccelerator::from), None, ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { id: Rc::new(item.id().clone()), inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -126,17 +168,21 @@ impl IconMenuItem { accelerator: Option, ) -> Self { let id = id.into(); + let item = crate::platform_impl::MenuChild::new_native_icon( + text.as_ref(), + enabled, + native_icon, + accelerator.map(KeyAccelerator::from), + Some(id), + ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { - id: Rc::new(id.clone()), - inner: Rc::new(RefCell::new( - crate::platform_impl::MenuChild::new_native_icon( - text.as_ref(), - enabled, - native_icon, - accelerator.map(KeyAccelerator::from), - Some(id), - ), - )), + id: Rc::new(item.id().clone()), + inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -154,7 +200,14 @@ impl IconMenuItem { /// an `&` before a character to assign this character as the mnemonic /// for this check menu item. To display a `&` without assigning a mnemenonic, use `&&`. pub fn set_text>(&self, text: S) { - self.inner.borrow_mut().set_text(text.as_ref()) + let mut inner = self.inner.borrow_mut(); + inner.set_text(text.as_ref()); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Get whether this check menu item is enabled or not. @@ -164,7 +217,14 @@ impl IconMenuItem { /// Enable or disable this check menu item. pub fn set_enabled(&self, enabled: bool) { - self.inner.borrow_mut().set_enabled(enabled) + let mut inner = self.inner.borrow_mut(); + inner.set_enabled(enabled); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Set this icon menu item accelerator. @@ -185,7 +245,14 @@ impl IconMenuItem { /// Change this menu item icon or remove it. pub fn set_icon(&self, icon: Option) { - self.inner.borrow_mut().set_icon(icon) + let mut inner = self.inner.borrow_mut(); + inner.set_icon(icon); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Change this menu item icon to a native image or remove it. diff --git a/src/items/mod.rs b/src/items/mod.rs index 5dd90e0f..eb1153d7 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -3,12 +3,16 @@ // SPDX-License-Identifier: MIT mod check; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +mod compat; mod icon; mod normal; mod predefined; mod submenu; pub use check::*; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +pub use compat::*; pub use icon::*; pub use normal::*; pub use predefined::*; diff --git a/src/items/normal.rs b/src/items/normal.rs index 581dc524..737d4c62 100644 --- a/src/items/normal.rs +++ b/src/items/normal.rs @@ -1,5 +1,11 @@ use std::{cell::RefCell, mem, rc::Rc}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + use crate::{ accelerator::{Accelerator, KeyAccelerator}, sealed::IsMenuItemBase, @@ -14,6 +20,8 @@ use crate::{ pub struct MenuItem { pub(crate) id: Rc, pub(crate) inner: Rc>, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) compat: Arc>, } impl IsMenuItemBase for MenuItem {} @@ -32,6 +40,19 @@ impl IsMenuItem for MenuItem { } impl MenuItem { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_menu_item( + item: &crate::platform_impl::MenuChild, + ) -> crate::CompatMenuItem { + crate::CompatStandardItem { + id: item.id().0.clone(), + label: super::strip_mnemonic(item.text()), + enabled: item.is_enabled(), + icon: None, + } + .into() + } + /// Create a new menu item. /// /// - `text` could optionally contain an `&` before a character to assign this character as the mnemonic @@ -43,9 +64,14 @@ impl MenuItem { accelerator.map(KeyAccelerator::from), None, ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { id: Rc::new(item.id().clone()), inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -60,14 +86,20 @@ impl MenuItem { accelerator: Option, ) -> Self { let id = id.into(); + let item = crate::platform_impl::MenuChild::new( + text.as_ref(), + enabled, + accelerator.map(KeyAccelerator::from), + Some(id), + ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { - id: Rc::new(id.clone()), - inner: Rc::new(RefCell::new(crate::platform_impl::MenuChild::new( - text.as_ref(), - enabled, - accelerator.map(KeyAccelerator::from), - Some(id), - ))), + id: Rc::new(item.id().clone()), + inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -85,7 +117,14 @@ impl MenuItem { /// an `&` before a character to assign this character as the mnemonic /// for this menu item. To display a `&` without assigning a mnemenonic, use `&&`. pub fn set_text>(&self, text: S) { - self.inner.borrow_mut().set_text(text.as_ref()) + let mut inner = self.inner.borrow_mut(); + inner.set_text(text.as_ref()); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Get whether this menu item is enabled or not. @@ -95,7 +134,14 @@ impl MenuItem { /// Enable or disable this menu item. pub fn set_enabled(&self, enabled: bool) { - self.inner.borrow_mut().set_enabled(enabled) + let mut inner = self.inner.borrow_mut(); + inner.set_enabled(enabled); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Set this menu item accelerator. diff --git a/src/items/predefined.rs b/src/items/predefined.rs index acb02831..61e81fb2 100644 --- a/src/items/predefined.rs +++ b/src/items/predefined.rs @@ -4,6 +4,12 @@ use std::{cell::RefCell, mem, rc::Rc}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + use crate::{ accelerator::{Accelerator, CMD_OR_CTRL}, sealed::IsMenuItemBase, @@ -16,6 +22,8 @@ use keyboard_types::{Code, Modifiers}; pub struct PredefinedMenuItem { pub(crate) id: Rc, pub(crate) inner: Rc>, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) compat: Arc>, } impl IsMenuItemBase for PredefinedMenuItem {} @@ -34,6 +42,24 @@ impl IsMenuItem for PredefinedMenuItem { } impl PredefinedMenuItem { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_menu_item( + item: &crate::platform_impl::MenuChild, + ) -> crate::CompatMenuItem { + let label = super::strip_mnemonic(item.text()); + if label.is_empty() { + crate::CompatMenuItem::Separator + } else { + crate::CompatStandardItem { + id: item.id().0.clone(), + label, + enabled: item.is_enabled(), + icon: None, + } + .into() + } + } + /// Separator menu item pub fn separator() -> PredefinedMenuItem { PredefinedMenuItem::new::<&str>(PredefinedMenuItemType::Separator, None) @@ -176,9 +202,14 @@ impl PredefinedMenuItem { item, text.map(|t| t.as_ref().to_string()), ); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = item.compat_child(); + Self { id: Rc::new(item.id().clone()), inner: Rc::new(RefCell::new(item)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -194,7 +225,14 @@ impl PredefinedMenuItem { /// Set the text for this predefined menu item. pub fn set_text>(&self, text: S) { - self.inner.borrow_mut().set_text(text.as_ref()) + let mut inner = self.inner.borrow_mut(); + inner.set_text(text.as_ref()); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Convert this menu item into its menu ID. @@ -240,6 +278,7 @@ fn test_about_metadata() { #[derive(Debug, Clone, Default)] #[non_exhaustive] +#[allow(dead_code)] #[allow(clippy::large_enum_variant)] pub(crate) enum PredefinedMenuItemType { Separator, diff --git a/src/items/submenu.rs b/src/items/submenu.rs index 55268826..dca40153 100644 --- a/src/items/submenu.rs +++ b/src/items/submenu.rs @@ -4,9 +4,30 @@ use std::{cell::RefCell, mem, rc::Rc}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + +#[cfg(any( + target_os = "windows", + target_os = "macos", + all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ), + feature = "gtk" + ) +))] +use crate::dpi::Position; use crate::{ - dpi::Position, sealed::IsMenuItemBase, util::AddOp, ContextMenu, Icon, IsMenuItem, MenuId, - MenuItemKind, NativeIcon, + sealed::IsMenuItemBase, util::AddOp, ContextMenu, Icon, IsMenuItem, MenuId, MenuItemKind, + NativeIcon, }; /// A menu that can be added to a [`Menu`] or another [`Submenu`]. @@ -16,6 +37,8 @@ use crate::{ pub struct Submenu { pub(crate) id: Rc, pub(crate) inner: Rc>, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) compat: Arc>, } impl IsMenuItemBase for Submenu {} @@ -34,15 +57,32 @@ impl IsMenuItem for Submenu { } impl Submenu { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_menu_item( + item: &crate::platform_impl::MenuChild, + ) -> crate::CompatMenuItem { + crate::CompatSubMenuItem { + label: super::strip_mnemonic(item.text()), + enabled: item.is_enabled(), + submenu: item.compat_items(), + } + .into() + } + /// Create a new submenu. /// /// - `text` could optionally contain an `&` before a character to assign this character as the mnemonic /// for this submenu. To display a `&` without assigning a mnemenonic, use `&&`. pub fn new>(text: S, enabled: bool) -> Self { let submenu = crate::platform_impl::MenuChild::new_submenu(text.as_ref(), enabled, None); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = submenu.compat_child(); + Self { id: Rc::new(submenu.id().clone()), inner: Rc::new(RefCell::new(submenu)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -52,14 +92,16 @@ impl Submenu { /// for this submenu. To display a `&` without assigning a mnemenonic, use `&&`. pub fn with_id, S: AsRef>(id: I, text: S, enabled: bool) -> Self { let id = id.into(); + let submenu = + crate::platform_impl::MenuChild::new_submenu(text.as_ref(), enabled, Some(id)); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = submenu.compat_child(); Self { - id: Rc::new(id.clone()), - inner: Rc::new(RefCell::new(crate::platform_impl::MenuChild::new_submenu( - text.as_ref(), - enabled, - Some(id), - ))), + id: Rc::new(submenu.id().clone()), + inner: Rc::new(RefCell::new(submenu)), + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, } } @@ -93,7 +135,16 @@ impl Submenu { /// Add a menu item to the end of this menu. pub fn append(&self, item: &dyn IsMenuItem) -> crate::Result<()> { - self.inner.borrow_mut().add_menu_item(item, AddOp::Append) + let mut inner = self.inner.borrow_mut(); + inner.add_menu_item(item, AddOp::Append)?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } + + Ok(()) } /// Add menu items to the end of this submenu. It calls [`Submenu::append`] in a loop. @@ -107,9 +158,16 @@ impl Submenu { /// Add a menu item to the beginning of this submenu. pub fn prepend(&self, item: &dyn IsMenuItem) -> crate::Result<()> { - self.inner - .borrow_mut() - .add_menu_item(item, AddOp::Insert(0)) + let mut inner = self.inner.borrow_mut(); + inner.add_menu_item(item, AddOp::Insert(0))?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } + + Ok(()) } /// Add menu items to the beginning of this submenu. @@ -121,9 +179,16 @@ impl Submenu { /// Insert a menu item at the specified `position` in the submenu. pub fn insert(&self, item: &dyn IsMenuItem, position: usize) -> crate::Result<()> { - self.inner - .borrow_mut() - .add_menu_item(item, AddOp::Insert(position)) + let mut inner = self.inner.borrow_mut(); + inner.add_menu_item(item, AddOp::Insert(position))?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } + + Ok(()) } /// Insert menu items at the specified `position` in the submenu. @@ -137,7 +202,16 @@ impl Submenu { /// Remove a menu item from this submenu. pub fn remove(&self, item: &dyn IsMenuItem) -> crate::Result<()> { - self.inner.borrow_mut().remove(item) + let mut inner = self.inner.borrow_mut(); + inner.remove(item)?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } + + Ok(()) } /// Remove the menu item at the specified position from this submenu and returns it. @@ -166,7 +240,14 @@ impl Submenu { /// an `&` before a character to assign this character as the mnemonic /// for this submenu. To display a `&` without assigning a mnemenonic, use `&&`. pub fn set_text>(&self, text: S) { - self.inner.borrow_mut().set_text(text.as_ref()) + let mut inner = self.inner.borrow_mut(); + inner.set_text(text.as_ref()); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Get whether this submenu is enabled or not. @@ -176,7 +257,14 @@ impl Submenu { /// Enable or disable this submenu. pub fn set_enabled(&self, enabled: bool) { - self.inner.borrow_mut().set_enabled(enabled) + let mut inner = self.inner.borrow_mut(); + inner.set_enabled(enabled); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Set this submenu as the Window menu for the application on macOS. @@ -230,7 +318,14 @@ impl Submenu { /// Change this menu item icon or remove it. pub fn set_icon(&self, icon: Option) { - self.inner.borrow_mut().set_icon(icon) + let mut inner = self.inner.borrow_mut(); + inner.set_icon(icon); + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + { + self.compat.store(Arc::new(Self::compat_menu_item(&inner))); + crate::send_menu_update(); + } } /// Change this menu item icon to a native image or remove it. @@ -317,6 +412,11 @@ impl ContextMenu for Submenu { self.inner.borrow().ns_menu() } + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + fn compat_items(&self) -> Vec>> { + self.inner.borrow().compat_items() + } + fn as_submenu(&self) -> Option<&Submenu> { Some(self) } diff --git a/src/lib.rs b/src/lib.rs index c11e7471..cd319282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -186,6 +186,12 @@ use crossbeam_channel::{unbounded, Receiver, Sender}; use once_cell::sync::{Lazy, OnceCell}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + pub mod about_metadata; pub mod accelerator; mod builders; @@ -431,6 +437,10 @@ pub trait ContextMenu { ))] fn gtk_context_menu(&self) -> gtk::Menu; + /// Get the menu items in a platform-neutral representation suitable for Linux KSNI trays. + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + fn compat_items(&self) -> Vec>>; + /// Shows this menu as a context menu for the specified `NSView`. /// /// - `position` is relative to the window top-left corner, if `None`, the cursor position is used. @@ -520,7 +530,11 @@ impl MenuEvent { } } - pub(crate) fn send(event: MenuEvent) { + /// Emit a menu event. + /// + /// This is intended for platform integrations that present `muda` menus + /// outside of `muda` itself. + pub fn send(event: MenuEvent) { if let Some(handler) = MENU_EVENT_HANDLER.get_or_init(|| None) { handler(event); } else { @@ -528,3 +542,16 @@ impl MenuEvent { } } } + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +static MENU_UPDATE_CHANNEL: Lazy<(Sender<()>, Receiver<()>)> = Lazy::new(unbounded); + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +pub fn recv_menu_update() -> std::result::Result<(), crossbeam_channel::RecvError> { + MENU_UPDATE_CHANNEL.1.recv() +} + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +pub fn send_menu_update() { + let _ = MENU_UPDATE_CHANNEL.0.send(()); +} diff --git a/src/menu.rs b/src/menu.rs index cdf5604b..327ff5c9 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -4,7 +4,28 @@ use std::{cell::RefCell, rc::Rc}; -use crate::{dpi::Position, util::AddOp, ContextMenu, IsMenuItem, MenuId, MenuItemKind}; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + +#[cfg(any( + target_os = "windows", + target_os = "macos", + all( + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ), + feature = "gtk" + ) +))] +use crate::dpi::Position; +use crate::{util::AddOp, ContextMenu, IsMenuItem, MenuId, MenuItemKind}; /// A root menu that can be added to a Window on Windows and Linux /// and used as the app global menu on macOS. @@ -69,7 +90,12 @@ impl Menu { /// /// [`Submenu`]: crate::Submenu pub fn append(&self, item: &dyn IsMenuItem) -> crate::Result<()> { - self.inner.borrow_mut().add_menu_item(item, AddOp::Append) + self.inner.borrow_mut().add_menu_item(item, AddOp::Append)?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + crate::send_menu_update(); + + Ok(()) } /// Add menu items to the end of this menu. It calls [`Menu::append`] in a loop internally. @@ -97,7 +123,12 @@ impl Menu { pub fn prepend(&self, item: &dyn IsMenuItem) -> crate::Result<()> { self.inner .borrow_mut() - .add_menu_item(item, AddOp::Insert(0)) + .add_menu_item(item, AddOp::Insert(0))?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + crate::send_menu_update(); + + Ok(()) } /// Add menu items to the beginning of this menu. It calls [`Menu::insert_items`] with position of `0` internally. @@ -121,7 +152,12 @@ impl Menu { pub fn insert(&self, item: &dyn IsMenuItem, position: usize) -> crate::Result<()> { self.inner .borrow_mut() - .add_menu_item(item, AddOp::Insert(position)) + .add_menu_item(item, AddOp::Insert(position))?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + crate::send_menu_update(); + + Ok(()) } /// Insert menu items at the specified `position` in the menu. @@ -141,7 +177,12 @@ impl Menu { /// Remove a menu item from this menu. pub fn remove(&self, item: &dyn IsMenuItem) -> crate::Result<()> { - self.inner.borrow_mut().remove(item) + self.inner.borrow_mut().remove(item)?; + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + crate::send_menu_update(); + + Ok(()) } /// Remove the menu item at the specified position from this menu and returns it. @@ -495,6 +536,11 @@ impl ContextMenu for Menu { self.inner.borrow().ns_menu() } + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + fn compat_items(&self) -> Vec>> { + self.inner.borrow().compat_items() + } + fn as_menu(&self) -> Option<&Menu> { Some(self) } diff --git a/src/platform_impl/gtk/mod.rs b/src/platform_impl/gtk/mod.rs index 91248f6c..190060b6 100644 --- a/src/platform_impl/gtk/mod.rs +++ b/src/platform_impl/gtk/mod.rs @@ -22,11 +22,22 @@ use std::{ cell::RefCell, collections::{hash_map::Entry, HashMap}, rc::Rc, - sync::atomic::{AtomicBool, Ordering}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, }; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + static COUNTER: Counter = Counter::new(); +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +fn compat_placeholder() -> Arc> { + Arc::new(ArcSwap::from_pointee(crate::CompatMenuItem::Separator)) +} + macro_rules! is_item_supported { ($item:tt) => {{ let child = $item.child(); @@ -246,6 +257,14 @@ impl Menu { .collect() } + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub fn compat_items(&self) -> Vec>> { + self.items() + .into_iter() + .map(|item| item.compat_child()) + .collect() + } + pub fn init_for_gtk_window( &mut self, window: &W, @@ -419,6 +438,8 @@ pub struct MenuChild { gtk_menus: Option>>, gtk_menu: Option<(u32, Option)>, // dedicated menu for tray or context menus accel_group: Option, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: Arc>, } impl Drop for MenuChild { @@ -508,7 +529,10 @@ impl MenuChild { icon: None, is_syncing_checked_state: None, predefined_item_type: None, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: compat_placeholder(), } + .with_compat() } pub fn new_submenu(text: &str, enabled: bool, id: Option) -> Self { @@ -528,7 +552,10 @@ impl MenuChild { predefined_item_type: None, accelerator: None, checked: None, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: compat_placeholder(), } + .with_compat() } pub(crate) fn new_predefined(item_type: PredefinedMenuItemType, text: Option) -> Self { @@ -548,7 +575,10 @@ impl MenuChild { gtk_menus: None, icon: None, is_syncing_checked_state: None, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: compat_placeholder(), } + .with_compat() } pub fn new_check( @@ -574,7 +604,10 @@ impl MenuChild { gtk_menus: None, icon: None, predefined_item_type: None, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: compat_placeholder(), } + .with_compat() } pub fn new_icon( @@ -600,7 +633,10 @@ impl MenuChild { gtk_menus: None, is_syncing_checked_state: None, predefined_item_type: None, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: compat_placeholder(), } + .with_compat() } pub fn new_native_icon( @@ -626,12 +662,65 @@ impl MenuChild { icon: None, is_syncing_checked_state: None, predefined_item_type: None, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat: compat_placeholder(), } + .with_compat() } } /// Shared methods impl MenuChild { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + fn with_compat(mut self) -> Self { + self.refresh_compat(); + self + } + + #[cfg(not(all(feature = "linux-ksni", target_os = "linux")))] + fn with_compat(self) -> Self { + self + } + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_child(&self) -> Arc> { + self.compat.clone() + } + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn refresh_compat(&mut self) { + self.compat.store(Arc::new(self.compat_menu_item())); + } + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + fn compat_menu_item(&self) -> crate::CompatMenuItem { + match self.item_type { + MenuItemType::Submenu => crate::CompatSubMenuItem { + label: crate::strip_mnemonic(self.text()), + enabled: self.is_enabled(), + submenu: self.compat_items(), + } + .into(), + MenuItemType::Check => crate::CompatCheckmarkItem { + id: self.id.0.clone(), + label: crate::strip_mnemonic(self.text()), + enabled: self.is_enabled(), + checked: self.is_checked(), + } + .into(), + MenuItemType::Predefined if self.is_separator() => crate::CompatMenuItem::Separator, + MenuItemType::MenuItem | MenuItemType::Predefined | MenuItemType::Icon => { + crate::CompatStandardItem { + id: self.id.0.clone(), + label: crate::strip_mnemonic(self.text()), + enabled: self.is_enabled(), + icon: None, + } + .into() + } + } + } + pub(crate) fn item_type(&self) -> MenuItemType { self.item_type } @@ -800,6 +889,14 @@ impl MenuChild { /// Submenu methods impl MenuChild { + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub fn is_separator(&self) -> bool { + matches!( + self.predefined_item_type, + Some(PredefinedMenuItemType::Separator) + ) + } + pub fn add_menu_item(&mut self, item: &dyn crate::IsMenuItem, op: AddOp) -> crate::Result<()> { if is_item_supported!(item) { for menus in self.gtk_menus.as_ref().unwrap().values() { @@ -966,6 +1063,14 @@ impl MenuChild { .collect() } + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub fn compat_items(&self) -> Vec>> { + self.items() + .into_iter() + .map(|item| item.compat_child()) + .collect() + } + pub fn show_context_menu_for_gtk_window( &mut self, widget: &impl IsA, diff --git a/src/platform_impl/linux/mod.rs b/src/platform_impl/linux/mod.rs new file mode 100644 index 00000000..f0b07bfe --- /dev/null +++ b/src/platform_impl/linux/mod.rs @@ -0,0 +1,382 @@ +// Copyright 2022-2022 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +use arc_swap::ArcSwap; + +use crate::{ + accelerator::KeyAccelerator, + icon::{BadIcon, Icon, NativeIcon}, + items::PredefinedMenuItemType, + util::{AddOp, Counter}, + IsMenuItem, MenuId, MenuItemKind, MenuItemType, +}; + +fn compat_placeholder() -> Arc> { + Arc::new(ArcSwap::from_pointee(crate::CompatMenuItem::Separator)) +} + +static COUNTER: Counter = Counter::new(); + +#[derive(Debug, Clone)] +pub struct PlatformIcon { + rgba: Vec, + width: u32, + height: u32, +} + +impl PlatformIcon { + pub fn from_rgba(rgba: Vec, width: u32, height: u32) -> Result { + Ok(Self { + rgba, + width, + height, + }) + } + + pub(crate) fn to_png(&self) -> Vec { + let mut png = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + + let mut writer = encoder + .write_header() + .expect("writing an in-memory PNG header should not fail"); + writer + .write_image_data(&self.rgba) + .expect("writing in-memory PNG data should not fail"); + } + png + } +} + +pub struct Menu { + id: MenuId, + children: Vec>>, +} + +impl Menu { + pub fn new(id: Option) -> Self { + Self { + id: id.unwrap_or_else(|| MenuId(COUNTER.next().to_string())), + children: Vec::new(), + } + } + + pub fn id(&self) -> &MenuId { + &self.id + } + + pub fn add_menu_item(&mut self, item: &dyn IsMenuItem, op: AddOp) -> crate::Result<()> { + match op { + AddOp::Append => self.children.push(item.child()), + AddOp::Insert(position) => self.children.insert(position, item.child()), + } + + Ok(()) + } + + pub fn remove(&mut self, item: &dyn IsMenuItem) -> crate::Result<()> { + let index = self + .children + .iter() + .position(|e| e.borrow().id == item.id()) + .ok_or(crate::Error::NotAChildOfThisMenu)?; + self.children.remove(index); + Ok(()) + } + + pub fn items(&self) -> Vec { + self.children + .iter() + .map(|c| c.borrow().kind(c.clone())) + .collect() + } + + pub fn compat_items(&self) -> Vec>> { + self.items() + .into_iter() + .map(|item| item.compat_child()) + .collect() + } +} + +/// A generic child in a menu +#[derive(Debug, Default)] +pub struct MenuChild { + item_type: MenuItemType, + text: String, + enabled: bool, + id: MenuId, + accelerator: Option, + predefined_item_type: Option, + checked: bool, + icon: Option, + children: Option>>>, + compat: Arc>, +} + +impl MenuChild { + pub fn new( + text: &str, + enabled: bool, + key_accelerator: Option, + id: Option, + ) -> Self { + Self { + item_type: MenuItemType::MenuItem, + text: text.to_string(), + enabled, + id: id.unwrap_or_else(|| MenuId(COUNTER.next().to_string())), + accelerator: key_accelerator, + predefined_item_type: None, + checked: false, + icon: None, + children: None, + compat: compat_placeholder(), + } + .with_compat() + } + + pub fn new_submenu(text: &str, enabled: bool, id: Option) -> Self { + Self { + item_type: MenuItemType::Submenu, + text: text.to_string(), + enabled, + id: id.unwrap_or_else(|| MenuId(COUNTER.next().to_string())), + accelerator: None, + predefined_item_type: None, + checked: false, + icon: None, + children: Some(Vec::new()), + compat: compat_placeholder(), + } + .with_compat() + } + + pub(crate) fn new_predefined(item_type: PredefinedMenuItemType, text: Option) -> Self { + Self { + item_type: MenuItemType::Predefined, + text: text.unwrap_or_else(|| item_type.text().to_string()), + enabled: true, + id: MenuId(COUNTER.next().to_string()), + accelerator: item_type.accelerator().map(Into::into), + predefined_item_type: Some(item_type), + checked: false, + icon: None, + children: None, + compat: compat_placeholder(), + } + .with_compat() + } + + pub fn new_check( + text: &str, + enabled: bool, + checked: bool, + key_accelerator: Option, + id: Option, + ) -> Self { + Self { + item_type: MenuItemType::Check, + text: text.to_string(), + enabled, + id: id.unwrap_or_else(|| MenuId(COUNTER.next().to_string())), + accelerator: key_accelerator, + predefined_item_type: None, + checked, + icon: None, + children: None, + compat: compat_placeholder(), + } + .with_compat() + } + + pub fn new_icon( + text: &str, + enabled: bool, + icon: Option, + key_accelerator: Option, + id: Option, + ) -> Self { + Self { + item_type: MenuItemType::Icon, + text: text.to_string(), + enabled, + id: id.unwrap_or_else(|| MenuId(COUNTER.next().to_string())), + accelerator: key_accelerator, + predefined_item_type: None, + checked: false, + icon, + children: None, + compat: compat_placeholder(), + } + .with_compat() + } + + pub fn new_native_icon( + text: &str, + enabled: bool, + _native_icon: Option, + key_accelerator: Option, + id: Option, + ) -> Self { + Self { + item_type: MenuItemType::Icon, + text: text.to_string(), + enabled, + id: id.unwrap_or_else(|| MenuId(COUNTER.next().to_string())), + accelerator: key_accelerator, + predefined_item_type: None, + checked: false, + icon: None, + children: None, + compat: compat_placeholder(), + } + .with_compat() + } +} + +impl MenuChild { + fn with_compat(mut self) -> Self { + self.refresh_compat(); + self + } + + pub(crate) fn compat_child(&self) -> Arc> { + self.compat.clone() + } + + pub(crate) fn refresh_compat(&mut self) { + self.compat.store(Arc::new(self.compat_menu_item())); + } + + fn compat_menu_item(&self) -> crate::CompatMenuItem { + match self.item_type { + MenuItemType::Submenu => crate::CompatSubMenuItem { + label: crate::strip_mnemonic(self.text()), + enabled: self.is_enabled(), + submenu: self.compat_items(), + } + .into(), + MenuItemType::Check => crate::CompatCheckmarkItem { + id: self.id.0.clone(), + label: crate::strip_mnemonic(self.text()), + enabled: self.is_enabled(), + checked: self.is_checked(), + } + .into(), + MenuItemType::Predefined if self.is_separator() => crate::CompatMenuItem::Separator, + MenuItemType::MenuItem | MenuItemType::Predefined | MenuItemType::Icon => { + crate::CompatStandardItem { + id: self.id.0.clone(), + label: crate::strip_mnemonic(self.text()), + enabled: self.is_enabled(), + icon: self.icon_png(), + } + .into() + } + } + } + + pub(crate) fn item_type(&self) -> MenuItemType { + self.item_type + } + + pub fn id(&self) -> &MenuId { + &self.id + } + + pub fn text(&self) -> String { + self.text.clone() + } + + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + pub fn set_key_accelerator( + &mut self, + accelerator: Option, + ) -> crate::Result<()> { + self.accelerator = accelerator; + Ok(()) + } +} + +impl MenuChild { + pub fn is_checked(&self) -> bool { + self.checked + } + + pub fn set_checked(&mut self, checked: bool) { + self.checked = checked; + } +} + +impl MenuChild { + pub fn icon_png(&self) -> Option> { + self.icon.as_ref().map(|icon| icon.inner.to_png()) + } + + pub fn set_icon(&mut self, icon: Option) { + self.icon = icon; + } +} + +impl MenuChild { + pub fn is_separator(&self) -> bool { + matches!( + self.predefined_item_type, + Some(PredefinedMenuItemType::Separator) + ) + } + + pub fn add_menu_item(&mut self, item: &dyn IsMenuItem, op: AddOp) -> crate::Result<()> { + let children = self.children.as_mut().unwrap(); + match op { + AddOp::Append => children.push(item.child()), + AddOp::Insert(position) => children.insert(position, item.child()), + } + + Ok(()) + } + + pub fn remove(&mut self, item: &dyn IsMenuItem) -> crate::Result<()> { + let children = self.children.as_mut().unwrap(); + let index = children + .iter() + .position(|e| e.borrow().id == item.id()) + .ok_or(crate::Error::NotAChildOfThisMenu)?; + children.remove(index); + Ok(()) + } + + pub fn items(&self) -> Vec { + self.children + .as_ref() + .unwrap() + .iter() + .map(|c| c.borrow().kind(c.clone())) + .collect() + } + + pub fn compat_items(&self) -> Vec>> { + self.items() + .into_iter() + .map(|item| item.compat_child()) + .collect() + } +} diff --git a/src/platform_impl/mod.rs b/src/platform_impl/mod.rs index 2288fcbc..47473e12 100644 --- a/src/platform_impl/mod.rs +++ b/src/platform_impl/mod.rs @@ -17,6 +17,9 @@ mod platform; ))] #[path = "gtk/mod.rs"] mod platform; +#[cfg(all(target_os = "linux", feature = "linux-ksni", not(feature = "gtk")))] +#[path = "linux/mod.rs"] +mod platform; #[cfg(target_os = "macos")] #[path = "macos/mod.rs"] mod platform; @@ -26,6 +29,12 @@ use std::{ rc::Rc, }; +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use std::sync::Arc; + +#[cfg(all(feature = "linux-ksni", target_os = "linux"))] +use arc_swap::ArcSwap; + use crate::{items::*, IsMenuItem, MenuItemKind, MenuItemType}; pub(crate) use self::platform::*; @@ -48,37 +57,57 @@ impl MenuChild { match self.item_type() { MenuItemType::Submenu => { let id = c.borrow().id().clone(); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = c.borrow().compat_child(); MenuItemKind::Submenu(Submenu { id: Rc::new(id), inner: c, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, }) } MenuItemType::MenuItem => { let id = c.borrow().id().clone(); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = c.borrow().compat_child(); MenuItemKind::MenuItem(MenuItem { id: Rc::new(id), inner: c, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, }) } MenuItemType::Predefined => { let id = c.borrow().id().clone(); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = c.borrow().compat_child(); MenuItemKind::Predefined(PredefinedMenuItem { id: Rc::new(id), inner: c, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, }) } MenuItemType::Check => { let id = c.borrow().id().clone(); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = c.borrow().compat_child(); MenuItemKind::Check(CheckMenuItem { id: Rc::new(id), inner: c, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, }) } MenuItemType::Icon => { let id = c.borrow().id().clone(); + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + let compat = c.borrow().compat_child(); MenuItemKind::Icon(IconMenuItem { id: Rc::new(id), inner: c, + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + compat, }) } } @@ -116,4 +145,15 @@ impl MenuItemKind { MenuItemKind::Icon(i) => i.inner.borrow_mut(), } } + + #[cfg(all(feature = "linux-ksni", target_os = "linux"))] + pub(crate) fn compat_child(&self) -> Arc> { + match self { + MenuItemKind::MenuItem(i) => i.compat.clone(), + MenuItemKind::Submenu(i) => i.compat.clone(), + MenuItemKind::Predefined(i) => i.compat.clone(), + MenuItemKind::Check(i) => i.compat.clone(), + MenuItemKind::Icon(i) => i.compat.clone(), + } + } } From 76e8f3f3638439ec2b223cbd7c11771332571b0a Mon Sep 17 00:00:00 2001 From: Anthony Smith Date: Wed, 8 Jul 2026 08:08:44 -0400 Subject: [PATCH 2/3] Add linux-ksni change file --- .changes/linux-ksni.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/linux-ksni.md diff --git a/.changes/linux-ksni.md b/.changes/linux-ksni.md new file mode 100644 index 00000000..e2d7b72c --- /dev/null +++ b/.changes/linux-ksni.md @@ -0,0 +1,5 @@ +--- +"muda": minor +--- + +Add a `linux-ksni` feature with a GTK-free compatibility menu model for Linux StatusNotifierItem tray integrations. From 2e8d21d83bf19df078240ad3747f514f357b8491 Mon Sep 17 00:00:00 2001 From: Anthony Smith Date: Wed, 8 Jul 2026 08:30:11 -0400 Subject: [PATCH 3/3] Default linux-ksni context menu items --- src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index cd319282..c968655c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -439,7 +439,9 @@ pub trait ContextMenu { /// Get the menu items in a platform-neutral representation suitable for Linux KSNI trays. #[cfg(all(feature = "linux-ksni", target_os = "linux"))] - fn compat_items(&self) -> Vec>>; + fn compat_items(&self) -> Vec>> { + Vec::new() + } /// Shows this menu as a context menu for the specified `NSView`. ///