1#![cfg_attr(not(all(desktop, feature = "tray")), allow(dead_code))]
36
37use crate::AppIcon;
38
39pub(crate) enum TrayAction<Msg> {
41 Message(Msg),
43 ShowWindow,
45 Quit,
47}
48
49pub(crate) enum TrayEntry<Msg> {
51 Item {
52 label: String,
53 enabled: bool,
54 action: Option<TrayAction<Msg>>,
55 },
56 Separator,
57}
58
59pub struct Tray<Msg> {
62 pub(crate) tooltip: String,
63 pub(crate) icon: Option<AppIcon>,
64 pub(crate) entries: Vec<TrayEntry<Msg>>,
65 pub(crate) on_click: TrayAction<Msg>,
66 pub(crate) keep_running: bool,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub(crate) enum TrayEvent {
72 Click,
74 Item(usize),
76}
77
78#[derive(Clone, Debug, PartialEq)]
81pub(crate) struct Shape {
82 pub(crate) tooltip: String,
83 pub(crate) icon: Option<Vec<u8>>,
84 pub(crate) entries: Vec<Option<(String, bool)>>,
86}
87
88impl<Msg> Tray<Msg> {
89 pub fn new(tooltip: impl Into<String>) -> Self {
92 Self {
93 tooltip: tooltip.into(),
94 icon: None,
95 entries: Vec::new(),
96 on_click: TrayAction::ShowWindow,
97 keep_running: false,
98 }
99 }
100
101 pub fn icon(mut self, icon: AppIcon) -> Self {
103 self.icon = Some(icon);
104 self
105 }
106
107 pub fn item(mut self, label: impl Into<String>, message: Msg) -> Self {
109 self.entries.push(TrayEntry::Item {
110 label: label.into(),
111 enabled: true,
112 action: Some(TrayAction::Message(message)),
113 });
114 self
115 }
116
117 pub fn disabled_item(mut self, label: impl Into<String>) -> Self {
120 self.entries.push(TrayEntry::Item {
121 label: label.into(),
122 enabled: false,
123 action: None,
124 });
125 self
126 }
127
128 pub fn show_item(mut self, label: impl Into<String>) -> Self {
130 self.entries.push(TrayEntry::Item {
131 label: label.into(),
132 enabled: true,
133 action: Some(TrayAction::ShowWindow),
134 });
135 self
136 }
137
138 pub fn quit_item(mut self, label: impl Into<String>) -> Self {
140 self.entries.push(TrayEntry::Item {
141 label: label.into(),
142 enabled: true,
143 action: Some(TrayAction::Quit),
144 });
145 self
146 }
147
148 pub fn separator(mut self) -> Self {
150 self.entries.push(TrayEntry::Separator);
151 self
152 }
153
154 pub fn on_click(mut self, message: Msg) -> Self {
156 self.on_click = TrayAction::Message(message);
157 self
158 }
159
160 pub fn keep_running(mut self) -> Self {
164 self.keep_running = true;
165 self
166 }
167
168 #[cfg(any(desktop, web, test))]
170 pub(crate) fn shape(&self, default_icon: &AppIcon) -> Shape {
171 Shape {
172 tooltip: self.tooltip.clone(),
173 icon: self
174 .icon
175 .as_ref()
176 .unwrap_or(default_icon)
177 .png()
178 .map(<[u8]>::to_vec),
179 entries: self
180 .entries
181 .iter()
182 .map(|entry| match entry {
183 TrayEntry::Item { label, enabled, .. } => Some((label.clone(), *enabled)),
184 TrayEntry::Separator => None,
185 })
186 .collect(),
187 }
188 }
189
190 pub(crate) fn action(self, event: TrayEvent) -> Option<TrayAction<Msg>> {
193 match event {
194 TrayEvent::Click => Some(self.on_click),
195 TrayEvent::Item(at) => match self.entries.into_iter().nth(at)? {
196 TrayEntry::Item { action, .. } => action,
197 TrayEntry::Separator => None,
198 },
199 }
200 }
201}
202
203#[cfg(all(desktop, feature = "tray"))]
205pub(crate) mod native {
206 use std::sync::{Arc, Mutex, Once};
207
208 use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
209 use tray_icon::{
210 Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent,
211 };
212 use winit::window::Window;
213
214 use super::{Shape, TrayEvent};
215
216 pub(crate) struct Live {
218 icon: TrayIcon,
219 shape: Shape,
220 }
221
222 static EVENTS: Mutex<Vec<TrayEvent>> = Mutex::new(Vec::new());
224
225 static WINDOW: Mutex<Option<Arc<Window>>> = Mutex::new(None);
228
229 fn push(event: TrayEvent) {
230 if let Ok(mut events) = EVENTS.lock() {
231 events.push(event);
232 }
233 if let Some(window) = WINDOW.lock().ok().and_then(|window| window.clone()) {
234 window.request_redraw();
235 }
236 }
237
238 pub(crate) fn take_events() -> Vec<TrayEvent> {
240 EVENTS
241 .lock()
242 .map(|mut events| std::mem::take(&mut *events))
243 .unwrap_or_default()
244 }
245
246 fn listen() {
248 static ONCE: Once = Once::new();
249 ONCE.call_once(|| {
250 TrayIconEvent::set_event_handler(Some(|event: TrayIconEvent| {
251 if let TrayIconEvent::Click {
252 button: MouseButton::Left,
253 button_state: MouseButtonState::Up,
254 ..
255 } = event
256 {
257 push(TrayEvent::Click);
258 }
259 }));
260 MenuEvent::set_event_handler(Some(|event: MenuEvent| {
261 if let Ok(at) = event.id.0.parse::<usize>() {
262 push(TrayEvent::Item(at));
263 }
264 }));
265 });
266 }
267
268 fn menu(shape: &Shape) -> Menu {
269 let menu = Menu::new();
270 for (at, entry) in shape.entries.iter().enumerate() {
271 let added = match entry {
272 Some((label, enabled)) => {
273 menu.append(&MenuItem::with_id(at.to_string(), label, *enabled, None))
274 }
275 None => menu.append(&PredefinedMenuItem::separator()),
276 };
277 if let Err(err) = added {
278 log::warn!("tray: an item could not be added to the menu ({err})");
279 }
280 }
281 menu
282 }
283
284 fn icon(shape: &Shape) -> Option<Icon> {
285 let image = frus_image::decode(shape.icon.as_deref()?)
286 .map_err(|err| log::warn!("tray: the icon is not a PNG that can be read ({err})"))
287 .ok()?;
288 Icon::from_rgba(image.rgba().to_vec(), image.width(), image.height())
289 .map_err(|err| log::warn!("tray: the icon cannot be used ({err})"))
290 .ok()
291 }
292
293 pub(crate) fn sync(live: &mut Option<Live>, shape: Shape, window: Option<&Arc<Window>>) {
296 if let Ok(mut slot) = WINDOW.lock() {
297 *slot = window.cloned();
298 }
299 match live {
300 Some(live) if live.shape == shape => {}
301 Some(live) => {
302 if live.shape.entries != shape.entries {
303 live.icon.set_menu(Some(Box::new(menu(&shape))));
304 }
305 if live.shape.tooltip != shape.tooltip {
306 let _ = live.icon.set_tooltip(Some(&shape.tooltip));
307 }
308 if live.shape.icon != shape.icon {
309 let _ = live.icon.set_icon(icon(&shape));
310 }
311 live.shape = shape;
312 }
313 None => {
314 listen();
315 let mut builder = TrayIconBuilder::new()
316 .with_tooltip(&shape.tooltip)
317 .with_menu(Box::new(menu(&shape)))
318 .with_menu_on_left_click(cfg!(target_os = "macos"));
319 if let Some(icon) = icon(&shape) {
320 builder = builder.with_icon(icon);
321 }
322 match builder.build() {
323 Ok(icon) => *live = Some(Live { icon, shape }),
324 Err(err) => log::warn!("tray: the icon could not be made ({err})"),
325 }
326 }
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[derive(Clone, Debug, PartialEq)]
336 enum Msg {
337 New,
338 Ping,
339 }
340
341 fn tray() -> Tray<Msg> {
342 Tray::new("Tasks")
343 .item("New task", Msg::New)
344 .disabled_item("3 to do")
345 .separator()
346 .show_item("Open")
347 .quit_item("Quit")
348 }
349
350 fn message(action: Option<TrayAction<Msg>>) -> Option<Msg> {
351 match action? {
352 TrayAction::Message(msg) => Some(msg),
353 _ => None,
354 }
355 }
356
357 #[test]
360 fn each_place_does_what_it_says() {
361 assert_eq!(message(tray().action(TrayEvent::Item(0))), Some(Msg::New));
362 assert!(tray().action(TrayEvent::Item(1)).is_none(), "disabled");
363 assert!(tray().action(TrayEvent::Item(2)).is_none(), "a separator");
364 assert!(matches!(
365 tray().action(TrayEvent::Item(3)),
366 Some(TrayAction::ShowWindow)
367 ));
368 assert!(matches!(
369 tray().action(TrayEvent::Item(4)),
370 Some(TrayAction::Quit)
371 ));
372 assert!(tray().action(TrayEvent::Item(5)).is_none(), "past the end");
373 }
374
375 #[test]
377 fn a_click_shows_the_window_unless_told() {
378 assert!(matches!(
379 tray().action(TrayEvent::Click),
380 Some(TrayAction::ShowWindow)
381 ));
382 assert_eq!(
383 message(tray().on_click(Msg::Ping).action(TrayEvent::Click)),
384 Some(Msg::Ping)
385 );
386 }
387
388 #[cfg(all(desktop, feature = "tray"))]
392 #[test]
393 fn no_system_tray_without_a_window() {
394 let app = crate::FrusApp::from_fn(|_| Box::new(frus_widgets::Spacer::default()))
395 .tray(|| Tray::new("Tasks").quit_item("Quit").keep_running());
396 let mut driver = crate::testing::Driver::new(app, 200.0, 200.0);
397 driver.frame(0.016);
398 assert!(!driver.has_system_tray());
399 }
400
401 #[test]
405 fn the_shape_leaves_the_messages_out() {
406 let icon = AppIcon::frus();
407 let shape = tray().shape(&icon);
408 assert_eq!(shape.tooltip, "Tasks");
409 assert_eq!(
410 shape.entries,
411 vec![
412 Some(("New task".into(), true)),
413 Some(("3 to do".into(), false)),
414 None,
415 Some(("Open".into(), true)),
416 Some(("Quit".into(), true)),
417 ]
418 );
419 assert_eq!(
420 shape.icon.as_deref(),
421 icon.png(),
422 "the application's icon by default"
423 );
424 let other = Tray::new("Tasks").item("New task", Msg::Ping);
425 let same = Tray::new("Tasks").item("New task", Msg::New);
426 assert_eq!(other.shape(&icon), same.shape(&icon));
427 assert_ne!(
428 Tray::<Msg>::new("Tasks").icon(AppIcon::none()).shape(&icon),
429 Tray::<Msg>::new("Tasks").shape(&icon)
430 );
431 assert!(!tray().keep_running && tray().keep_running().keep_running);
432 }
433}