use std::cell::RefCell;
use std::sync::{Arc, Mutex, OnceLock};
use crossbeam_channel::{unbounded, Receiver, Sender};
use crate::menu::Icon as MuriIcon;
pub use super::muda::{BadIcon, Icon, Menu};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TrayIconId(pub String);
impl<T: Into<String>> From<T> for TrayIconId {
fn from(value: T) -> Self {
TrayIconId(value.into())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PhysicalPosition {
pub x: f64,
pub y: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Rect {
pub position: PhysicalPosition,
pub size: (f64, f64),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButton {
Left,
Right,
Middle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButtonState {
Up,
Down,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum TrayIconEvent {
Click {
id: TrayIconId,
position: PhysicalPosition,
rect: Rect,
button: MouseButton,
button_state: MouseButtonState,
},
DoubleClick {
id: TrayIconId,
position: PhysicalPosition,
rect: Rect,
button: MouseButton,
},
Enter {
id: TrayIconId,
position: PhysicalPosition,
rect: Rect,
},
Move {
id: TrayIconId,
position: PhysicalPosition,
rect: Rect,
},
Leave {
id: TrayIconId,
position: PhysicalPosition,
rect: Rect,
},
}
pub type TrayIconEventReceiver = Receiver<TrayIconEvent>;
type TrayEventHandler = Arc<dyn Fn(TrayIconEvent) + Send + Sync + 'static>;
struct TrayChannel {
sender: Sender<TrayIconEvent>,
receiver: TrayIconEventReceiver,
}
fn tray_channel() -> &'static TrayChannel {
static CHANNEL: OnceLock<TrayChannel> = OnceLock::new();
CHANNEL.get_or_init(|| {
let (sender, receiver) = unbounded();
TrayChannel { sender, receiver }
})
}
fn tray_handler_slot() -> &'static Mutex<Option<TrayEventHandler>> {
static HANDLER: OnceLock<Mutex<Option<TrayEventHandler>>> = OnceLock::new();
HANDLER.get_or_init(|| Mutex::new(None))
}
impl TrayIconEvent {
pub fn receiver() -> &'static TrayIconEventReceiver {
&tray_channel().receiver
}
pub fn set_event_handler<F>(handler: Option<F>)
where
F: Fn(TrayIconEvent) + Send + Sync + 'static,
{
let handler: Option<TrayEventHandler> = handler.map(|f| Arc::new(f) as TrayEventHandler);
if let Ok(mut slot) = tray_handler_slot().lock() {
*slot = handler;
}
}
#[allow(dead_code)] pub(crate) fn emit(event: TrayIconEvent) {
let _ = tray_channel().sender.send(event.clone());
let handler = tray_handler_slot()
.lock()
.ok()
.and_then(|slot| slot.clone());
if let Some(handler) = handler {
handler(event);
}
}
}
#[derive(Default)]
pub struct TrayIconBuilder {
id: Option<TrayIconId>,
icon: Option<Icon>,
tooltip: Option<String>,
title: Option<String>,
menu: Option<Menu>,
}
impl TrayIconBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn with_id(mut self, id: impl Into<TrayIconId>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[allow(clippy::boxed_local)]
pub fn with_menu(mut self, menu: Box<Menu>) -> Self {
self.menu = Some(*menu);
self
}
pub fn build(self) -> super::muda::Result<TrayIcon> {
let muri_menu = if let Some(menu) = &self.menu {
menu.build_custom_surface().menu().clone()
} else {
crate::Menu::new()
};
let mut tray = crate::Tray::new(MuriIcon::Symbol("tray")).menu(muri_menu);
if let Some(tooltip) = &self.tooltip {
tray = tray.tooltip(tooltip.clone());
}
let id = self.id.unwrap_or_else(|| TrayIconId(next_tray_id()));
Ok(TrayIcon {
id,
icon: RefCell::new(self.icon),
tooltip: RefCell::new(self.tooltip),
title: RefCell::new(self.title),
_tray: RefCell::new(tray),
})
}
}
fn next_tray_id() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(1);
COUNTER.fetch_add(1, Ordering::Relaxed).to_string()
}
pub struct TrayIcon {
id: TrayIconId,
icon: RefCell<Option<Icon>>,
tooltip: RefCell<Option<String>>,
title: RefCell<Option<String>>,
_tray: RefCell<crate::Tray>,
}
impl TrayIcon {
pub fn id(&self) -> &TrayIconId {
&self.id
}
pub fn set_icon(&self, icon: Option<Icon>) -> super::muda::Result<()> {
*self.icon.borrow_mut() = icon;
Ok(())
}
pub fn set_tooltip(&self, tooltip: Option<impl Into<String>>) -> super::muda::Result<()> {
*self.tooltip.borrow_mut() = tooltip.map(Into::into);
Ok(())
}
pub fn set_title(&self, title: Option<impl Into<String>>) {
*self.title.borrow_mut() = title.map(Into::into);
}
pub fn set_visible(&self, _visible: bool) -> super::muda::Result<()> {
Ok(())
}
pub fn set_menu(&self, menu: Option<Box<Menu>>) {
let muri_menu = match &menu {
Some(menu) => menu.build_custom_surface().menu().clone(),
None => crate::Menu::new(),
};
self._tray.borrow_mut().set_menu(muri_menu);
}
pub fn rect(&self) -> Option<Rect> {
None
}
}
#[cfg(test)]
mod tests {
use super::super::muda::{MenuItem, SurfaceMode};
use super::*;
#[test]
fn with_menu_routes_to_a_custom_surface() {
let menu = Menu::new();
menu.append(&MenuItem::with_id("open", "Open", true, None))
.unwrap();
let probe = menu.clone();
let tray = TrayIconBuilder::new()
.with_tooltip("MyApp")
.with_menu(Box::new(menu))
.build()
.expect("tray builds without a loop");
assert_eq!(
probe.mode(),
SurfaceMode::Custom,
"TrayIconBuilder routes its menu to a muri custom surface"
);
assert!(tray.id().0.parse::<u32>().is_ok());
}
#[test]
fn tray_event_channel_round_trips() {
let _guard = crate::event::test_lock();
let rx = TrayIconEvent::receiver();
while rx.try_recv().is_ok() {}
let ev = TrayIconEvent::Enter {
id: TrayIconId("t1".into()),
position: PhysicalPosition::default(),
rect: Rect::default(),
};
TrayIconEvent::emit(ev);
let got = rx.try_recv().expect("an emitted tray event is received");
assert!(matches!(got, TrayIconEvent::Enter { .. }));
}
#[test]
fn tray_reentrant_set_event_handler_from_handler_does_not_deadlock() {
let _guard = crate::event::test_lock();
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let hits = Arc::new(AtomicUsize::new(0));
let hits2 = Arc::clone(&hits);
TrayIconEvent::set_event_handler(Some(move |ev: TrayIconEvent| {
if let TrayIconEvent::Enter { id, .. } = &ev {
if id.0 == "tray_reentrant_probe" {
hits2.fetch_add(1, Ordering::SeqCst);
TrayIconEvent::set_event_handler(None::<fn(TrayIconEvent)>);
}
}
}));
TrayIconEvent::emit(TrayIconEvent::Enter {
id: TrayIconId("tray_reentrant_probe".into()),
position: PhysicalPosition::default(),
rect: Rect::default(),
});
assert_eq!(hits.load(Ordering::SeqCst), 1);
TrayIconEvent::set_event_handler(None::<fn(TrayIconEvent)>);
let rx = TrayIconEvent::receiver();
while rx.try_recv().is_ok() {}
}
#[test]
fn set_icon_records_the_new_icon_instead_of_discarding_it() {
let tray = TrayIconBuilder::new()
.build()
.expect("tray builds without a loop");
assert!(tray.icon.borrow().is_none(), "no icon configured at build");
let icon = Icon::from_rgba(vec![10, 20, 30, 40], 1, 1).expect("valid RGBA");
tray.set_icon(Some(icon)).expect("set_icon succeeds");
{
let guard = tray.icon.borrow();
let stored = guard.as_ref().expect("icon was recorded");
assert_eq!(stored.width, 1);
assert_eq!(stored.height, 1);
assert_eq!(stored.rgba, vec![10, 20, 30, 40]);
}
tray.set_icon(None).expect("clearing succeeds");
assert!(
tray.icon.borrow().is_none(),
"set_icon(None) clears the icon"
);
}
}