Skip to main content

frus_shell/
tray.rs

1//! **The tray icon** (milestone 706, part of #219): the application's icon in the system's
2//! notification area — the Windows taskbar's corner, the macOS menu bar, a Linux desktop's
3//! status area — with a menu, and the choice of running on there once the window is closed.
4//!
5//! An application describes its tray from its state, as it does its title and its
6//! subscriptions: [`Application::tray`](crate::Application::tray), or `FrusApp::tray` for an
7//! application of components. The shell builds it, changes it when the description changes, and
8//! takes it away when there is none:
9//!
10//! ```
11//! # use frus_shell::Tray;
12//! # #[derive(Clone)] enum Msg { NewTask }
13//! let tray: Tray<Msg> = Tray::new("Tasks")
14//!     .item("New task", Msg::NewTask)
15//!     .separator()
16//!     .show_item("Open Tasks")
17//!     .quit_item("Quit")
18//!     .keep_running();
19//! ```
20//!
21//! - A **click** on the icon brings the window forward, unless [`on_click`](Tray::on_click)
22//!   says otherwise; the menu opens on the other button (on macOS, on the click itself, as the
23//!   menu bar does).
24//! - An **item** sends its message; [`show_item`](Tray::show_item) and
25//!   [`quit_item`](Tray::quit_item) do what a tray's menu always offers, with no message to
26//!   write.
27//! - [`keep_running`](Tray::keep_running): closing the window **hides** it, and the
28//!   application goes on in the tray until a quit item — what a chat client or a sync tool does.
29//!
30//! Behind the `tray` feature, on the desktop. On Linux the icon is the desktop's status area,
31//! reached over D-Bus; building there needs GTK 3's development files (`libgtk-3-dev`), which
32//! the menus are made with.
33
34// Without the system's tray, the description is kept but read by nobody.
35#![cfg_attr(not(all(desktop, feature = "tray")), allow(dead_code))]
36
37use crate::AppIcon;
38
39/// What a click or an item does.
40pub(crate) enum TrayAction<Msg> {
41    /// Sends a message to the application.
42    Message(Msg),
43    /// Brings the window forward, showing it if it was hidden.
44    ShowWindow,
45    /// Ends the application.
46    Quit,
47}
48
49/// One line of the tray's menu.
50pub(crate) enum TrayEntry<Msg> {
51    Item {
52        label: String,
53        enabled: bool,
54        action: Option<TrayAction<Msg>>,
55    },
56    Separator,
57}
58
59/// The application's tray icon, described: its tooltip, its icon, its menu, what a click on it
60/// does, and whether closing the window leaves the application running in it.
61pub 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/// What the person did with the tray.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub(crate) enum TrayEvent {
72    /// A click on the icon.
73    Click,
74    /// The menu's item at this place, separators counted.
75    Item(usize),
76}
77
78/// What the system is shown of a tray: everything but the messages, which it never sees. Two
79/// descriptions with the same shape need nothing changed.
80#[derive(Clone, Debug, PartialEq)]
81pub(crate) struct Shape {
82    pub(crate) tooltip: String,
83    pub(crate) icon: Option<Vec<u8>>,
84    /// `Some((label, enabled))` for an item, `None` for a separator.
85    pub(crate) entries: Vec<Option<(String, bool)>>,
86}
87
88impl<Msg> Tray<Msg> {
89    /// A tray icon showing `tooltip` when the pointer rests on it, with the application's icon
90    /// and an empty menu; a click on it brings the window forward.
91    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    /// The icon, when it is not the application's own.
102    pub fn icon(mut self, icon: AppIcon) -> Self {
103        self.icon = Some(icon);
104        self
105    }
106
107    /// Adds an item labelled `label` that sends `message`.
108    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    /// Adds an item that is shown but cannot be chosen: a status line, or an action not
118    /// available now.
119    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    /// Adds an item that brings the window forward, showing it if it was hidden.
129    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    /// Adds an item that ends the application.
139    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    /// Adds a line between items.
149    pub fn separator(mut self) -> Self {
150        self.entries.push(TrayEntry::Separator);
151        self
152    }
153
154    /// What a click on the icon sends, in place of bringing the window forward.
155    pub fn on_click(mut self, message: Msg) -> Self {
156        self.on_click = TrayAction::Message(message);
157        self
158    }
159
160    /// Closing the window **hides** it, and the application goes on in the tray until a
161    /// [`quit_item`](Self::quit_item) — or the system — ends it. Without one, closing the
162    /// window ends the application as it always does.
163    pub fn keep_running(mut self) -> Self {
164        self.keep_running = true;
165        self
166    }
167
168    /// What the system is shown of it.
169    #[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    /// What `event` does in this description: `None` for a separator, a disabled item, or a
191    /// place the menu no longer has.
192    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/// The tray as the system has it (desktop, `tray` feature).
204#[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    /// The icon on the system's tray, and what it was made from.
217    pub(crate) struct Live {
218        icon: TrayIcon,
219        shape: Shape,
220    }
221
222    /// What the person did, waiting for the event loop.
223    static EVENTS: Mutex<Vec<TrayEvent>> = Mutex::new(Vec::new());
224
225    /// The window to wake when something is waiting: a redraw asked of it brings the event
226    /// loop round on every system, the tray's events arriving on a thread of their own on Linux.
227    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    /// What the person did since the last call.
239    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    /// The handlers, which the library takes once for the whole run.
247    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    /// Makes the system's tray show `shape`: built the first time, changed where it differs,
294    /// untouched where it does not.
295    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    /// **Each place in the menu does what it says**: an item its message, a disabled item and a
358    /// separator nothing, the built-in items theirs, a place past the end nothing.
359    #[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    /// **A click brings the window forward, unless it is given a message.**
376    #[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    /// **No system tray without a real window.** An application that declares a tray, run
389    /// through the windowless driver tests use, makes none: on macOS a menu made off the main
390    /// thread is a panic, and every test of such an application would be one.
391    #[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    /// **The shape is what the system sees**: the labels, which are enabled, the separators,
402    /// the tooltip and the icon — and not the messages, so two trays that differ only in what
403    /// an item sends need nothing changed.
404    #[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}