use std::cell::RefCell;
use std::sync::{Arc, Mutex, OnceLock};
use crossbeam_channel::{unbounded, Receiver, Sender};
use crate::menu::Icon as MuriIcon;
use crate::{MenuOptions, ThemeSource};
pub use super::muda::{BadIcon, Icon, Menu};
pub use super::muda as 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),
}
impl From<crate::LogicalRect> for Rect {
fn from(r: crate::LogicalRect) -> Self {
Rect {
position: PhysicalPosition {
x: r.origin.x as f64,
y: r.origin.y as f64,
},
size: (r.size.width as f64, r.size.height as 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>,
options: Option<MenuOptions>,
}
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 with_options(mut self, options: MenuOptions) -> Self {
self.options = Some(options);
self
}
pub fn with_theme(mut self, theme: ThemeSource) -> Self {
let options = self.options.take().unwrap_or_default();
self.options = Some(options.theme(theme));
self
}
fn configured_tray(&self) -> crate::Tray {
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(icon_to_muri(&self.icon)).menu(muri_menu);
if let Some(tooltip) = &self.tooltip {
tray = tray.tooltip(tooltip.clone());
}
if let Some(title) = &self.title {
tray = tray.title(title.clone());
}
if let Some(options) = self.options.clone() {
tray = tray.options(options);
}
tray
}
pub fn build(self) -> super::muda::Result<TrayIcon> {
let handle = self.spawn_handle().ok();
Ok(self.into_tray_icon(handle))
}
pub fn build_result(self) -> super::muda::Result<TrayIcon> {
let handle = self.spawn_handle()?;
Ok(self.into_tray_icon(Some(handle)))
}
fn spawn_handle(&self) -> super::muda::Result<crate::TrayHandle> {
let tray = self.configured_tray();
let marker = crate::MainThreadMarker::new().ok_or_else(|| {
super::muda::Error::Platform("a tray must be built on the main thread".into())
})?;
tray.spawn(marker)
.map_err(|e| super::muda::Error::Platform(e.to_string()))
}
fn into_tray_icon(self, handle: Option<crate::TrayHandle>) -> TrayIcon {
let id = self.id.unwrap_or_else(|| TrayIconId(next_tray_id()));
TrayIcon {
id,
icon: RefCell::new(self.icon),
tooltip: RefCell::new(self.tooltip),
title: RefCell::new(self.title),
handle,
}
}
}
fn icon_to_muri(icon: &Option<Icon>) -> MuriIcon {
icon.as_ref()
.and_then(|i| super::encode_rgba_cached(&i.rgba, i.width, i.height))
.map(MuriIcon::Png)
.unwrap_or(MuriIcon::Symbol("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>>,
handle: Option<crate::TrayHandle>,
}
impl TrayIcon {
pub fn id(&self) -> &TrayIconId {
&self.id
}
pub fn is_live(&self) -> bool {
self.handle.is_some()
}
pub fn set_icon(&self, icon: Option<Icon>) -> super::muda::Result<()> {
if let Some(handle) = &self.handle {
handle.set_icon(icon_to_muri(&icon));
}
*self.icon.borrow_mut() = icon;
Ok(())
}
pub fn set_tooltip(&self, tooltip: Option<impl Into<String>>) -> super::muda::Result<()> {
let tooltip = tooltip.map(Into::into);
if let Some(handle) = &self.handle {
handle.set_tooltip(tooltip.clone());
}
*self.tooltip.borrow_mut() = tooltip;
Ok(())
}
pub fn set_title(&self, title: Option<impl Into<String>>) {
let title = title.map(Into::into);
if let Some(handle) = &self.handle {
handle.set_title(title.clone());
}
*self.title.borrow_mut() = title;
}
pub fn set_visible(&self, visible: bool) -> super::muda::Result<()> {
if let Some(handle) = &self.handle {
handle.set_visible(visible);
}
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(),
};
if let Some(handle) = &self.handle {
handle.set_menu(muri_menu);
}
}
pub fn rect(&self) -> Option<Rect> {
self.handle
.as_ref()
.and_then(|h| h.anchor_rect())
.map(Rect::from)
}
pub fn set_theme(&self, theme: ThemeSource) {
if let Some(handle) = &self.handle {
handle.set_theme(theme);
}
}
pub fn set_options(&self, options: MenuOptions) {
if let Some(handle) = &self.handle {
handle.set_options(options);
}
}
}
impl Drop for TrayIcon {
fn drop(&mut self) {
if let Some(handle) = &self.handle {
handle.shutdown();
}
}
}
#[cfg(test)]
mod tests {
use super::super::muda::{MenuItem, SurfaceMode};
use super::*;
#[test]
fn menu_submodule_reexports_muda_menu_item_types() {
use super::super::tray_icon::menu::{
CheckMenuItem, Menu as MenuViaTrayIcon, MenuItem, PredefinedMenuItem, Submenu,
};
let menu = MenuViaTrayIcon::new();
let item = MenuItem::with_id("open", "Open", true, None);
let check = CheckMenuItem::with_id("toggle", "Toggle", true, true, None);
let sep = PredefinedMenuItem::separator();
let sub = Submenu::new("More", true);
menu.append(&item).unwrap();
menu.append(&check).unwrap();
menu.append(&sep).unwrap();
menu.append(&sub).unwrap();
let _same_type: MenuViaTrayIcon = Menu::new();
assert_eq!(item.id().as_str(), "open");
}
#[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"
);
}
#[test]
fn icon_to_muri_encodes_rgba_as_a_decodable_png() {
let rgba = vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, ];
let icon = Some(Icon::from_rgba(rgba.clone(), 2, 2).expect("valid RGBA"));
match icon_to_muri(&icon) {
MuriIcon::Png(bytes) => {
let (decoded, w, h) =
crate::render::decode_png(&bytes).expect("encoded tray icon PNG decodes");
assert_eq!((w, h), (2, 2));
assert_eq!(
decoded, rgba,
"the pixels round-trip through the encoded icon"
);
}
other => panic!("expected an encoded PNG icon, got {other:?}"),
}
}
#[test]
fn icon_to_muri_falls_back_to_the_placeholder_symbol() {
assert!(matches!(icon_to_muri(&None), MuriIcon::Symbol("tray")));
}
#[test]
fn title_is_recorded_from_the_builder_and_setter() {
let tray = TrayIconBuilder::new()
.with_title("45%")
.build()
.expect("tray builds");
assert_eq!(tray.title.borrow().as_deref(), Some("45%"));
tray.set_title(Some("12%"));
assert_eq!(tray.title.borrow().as_deref(), Some("12%"));
tray.set_title(None::<String>);
assert!(tray.title.borrow().is_none());
}
#[test]
fn facade_setters_and_drop_post_the_matching_commands_to_the_live_handle() {
use crate::TrayCommand;
let native = crate::Tray::new(MuriIcon::Symbol("tray"));
let handle = native.handle();
let tray = TrayIcon {
id: TrayIconId("t".into()),
icon: RefCell::new(None),
tooltip: RefCell::new(None),
title: RefCell::new(None),
handle: Some(handle.clone()),
};
tray.set_icon(Some(Icon::from_rgba(vec![1, 2, 3, 4], 1, 1).unwrap()))
.unwrap();
tray.set_title(Some("9%"));
tray.set_tooltip(Some("tip")).unwrap();
tray.set_menu(None);
tray.set_visible(false).unwrap();
drop(tray);
let posted = handle.take_posted();
assert!(matches!(&posted[0], TrayCommand::SetIcon(_)));
assert!(matches!(&posted[1], TrayCommand::SetTitle(Some(s)) if s == "9%"));
assert!(matches!(&posted[2], TrayCommand::SetTooltip(Some(s)) if s == "tip"));
assert!(matches!(&posted[3], TrayCommand::SetMenu(_)));
assert!(matches!(&posted[4], TrayCommand::SetVisible(false)));
assert!(
matches!(&posted[5], TrayCommand::Shutdown),
"Drop must post Shutdown so the OS icon is removed; got {:?}",
posted.get(5)
);
}
#[test]
fn with_options_and_with_theme_are_threaded_into_the_built_tray() {
let tray = TrayIconBuilder::new()
.with_options(MenuOptions::default().min_width(200.0).max_width(400.0))
.configured_tray();
let mo = tray.menu_options();
assert_eq!(
mo.min_width,
Some(200.0),
"with_options width must reach the tray"
);
assert_eq!(mo.max_width, Some(400.0));
let themed = TrayIconBuilder::new()
.with_options(MenuOptions::default().min_width(50.0))
.with_theme(ThemeSource::Windows(crate::ThemeMode::Dark))
.configured_tray();
let tm = themed.menu_options();
assert_eq!(
tm.min_width,
Some(50.0),
"with_theme must not clobber a prior width bound"
);
assert!(
matches!(tm.theme, ThemeSource::Windows(_)),
"with_theme must set options.theme, got {:?}",
tm.theme
);
}
#[test]
fn rect_maps_the_native_anchor_rect_through_the_live_handle() {
let native = crate::Tray::new(MuriIcon::Symbol("tray"));
let handle = native.handle();
let tray = TrayIcon {
id: TrayIconId("t".into()),
icon: RefCell::new(None),
tooltip: RefCell::new(None),
title: RefCell::new(None),
handle: Some(handle),
};
assert_eq!(tray.rect(), None);
let headless = TrayIcon {
id: TrayIconId("h".into()),
icon: RefCell::new(None),
tooltip: RefCell::new(None),
title: RefCell::new(None),
handle: None,
};
assert_eq!(headless.rect(), None);
}
#[test]
fn logical_rect_converts_into_the_facade_physical_rect() {
use crate::geometry::{LogicalPoint, LogicalRect, LogicalSize};
let logical = LogicalRect::new(LogicalPoint::new(10.0, 20.0), LogicalSize::new(30.0, 40.0));
let rect: Rect = logical.into();
assert_eq!(rect.position, PhysicalPosition { x: 10.0, y: 20.0 });
assert_eq!(rect.size, (30.0, 40.0));
}
#[test]
fn build_and_build_result_agree_on_liveness() {
let live = TrayIconBuilder::new()
.build()
.expect("build() is infallible")
.is_live();
let ok = TrayIconBuilder::new().build_result().is_ok();
assert_eq!(
ok, live,
"build_result() must succeed exactly when build() yields a live tray"
);
}
#[test]
fn set_theme_and_set_options_post_to_the_live_handle() {
use crate::TrayCommand;
let native = crate::Tray::new(MuriIcon::Symbol("tray"));
let handle = native.handle();
let tray = TrayIcon {
id: TrayIconId("t".into()),
icon: RefCell::new(None),
tooltip: RefCell::new(None),
title: RefCell::new(None),
handle: Some(handle.clone()),
};
tray.set_theme(ThemeSource::System(crate::ThemeMode::Dark));
tray.set_options(MenuOptions::default().min_width(10.0));
let posted = handle.take_posted();
assert!(matches!(&posted[0], TrayCommand::SetTheme(_)));
assert!(matches!(&posted[1], TrayCommand::SetOptions(_)));
}
}