use ashlar_command::menu::MenuSpec;
use crate::{NativeError, Result, Waker};
pub const MAX_TOOLTIP: usize = 256;
pub const MAX_ICON_SIDE: u32 = 1024;
pub const MAX_ID: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Icon {
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
}
impl Icon {
pub fn validate(&self) -> Result<()> {
let side_ok = |s: u32| (1..=MAX_ICON_SIDE).contains(&s);
if !side_ok(self.width) || !side_ok(self.height) {
return Err(NativeError::Invalid(format!("a tray icon is 1–{MAX_ICON_SIDE} pixels a side")));
}
let want = (self.width as usize).saturating_mul(self.height as usize).saturating_mul(4);
if self.rgba.len() != want {
return Err(NativeError::Invalid(format!("a {}×{} icon needs {want} bytes, not {}", self.width, self.height, self.rgba.len())));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraySpec {
pub id: String,
pub tooltip: String,
pub icon: Icon,
pub template: bool,
pub menu: MenuSpec,
}
impl TraySpec {
pub fn new(id: impl Into<String>, tooltip: impl Into<String>, icon: Icon, menu: MenuSpec) -> Self {
Self { id: id.into(), tooltip: tooltip.into(), icon, template: false, menu }
}
pub fn template(mut self, on: bool) -> Self {
self.template = on;
self
}
pub fn validate(&self) -> Result<()> {
let n = self.id.chars().count();
if n == 0 || n > MAX_ID || self.id.chars().any(char::is_control) {
return Err(NativeError::Invalid(format!("a tray id is 1–{MAX_ID} characters without control characters")));
}
self.icon.validate()?;
self.menu.validate().map_err(|p| NativeError::Invalid(p.join("; ")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrayEvent {
Activate,
Menu(String),
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn prefix(id: &str) -> String {
format!("\u{1}tray/{id}/")
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn clip_tooltip(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).take(MAX_TOOLTIP).collect()
}
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Mutex, Once, PoisonError};
use tray_icon::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use super::*;
use crate::menu_events::{Subscription, subscribe};
static CLICKS: Mutex<Option<HashMap<String, (Sender<()>, Waker)>>> = Mutex::new(None);
static INSTALL: Once = Once::new();
fn on_click(e: TrayIconEvent) {
let id = match &e {
TrayIconEvent::Click { id, button: MouseButton::Left, button_state: MouseButtonState::Up, .. }
| TrayIconEvent::DoubleClick { id, .. } => id.0.clone(),
_ => return,
};
let sink = CLICKS.lock().unwrap_or_else(PoisonError::into_inner).as_ref().and_then(|m| m.get(&id).cloned());
if let Some((tx, wake)) = sink
&& tx.send(()).is_ok()
{
wake();
}
}
pub struct Tray {
icon: tray_icon::TrayIcon,
id: String,
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
template: bool,
menu_rx: Receiver<String>,
click_rx: Receiver<()>,
_route: Subscription,
}
fn os(e: impl std::fmt::Display) -> NativeError {
NativeError::Os(crate::os_text(e))
}
fn icon(i: &Icon) -> Result<tray_icon::Icon> {
tray_icon::Icon::from_rgba(i.rgba.clone(), i.width, i.height).map_err(os)
}
impl Tray {
pub fn new(spec: TraySpec, waker: Waker) -> Result<Self> {
spec.validate()?;
#[cfg(target_os = "macos")]
if objc2::MainThreadMarker::new().is_none() {
return Err(NativeError::Os("the tray must be created on the main thread".into()));
}
INSTALL.call_once(|| {
TrayIconEvent::set_event_handler(Some(|e: TrayIconEvent| {
let _ = std::panic::catch_unwind(|| on_click(e));
}));
});
let (menu_tx, menu_rx) = channel();
let (click_tx, click_rx) = channel();
{
let mut clicks = CLICKS.lock().unwrap_or_else(PoisonError::into_inner);
let map = clicks.get_or_insert_with(HashMap::new);
if map.contains_key(&spec.id) {
return Err(NativeError::Invalid(format!("a tray with id `{}` already exists", spec.id)));
}
map.insert(spec.id.clone(), (click_tx, waker.clone()));
}
let route = subscribe(prefix(&spec.id), menu_tx, waker);
let built = crate::guarded("the tray icon", || {
let menu = crate::menu_tree::build(&crate::menu_tree::plan(&spec.menu, &prefix(&spec.id)))?;
let b = TrayIconBuilder::new().with_id(spec.id.clone()).with_tooltip(clip_tooltip(&spec.tooltip)).with_menu(Box::new(menu));
#[cfg(target_os = "macos")]
let b = if spec.template { b.with_icon_templated(icon(&spec.icon)?) } else { b.with_icon(icon(&spec.icon)?) };
#[cfg(not(target_os = "macos"))]
let b = b.with_icon(icon(&spec.icon)?);
b.build().map_err(os)
});
match built {
Ok(icon) => Ok(Self { icon, id: spec.id, template: spec.template, menu_rx, click_rx, _route: route }),
Err(e) => {
forget_clicks(&spec.id);
Err(e)
}
}
}
pub fn events(&self) -> Vec<TrayEvent> {
let mut out: Vec<TrayEvent> = self.click_rx.try_iter().map(|()| TrayEvent::Activate).collect();
out.extend(self.menu_rx.try_iter().map(TrayEvent::Menu));
out
}
pub fn set_tooltip(&self, text: &str) -> Result<()> {
crate::guarded("the tray tooltip", || self.icon.set_tooltip(Some(clip_tooltip(text))).map_err(os))
}
pub fn set_icon(&self, i: &Icon) -> Result<()> {
i.validate()?;
crate::guarded("the tray icon", || {
let i = Some(icon(i)?);
#[cfg(target_os = "macos")]
let set = if self.template { self.icon.set_icon_templated(i) } else { self.icon.set_icon(i) };
#[cfg(not(target_os = "macos"))]
let set = self.icon.set_icon(i);
set.map_err(os)
})
}
pub fn set_menu(&self, spec: &MenuSpec) -> Result<()> {
spec.validate().map_err(|p| NativeError::Invalid(p.join("; ")))?;
crate::guarded("the tray menu", || {
let menu = crate::menu_tree::build(&crate::menu_tree::plan(spec, &prefix(&self.id)))?;
self.icon.set_menu(Some(Box::new(menu)));
Ok(())
})
}
}
fn forget_clicks(id: &str) {
if let Some(m) = CLICKS.lock().unwrap_or_else(PoisonError::into_inner).as_mut() {
m.remove(id);
}
}
impl Drop for Tray {
fn drop(&mut self) {
forget_clicks(&self.id);
}
}
}
#[cfg(target_arch = "wasm32")]
mod imp {
use super::*;
pub struct Tray(());
impl Tray {
pub fn new(spec: TraySpec, _waker: Waker) -> Result<Self> {
spec.validate()?;
Err(NativeError::Unsupported)
}
pub fn events(&self) -> Vec<TrayEvent> {
vec![]
}
pub fn set_tooltip(&self, _text: &str) -> Result<()> {
Err(NativeError::Unsupported)
}
pub fn set_icon(&self, _i: &Icon) -> Result<()> {
Err(NativeError::Unsupported)
}
pub fn set_menu(&self, _spec: &MenuSpec) -> Result<()> {
Err(NativeError::Unsupported)
}
}
}
pub use imp::Tray;
#[cfg(test)]
mod tests {
use ashlar_command::menu::{Item, MenuNode, Submenu};
use super::*;
fn icon(w: u32, h: u32) -> Icon {
Icon { rgba: vec![0; (w * h * 4) as usize], width: w, height: h }
}
fn spec() -> TraySpec {
TraySpec::new("main", "My app", icon(16, 16), MenuSpec::new(vec![Submenu::new("Tray", vec![MenuNode::Item(Item::new("show", "Show"))])]))
}
#[test]
fn specs_are_checked_before_anything_reaches_the_os() {
assert!(spec().validate().is_ok());
let mut s = spec();
s.id = String::new();
assert!(matches!(s.validate(), Err(NativeError::Invalid(_))));
s.id = "a\nb".into();
assert!(s.validate().is_err());
let mut s = spec();
s.icon.rgba.pop();
assert!(s.validate().is_err());
s.icon = icon(0, 16);
assert!(s.validate().is_err());
s.icon = Icon { rgba: vec![], width: MAX_ICON_SIDE + 1, height: 1 };
assert!(s.validate().is_err());
let mut s = spec();
s.menu.menus[0].children.push(MenuNode::Item(Item::new("show", "Again")));
assert!(s.validate().is_err(), "duplicate ids are refused");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn tray_items_carry_the_tray_prefix_so_events_route_back() {
let p = crate::menu_tree::plan(&spec().menu, &prefix("main"));
assert!(matches!(&p[..], [crate::menu_tree::Entry::Item { id, .. }] if id == "\u{1}tray/main/show"));
assert_eq!(crate::menu_events::owner(["", &prefix("main"), &prefix("other")], "\u{1}tray/main/show"), Some(1));
}
#[test]
fn tooltips_lose_control_characters_and_length() {
assert_eq!(clip_tooltip("a\nb"), "ab");
assert_eq!(clip_tooltip(&"x".repeat(MAX_TOOLTIP + 9)).chars().count(), MAX_TOOLTIP);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn creating_a_tray_never_panics() {
let _ = Tray::new(spec(), crate::no_waker());
}
}