#[derive(Clone, Debug)]
pub enum TrayMenuItem {
Action {
label: String,
shortcut: Option<String>,
},
Separator,
SubMenu {
label: String,
children: Vec<TrayMenuItem>,
},
}
impl TrayMenuItem {
pub fn action(label: impl Into<String>, _action: impl Fn() + Send + Sync + 'static) -> Self {
TrayMenuItem::Action {
label: label.into(),
shortcut: None,
}
}
pub fn action_with_shortcut(
label: impl Into<String>,
shortcut: impl Into<String>,
_action: impl Fn() + Send + Sync + 'static,
) -> Self {
TrayMenuItem::Action {
label: label.into(),
shortcut: Some(shortcut.into()),
}
}
pub fn separator() -> Self {
TrayMenuItem::Separator
}
pub fn sub_menu(label: impl Into<String>, children: Vec<TrayMenuItem>) -> Self {
TrayMenuItem::SubMenu {
label: label.into(),
children,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct TrayConfig {
pub icon_path: Option<String>,
pub icon_bytes: Option<Vec<u8>>,
pub tooltip: Option<String>,
pub menu_items: Vec<TrayMenuItem>,
}
impl TrayConfig {
pub fn new() -> Self {
Self::default()
}
pub fn tooltip(mut self, tip: impl Into<String>) -> Self {
self.tooltip = Some(tip.into());
self
}
pub fn icon_path(mut self, path: impl Into<String>) -> Self {
self.icon_path = Some(path.into());
self
}
pub fn icon_bytes(mut self, bytes: Vec<u8>) -> Self {
self.icon_bytes = Some(bytes);
self
}
pub fn menu_item(mut self, item: TrayMenuItem) -> Self {
self.menu_items.push(item);
self
}
pub fn has_menu(&self) -> bool {
!self.menu_items.is_empty()
}
}
pub struct TrayHandle {
#[cfg(feature = "tray")]
_inner: TrayHandleInner,
#[cfg(not(feature = "tray"))]
_marker: std::marker::PhantomData<()>,
}
#[cfg(feature = "tray")]
struct TrayHandleInner {
_tray: tray_icon::TrayIcon,
_menu: Option<tray_icon::menu::Menu>,
}
impl std::fmt::Debug for TrayHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TrayHandle").finish()
}
}
impl TrayHandle {
#[allow(unused_variables)]
pub fn mount(config: TrayConfig) -> Result<Self, String> {
#[cfg(feature = "tray")]
{
use tray_icon::{menu::Menu, TrayIconBuilder};
let menu_opt: Option<Menu> = if config.has_menu() {
let menu = Menu::new();
for item in &config.menu_items {
match item {
TrayMenuItem::Action { label, .. } => {
let mi = tray_icon::menu::MenuItem::new(label, true, None);
menu.append(&mi)
.map_err(|e| format!("tray menu append failed: {e}"))?;
}
TrayMenuItem::Separator => {
menu.append(&tray_icon::menu::PredefinedMenuItem::separator())
.map_err(|e| format!("tray separator append failed: {e}"))?;
}
TrayMenuItem::SubMenu { label, children } => {
let sub = Menu::new();
for child in children {
if let TrayMenuItem::Action {
label: child_label, ..
} = child
{
let mi =
tray_icon::menu::MenuItem::new(child_label, true, None);
sub.append(&mi)
.map_err(|e| format!("submenu append failed: {e}"))?;
}
}
let submenu = tray_icon::menu::Submenu::with_items(label, true, &[])
.map_err(|e| format!("tray submenu create failed: {e}"))?;
menu.append(&submenu)
.map_err(|e| format!("submenu attach failed: {e}"))?;
}
}
}
Some(menu)
} else {
None
};
let icon = if let Some(bytes) = &config.icon_bytes {
tray_icon::Icon::from_rgba(bytes.clone(), 1, 1)
.map_err(|e| format!("tray icon from rgba failed: {e}"))
.or_else(|_| {
tray_icon::Icon::from_rgba(vec![0u8; 4], 1, 1)
.map_err(|e| format!("tray fallback icon failed: {e}"))
})?
} else {
tray_icon::Icon::from_rgba(vec![0u8; 4], 1, 1)
.map_err(|e| format!("tray placeholder icon failed: {e}"))?
};
let mut builder = TrayIconBuilder::new().with_icon(icon);
if let Some(tip) = &config.tooltip {
builder = builder.with_tooltip(tip);
}
if let Some(ref menu) = menu_opt {
builder = builder.with_menu(Box::new(menu.clone()));
}
let tray = builder
.build()
.map_err(|e| format!("tray icon build failed: {e}"))?;
Ok(TrayHandle {
_inner: TrayHandleInner {
_tray: tray,
_menu: menu_opt,
},
})
}
#[cfg(not(feature = "tray"))]
{
Ok(TrayHandle {
_marker: std::marker::PhantomData,
})
}
}
#[allow(unused_variables)]
pub fn set_tooltip(&self, tip: &str) -> Result<(), String> {
#[cfg(feature = "tray")]
{
self._inner
._tray
.set_tooltip(Some(tip))
.map_err(|e| format!("set_tooltip failed: {e}"))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tray_config_default_is_empty() {
let c = TrayConfig::new();
assert!(!c.has_menu());
assert!(c.tooltip.is_none());
assert!(c.icon_path.is_none());
assert!(c.icon_bytes.is_none());
}
#[test]
fn tray_config_builder_chain() {
let c = TrayConfig::new()
.tooltip("My App")
.icon_path("/usr/share/icons/app.png")
.menu_item(TrayMenuItem::action("Show", || {}))
.menu_item(TrayMenuItem::separator())
.menu_item(TrayMenuItem::action("Quit", || {}));
assert_eq!(c.tooltip.as_deref(), Some("My App"));
assert_eq!(c.icon_path.as_deref(), Some("/usr/share/icons/app.png"));
assert_eq!(c.menu_items.len(), 3);
assert!(c.has_menu());
}
#[test]
fn tray_menu_item_separator_variant() {
let sep = TrayMenuItem::separator();
assert!(matches!(sep, TrayMenuItem::Separator));
}
#[test]
fn tray_menu_item_action_stores_label() {
let item = TrayMenuItem::action("Open", || {});
match item {
TrayMenuItem::Action { label, shortcut } => {
assert_eq!(label, "Open");
assert!(shortcut.is_none());
}
_ => panic!("expected Action"),
}
}
#[test]
fn tray_menu_item_action_with_shortcut() {
let item = TrayMenuItem::action_with_shortcut("Save", "Ctrl+S", || {});
match item {
TrayMenuItem::Action { shortcut, .. } => {
assert_eq!(shortcut.as_deref(), Some("Ctrl+S"));
}
_ => panic!("expected Action"),
}
}
#[test]
fn tray_config_icon_bytes() {
let c = TrayConfig::new().icon_bytes(vec![0u8; 64]);
assert!(c.icon_bytes.is_some());
}
#[test]
#[cfg_attr(feature = "tray", ignore = "tray-icon requires a live display server")]
fn tray_handle_mount_no_tray_feature_is_ok() {
let result = TrayHandle::mount(TrayConfig::new());
assert!(result.is_ok());
}
}