#![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, ClickHandler, Flex, Icon, Item, Menu, MenuEvent, MenuId, Row, Segment, StyleRun,
};
pub use platform::{Appearance, Platform, PlatformEvent};
pub use style::{Color, Font, FontFamily, Rgba, Weight};
pub use theme::{MenuOptions, Theme, ThemeSource};
pub struct Tray {
icon: Icon,
menu: Menu,
tooltip: 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>>>,
}
type WakeFn = Box<dyn Fn() + Send + Sync + 'static>;
#[derive(Debug)]
pub(crate) enum TrayCommand {
SetMenu(Menu),
SetIcon(Icon),
SetTooltip(Option<String>),
SetVisible(bool),
Open,
Close,
}
#[derive(Clone)]
pub struct TrayHandle {
queue: std::sync::Arc<std::sync::Mutex<Vec<TrayCommand>>>,
waker: std::sync::Arc<std::sync::Mutex<Option<WakeFn>>>,
}
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 {
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_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);
}
}
impl Tray {
pub fn new(icon: Icon) -> Self {
Tray {
icon,
menu: Menu::new(),
tooltip: 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)),
}
}
pub fn handle(&self) -> TrayHandle {
TrayHandle {
queue: std::sync::Arc::clone(&self.commands),
waker: std::sync::Arc::clone(&self.waker),
}
}
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 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 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());
}
pub fn run(self) -> Result<()> {
platform::current().run_tray(self)
}
}
pub struct ContextMenu {
menu: Menu,
options: MenuOptions,
on_click: Option<ClickHandler>,
}
impl ContextMenu {
pub fn new(menu: Menu) -> Self {
ContextMenu {
menu,
options: MenuOptions::default(),
on_click: None,
}
}
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());
}
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>,
}
impl Popup {
pub fn new(menu: Menu) -> Self {
Popup {
menu,
options: MenuOptions::default(),
on_click: None,
}
}
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());
}
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")
.menu(Menu::new().row(Row::new("quit").label("Quit")))
.theme(ThemeSource::Dark);
assert_eq!(tray.tooltip_text(), Some("usagio"));
assert_eq!(tray.current_menu().len(), 1);
assert!(matches!(tray.menu_options().theme, ThemeSource::Dark));
}
#[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);
}
}