#![deny(unsafe_code)]
#![deny(missing_docs)]
pub mod a11y;
pub mod anchor;
#[cfg(feature = "muda-compat")]
pub mod compat;
pub mod error;
pub mod event;
pub mod flyout;
pub mod geometry;
pub mod keynav;
pub mod layout;
pub mod menu;
pub mod platform;
pub mod render;
pub mod style;
pub mod theme;
pub use a11y::{announcement, build_tree, focused_id, locate, AxId, AxNode, AxRole, AxTree};
pub use anchor::place_popup;
pub use error::{Error, Result, Unsupported};
pub use event::MenuEventReceiver;
pub use flyout::{next_flyout, place_flyout, FlyoutPlacement, FlyoutSide, HoverTarget};
pub use geometry::{Edge, Insets, LogicalPoint, LogicalRect, LogicalSize};
pub use keynav::{handle_key, FlyoutFocus, MenuFocus, NavAction, NavKey};
pub use menu::{
Align, Axis, ClickHandler, Content, Flex, Icon, Item, Menu, MenuEvent, MenuId, Row, Segment,
Stack, StyleRun, TextContent,
};
pub use platform::{Appearance, Platform, PlatformEvent};
pub use render::{render_menu_to_png, render_menu_to_rgba};
pub use style::{Color, Font, FontFamily, Rgba, Weight};
pub use theme::{
GutterPolicy, MenuOptions, OsFamily, Preset, Theme, ThemeMode, ThemeSource,
TrailingGutterPolicy,
};
use std::marker::PhantomData;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Clone, Copy, Debug)]
pub struct MainThreadMarker(PhantomData<*const ()>);
impl MainThreadMarker {
#[allow(clippy::unnecessary_wraps)]
pub fn new() -> Option<Self> {
Some(MainThreadMarker(PhantomData))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SurfaceId(u64);
impl SurfaceId {
pub(crate) fn next() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(1);
SurfaceId(NEXT.fetch_add(1, Ordering::Relaxed))
}
}
pub struct Tray {
icon: Icon,
menu: Menu,
tooltip: Option<String>,
title: Option<String>,
options: MenuOptions,
on_click: Option<ClickHandler>,
commands: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
surface_id: SurfaceId,
}
type WakeFn = Box<dyn Fn() + Send + Sync + 'static>;
#[derive(Debug)]
pub(crate) enum TrayCommand {
SetMenu(Menu),
SetIcon(Icon),
SetTooltip(Option<String>),
SetTitle(Option<String>),
SetVisible(bool),
Open,
Close,
Shutdown,
SetTheme(ThemeSource),
SetOptions(MenuOptions),
QueryAnchorRect(std::sync::mpsc::Sender<Option<LogicalRect>>),
}
#[derive(Clone)]
pub struct TrayHandle {
queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
#[allow(dead_code)]
family: std::sync::Arc<HandleFamily>,
}
struct HandleFamily {
queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
}
impl Drop for HandleFamily {
fn drop(&mut self) {
if let Ok(mut q) = self.queue.lock() {
q.push(TrayCommand::Shutdown);
}
if let Ok(waker) = self.waker.lock() {
if let Some(wake) = waker.as_ref() {
wake();
}
}
}
}
impl std::fmt::Debug for TrayHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TrayHandle").finish_non_exhaustive()
}
}
impl TrayHandle {
#[cfg(test)]
pub(crate) fn take_posted(&self) -> Vec<TrayCommand> {
self.queue
.lock()
.map(|mut q| std::mem::take(&mut *q))
.unwrap_or_default()
}
fn post(&self, command: TrayCommand) {
if let Ok(mut q) = self.queue.lock() {
q.push(command);
}
if let Ok(waker) = self.waker.lock() {
if let Some(wake) = waker.as_ref() {
wake();
}
}
}
pub fn set_menu(&self, menu: Menu) {
self.post(TrayCommand::SetMenu(menu));
}
pub fn set_icon(&self, icon: Icon) {
self.post(TrayCommand::SetIcon(icon));
}
pub fn set_tooltip(&self, tooltip: Option<impl Into<String>>) {
self.post(TrayCommand::SetTooltip(tooltip.map(Into::into)));
}
pub fn set_title(&self, title: Option<impl Into<String>>) {
self.post(TrayCommand::SetTitle(title.map(Into::into)));
}
pub fn set_visible(&self, visible: bool) {
self.post(TrayCommand::SetVisible(visible));
}
pub fn open(&self) {
self.post(TrayCommand::Open);
}
pub fn close(&self) {
self.post(TrayCommand::Close);
}
pub fn shutdown(&self) {
self.post(TrayCommand::Shutdown);
}
pub fn set_theme(&self, theme: ThemeSource) {
self.post(TrayCommand::SetTheme(theme));
}
pub fn set_options(&self, options: MenuOptions) {
self.post(TrayCommand::SetOptions(options));
}
pub fn anchor_rect(&self) -> Option<LogicalRect> {
let (tx, rx) = std::sync::mpsc::channel();
self.post(TrayCommand::QueryAnchorRect(tx));
rx.recv_timeout(std::time::Duration::from_millis(200))
.ok()
.flatten()
}
}
impl Tray {
pub fn new(icon: Icon) -> Self {
Tray {
icon,
menu: Menu::new(),
tooltip: None,
title: None,
options: MenuOptions::default(),
on_click: None,
commands: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
waker: std::sync::Arc::new(std::sync::Mutex::new(None)),
surface_id: SurfaceId::next(),
}
}
pub fn surface_id(&self) -> SurfaceId {
self.surface_id
}
pub fn handle(&self) -> TrayHandle {
TrayHandle {
queue: std::sync::Arc::clone(&self.commands),
waker: std::sync::Arc::clone(&self.waker),
family: std::sync::Arc::new(HandleFamily {
queue: std::sync::Arc::clone(&self.commands),
waker: std::sync::Arc::clone(&self.waker),
}),
}
}
pub fn anchor_rect(&self) -> Result<LogicalRect> {
platform::current().tray_anchor_rect()
}
pub fn menu(mut self, menu: Menu) -> Self {
self.menu = menu;
self
}
pub fn tooltip(mut self, text: impl Into<String>) -> Self {
self.tooltip = Some(text.into());
self
}
pub fn title(mut self, text: impl Into<String>) -> Self {
self.title = Some(text.into());
self
}
pub fn options(mut self, options: MenuOptions) -> Self {
self.options = options;
self
}
pub fn theme(mut self, theme: ThemeSource) -> Self {
self.options.theme = theme;
self
}
pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn set_menu(&mut self, menu: Menu) {
self.menu = menu;
}
pub fn set_icon(&mut self, icon: Icon) {
self.icon = icon;
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn title_text(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn tooltip_text(&self) -> Option<&str> {
self.tooltip.as_deref()
}
pub fn current_menu(&self) -> &Menu {
&self.menu
}
pub fn icon(&self) -> &Icon {
&self.icon
}
pub fn menu_options(&self) -> &MenuOptions {
&self.options
}
pub fn accessibility_tree(&self) -> AxTree {
a11y::build_tree(&self.menu)
}
pub fn dispatch(&self, id: &MenuId) {
if id.is_none() {
return;
}
if let Some(handler) = &self.on_click {
handler(id);
}
event::emit(id.clone(), self.surface_id);
}
pub fn run(self, _m: MainThreadMarker) -> Result<()> {
platform::current().run_tray(self)
}
pub fn spawn(self, _m: MainThreadMarker) -> Result<TrayHandle> {
let handle = self.handle();
platform::current().spawn_tray(self)?;
Ok(handle)
}
}
pub struct ContextMenu {
menu: Menu,
options: MenuOptions,
on_click: Option<ClickHandler>,
surface_id: SurfaceId,
}
impl ContextMenu {
pub fn new(menu: Menu) -> Self {
ContextMenu {
menu,
options: MenuOptions::default(),
on_click: None,
surface_id: SurfaceId::next(),
}
}
pub fn surface_id(&self) -> SurfaceId {
self.surface_id
}
pub fn options(mut self, options: MenuOptions) -> Self {
self.options = options;
self
}
pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn menu(&self) -> &Menu {
&self.menu
}
pub fn menu_options(&self) -> &MenuOptions {
&self.options
}
pub fn accessibility_tree(&self) -> AxTree {
a11y::build_tree(&self.menu)
}
pub fn dispatch(&self, id: &MenuId) {
if id.is_none() {
return;
}
if let Some(handler) = &self.on_click {
handler(id);
}
event::emit(id.clone(), self.surface_id);
}
pub fn open_at(&self, point: LogicalPoint, edge: Edge) -> Result<()> {
let anchor = LogicalRect::new(point, LogicalSize::new(0.0, 0.0));
let handler = |id: &MenuId| self.dispatch(id);
platform::current().open_popup_session(
self.menu.clone(),
self.options.clone(),
&handler,
anchor,
edge,
)
}
}
pub struct Popup {
menu: Menu,
options: MenuOptions,
on_click: Option<ClickHandler>,
surface_id: SurfaceId,
}
impl Popup {
pub fn new(menu: Menu) -> Self {
Popup {
menu,
options: MenuOptions::default(),
on_click: None,
surface_id: SurfaceId::next(),
}
}
pub fn surface_id(&self) -> SurfaceId {
self.surface_id
}
pub fn options(mut self, options: MenuOptions) -> Self {
self.options = options;
self
}
pub fn on_click(mut self, handler: impl Fn(&MenuId) + Send + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn menu(&self) -> &Menu {
&self.menu
}
pub fn menu_options(&self) -> &MenuOptions {
&self.options
}
pub fn accessibility_tree(&self) -> AxTree {
a11y::build_tree(&self.menu)
}
pub fn dispatch(&self, id: &MenuId) {
if id.is_none() {
return;
}
if let Some(handler) = &self.on_click {
handler(id);
}
event::emit(id.clone(), self.surface_id);
}
pub fn anchored_to(&self, anchor: LogicalRect, edge: Edge) -> Result<()> {
let handler = |id: &MenuId| self.dispatch(id);
platform::current().open_popup_session(
self.menu.clone(),
self.options.clone(),
&handler,
anchor,
edge,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[test]
fn tray_builder_stores_configuration() {
let tray = Tray::new(Icon::Checkmark)
.tooltip("usagio")
.title("45%")
.menu(Menu::new().row(Row::new("quit").label("Quit")))
.theme(ThemeSource::System(ThemeMode::Dark));
assert_eq!(tray.tooltip_text(), Some("usagio"));
assert_eq!(tray.title_text(), Some("45%"));
assert_eq!(tray.current_menu().len(), 1);
assert!(matches!(
tray.menu_options().theme,
ThemeSource::System(ThemeMode::Dark)
));
}
#[test]
fn tray_handle_setters_post_the_matching_command() {
let tray = Tray::new(Icon::Checkmark);
let handle = tray.handle();
handle.set_title(Some("45%"));
handle.set_tooltip(Some("tip"));
handle.set_visible(false);
let posted = handle.take_posted();
assert!(
matches!(&posted[0], TrayCommand::SetTitle(Some(s)) if s == "45%"),
"got {:?}",
posted.first()
);
assert!(
matches!(&posted[1], TrayCommand::SetTooltip(Some(s)) if s == "tip"),
"got {:?}",
posted.get(1)
);
assert!(matches!(&posted[2], TrayCommand::SetVisible(false)));
}
#[test]
fn tray_title_defaults_none_and_set_title_replaces_it() {
let mut tray = Tray::new(Icon::Checkmark);
assert_eq!(tray.title_text(), None);
tray.set_title(Some("12%".to_owned()));
assert_eq!(tray.title_text(), Some("12%"));
tray.set_title(None);
assert_eq!(tray.title_text(), None);
}
#[test]
fn tray_dispatch_invokes_handler_with_id() {
let _guard = crate::event::test_lock();
let seen = Arc::new(AtomicUsize::new(0));
let seen2 = Arc::clone(&seen);
let tray = Tray::new(Icon::Checkmark).on_click(move |id| {
if id.as_str() == "quit" {
seen2.fetch_add(1, Ordering::SeqCst);
}
});
tray.dispatch(&MenuId::from("quit"));
tray.dispatch(&MenuId::from("other"));
assert_eq!(seen.load(Ordering::SeqCst), 1);
}
#[test]
fn dispatch_fires_closure_before_channel_and_skips_inert() {
let _guard = crate::event::test_lock();
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
while MenuEvent::receiver().try_recv().is_ok() {}
let log = Arc::new(Mutex::new(Vec::<String>::new()));
let seen_on_channel_in_closure = Arc::new(AtomicBool::new(false));
let log2 = Arc::clone(&log);
let flag2 = Arc::clone(&seen_on_channel_in_closure);
let tray = Tray::new(Icon::Checkmark).on_click(move |id| {
if let Ok(ev) = MenuEvent::receiver().try_recv() {
if ev.id == MenuId::from("m3_order_probe") {
flag2.store(true, Ordering::SeqCst);
}
}
log2.lock()
.unwrap()
.push(format!("closure:{}", id.as_str()))
});
tray.dispatch(&MenuId::none());
assert!(
log.lock().unwrap().is_empty(),
"inert id must not fire the closure"
);
tray.dispatch(&MenuId::from("m3_order_probe"));
log.lock().unwrap().push("after_dispatch".to_string());
assert!(
!seen_on_channel_in_closure.load(Ordering::SeqCst),
"channel must still be empty while the closure runs (closure-first order)"
);
let mut found = false;
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if ev.id == MenuId::from("m3_order_probe") {
found = true;
break;
}
}
assert!(
found,
"activation must be projected onto the global channel after the closure"
);
let log = log.lock().unwrap();
assert_eq!(log[0], "closure:m3_order_probe");
assert_eq!(log[1], "after_dispatch");
}
#[test]
fn context_menu_dispatch_works() {
let _guard = crate::event::test_lock();
let hit = Arc::new(AtomicUsize::new(0));
let hit2 = Arc::clone(&hit);
let cm = ContextMenu::new(Menu::new()).on_click(move |_| {
hit2.fetch_add(1, Ordering::SeqCst);
});
cm.dispatch(&MenuId::from("x"));
assert_eq!(hit.load(Ordering::SeqCst), 1);
}
#[test]
fn main_thread_marker_new_always_returns_some() {
assert!(MainThreadMarker::new().is_some());
}
#[test]
fn surface_id_is_unique_and_monotonic_across_surface_kinds() {
let a = Tray::new(Icon::Checkmark).surface_id();
let b = ContextMenu::new(Menu::new()).surface_id();
let c = Popup::new(Menu::new()).surface_id();
let d = Tray::new(Icon::Checkmark).surface_id();
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, d);
assert!(b.0 > a.0);
assert!(c.0 > b.0);
assert!(d.0 > c.0);
}
#[test]
fn tray_handle_drop_posts_shutdown_once_on_last_clone_only() {
let tray = Tray::new(Icon::Checkmark);
let family = tray.handle();
let clone = family.clone();
let inspector = tray.handle();
drop(clone);
assert!(
inspector.take_posted().is_empty(),
"an intermediate clone dropping must not post Shutdown"
);
drop(family);
let posted = inspector.take_posted();
assert!(
matches!(posted.as_slice(), [TrayCommand::Shutdown]),
"the last clone dropping must post exactly one Shutdown, got {:?}",
posted
);
}
#[test]
fn concurrent_last_clone_drops_post_shutdown_exactly_once() {
let tray = Tray::new(Icon::Checkmark);
let inspector = tray.handle(); let h1 = tray.handle();
let h2 = h1.clone();
let t1 = std::thread::spawn(move || drop(h1));
let t2 = std::thread::spawn(move || drop(h2));
t1.join().unwrap();
t2.join().unwrap();
let posted = inspector.take_posted();
let shutdowns = posted
.iter()
.filter(|c| matches!(c, TrayCommand::Shutdown))
.count();
assert_eq!(
shutdowns, 1,
"the family's final drop must post exactly one Shutdown, got {posted:?}"
);
}
#[test]
fn set_theme_and_options_post_live_swap_commands() {
let tray = Tray::new(Icon::Checkmark);
let inspector = tray.handle();
let control = tray.handle();
control.set_theme(ThemeSource::MacOs(ThemeMode::Dark));
control.set_options(MenuOptions::default().min_width(120.0));
let posted = inspector.take_posted();
assert!(
matches!(
posted.as_slice(),
[TrayCommand::SetTheme(_), TrayCommand::SetOptions(_)]
),
"set_theme/set_options must post the matching commands in order, got {:?}",
posted
);
}
#[test]
fn anchor_rect_query_times_out_to_none_with_no_live_backend() {
let tray = Tray::new(Icon::Checkmark);
let handle = tray.handle();
assert!(handle.anchor_rect().is_none());
let posted = handle.take_posted();
assert!(
posted
.iter()
.any(|c| matches!(c, TrayCommand::QueryAnchorRect(_))),
"anchor_rect must post a QueryAnchorRect command, got {posted:?}"
);
}
}