Skip to main content

azul_core/
tray.rs

1//! System tray / status icon  -  platform-agnostic model.
2//!
3//! The actual OS plumbing lives in `azul-dll` (`desktop/tray/`). This module
4//! only defines the data the three backends agree on.
5//!
6//! # Why the API has this shape
7//!
8//! The three platforms share almost nothing below the "icon + retained menu
9//! tree" level, and two of their constraints leak into any honest API:
10//!
11//! 1. **On Linux the menu is not a popup  -  it is a remote model.** SNI's `Menu`
12//!    property points at a `com.canonical.dbusmenu` object, and the *panel*
13//!    draws the menu, calling back into us with `GetLayout` / `AboutToShow`.
14//!    So the menu must be a RETAINED tree with stable ids and a revision
15//!    counter. An API shaped as `show_context_menu_at(x, y)` cannot be
16//!    implemented on Linux and would have to be redone  -  hence
17//!    [`TrayIconData::menu`] is state, not a call.
18//!
19//! 2. **`ContextMenu` is a REQUEST, not a command.** On Linux the panel may
20//!    open the menu itself and never tell us; on Windows and macOS we open it.
21//!    Callers must not assume their handler is the only thing that runs.
22//!
23//! Two more platform truths that the API deliberately does NOT hide:
24//!
25//! * **A tray may genuinely not exist.** On a vanilla GNOME there is no
26//!   `org.kde.StatusNotifierWatcher` at all: registration fails silently and no
27//!   icon ever appears. [`TrayIconData`] is therefore accepted on a best-effort
28//!   basis and the app must have a story for "no tray"  -  see
29//!   `App::tray_available()` in azul-dll.
30//! * **Click semantics differ.** The SNI spec does not say which gesture
31//!   activates an item; some desktops use single left click, some double. Never
32//!   document a precise gesture for [`TrayEventType::Activate`].
33
34use alloc::{string::String, vec::Vec};
35
36use crate::{
37    menu::{Menu, OptionMenu},
38    window::IconKey,
39};
40use azul_css::{corety::U8Vec, AzString, OptionString};
41
42/// RGBA8 image for a tray icon, at one specific size.
43///
44/// Unlike [`crate::window::WindowIcon`], which is fixed at 16x16 / 32x32, a
45/// tray icon needs arbitrary sizes: Windows wants
46/// `GetSystemMetricsForDpi(SM_CXSMICON)` (16 / 20 / 24 / 32 px as the taskbar's
47/// DPI changes), macOS wants an 18x18 *point* template image (so 36x36 px on a
48/// 2x display), and SNI wants an array of whatever sizes we care to publish so
49/// the panel can pick.
50///
51/// `rgba` is straight, non-premultiplied RGBA8, `width * height * 4` bytes, top
52/// row first. Every backend converts from this one representation:
53/// Windows builds a BGRA `HBITMAP` + mask, macOS an `NSBitmapImageRep`, and
54/// Linux byte-swaps to the ARGB32-big-endian that `IconPixmap` requires.
55#[derive(Debug, Clone)]
56#[repr(C)]
57pub struct TrayIconImage {
58    /// Cache key  -  lets a backend skip re-uploading an unchanged icon.
59    pub key: IconKey,
60    pub width: u32,
61    pub height: u32,
62    pub rgba: U8Vec,
63}
64
65impl PartialEq for TrayIconImage {
66    fn eq(&self, other: &Self) -> bool {
67        self.key == other.key
68    }
69}
70impl Eq for TrayIconImage {}
71
72impl TrayIconImage {
73    /// `rgba` must be exactly `width * height * 4` bytes; returns `None`
74    /// otherwise.
75    ///
76    /// Returns `OptionTrayIconImage` rather than `Option<Self>` so the
77    /// signature crosses the C ABI unchanged.
78    #[must_use]
79    #[allow(
80        clippy::new_ret_no_self,
81        reason = "C-ABI: must return OptionTrayIconImage"
82    )]
83    pub fn new(width: u32, height: u32, rgba: U8Vec) -> OptionTrayIconImage {
84        if width == 0 || height == 0 {
85            return OptionTrayIconImage::None;
86        }
87        let Some(expected) = (width as usize)
88            .checked_mul(height as usize)
89            .and_then(|n| n.checked_mul(4))
90        else {
91            return OptionTrayIconImage::None;
92        };
93        if rgba.as_ref().len() != expected {
94            return OptionTrayIconImage::None;
95        }
96        OptionTrayIconImage::Some(Self {
97            key: IconKey::new(),
98            width,
99            height,
100            rgba,
101        })
102    }
103
104    /// The icon's pixels as ARGB32 in **network (big-endian) byte order**, the
105    /// wire format `org.kde.StatusNotifierItem`'s `IconPixmap` (`a(iiay)`)
106    /// requires. Nothing else uses this layout, so it is computed on demand.
107    #[must_use]
108    pub fn to_argb32_be(&self) -> U8Vec {
109        let src = self.rgba.as_ref();
110        let mut out = Vec::with_capacity(src.len());
111        for px in src.chunks_exact(4) {
112            // RGBA -> ARGB, big-endian == [A, R, G, B] in memory order.
113            out.extend_from_slice(&[px[3], px[0], px[1], px[2]]);
114        }
115        U8Vec::from_vec(out)
116    }
117}
118
119impl_option!(
120    TrayIconImage,
121    OptionTrayIconImage,
122    copy = false,
123    [Debug, Clone, PartialEq, Eq]
124);
125
126impl_vec!(
127    TrayIconImage,
128    TrayIconImageVec,
129    TrayIconImageVecDestructor,
130    TrayIconImageVecDestructorType,
131    TrayIconImageVecSlice,
132    OptionTrayIconImage
133);
134impl_vec_debug!(TrayIconImage, TrayIconImageVec);
135impl_vec_clone!(TrayIconImage, TrayIconImageVec, TrayIconImageVecDestructor);
136impl_vec_partialeq!(TrayIconImage, TrayIconImageVec);
137
138/// Where a tray icon's pixels come from.
139#[derive(Debug, Clone, PartialEq)]
140#[repr(C, u8)]
141#[derive(Default)]
142pub enum TrayIconSource {
143    /// No icon. Most desktops render this as an invisible item, so it is
144    /// almost never what you want.
145    #[default]
146    None,
147    /// Explicit RGBA bitmaps, ideally at several sizes so each platform can
148    /// pick  -  see [`TrayIconData::best_icon`].
149    ///
150    /// Only needed for an icon that genuinely is not in a pack  -  typically one
151    /// generated at runtime, since the icon registry is frozen once the
152    /// provider is shared (`App::run` consumes the handle).
153    Rgba(TrayIconImageVec),
154    /// An **icon spec**  -  exactly the string an `<icon>` node takes: a bare
155    /// name (`"settings"`), a pack-qualified name (`"mypack:logo"`), or a
156    /// comma-separated fallback list (`"mypack:logo, settings"`).
157    ///
158    /// This is the preferred form, for two reasons.
159    ///
160    /// It resolves through the SAME registry and resolver `<icon>` DOM nodes
161    /// use, so anything registered there works with no tray-specific icon
162    /// path: Material Icons (the default pack), an image pack loaded from a
163    /// ZIP, or a custom resolver. Because resolution yields a `StyledDom`
164    /// which is then
165    /// rendered, an icon can be anything expressible as a DOM: a font glyph, a
166    /// bitmap, later an SVG or an emoji.
167    ///
168    /// And a spec can be rendered at ANY size on demand, which a fixed bitmap
169    /// cannot: a tray needs the same icon at several sizes and cannot know
170    /// them up front (Windows re-asks at every taskbar DPI: 16/20/24/32; macOS
171    /// wants 18pt, which is 36px on a 2x display; SNI publishes an array).
172    Named(AzString),
173}
174
175/// Hint about what the tray item represents. Maps to SNI's `Category`; Windows
176/// and macOS have no equivalent and ignore it.
177#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
178#[repr(C)]
179#[derive(Default)]
180pub enum TrayCategory {
181    #[default]
182    ApplicationStatus,
183    Communications,
184    SystemServices,
185    Hardware,
186}
187
188impl TrayCategory {
189    /// The exact string the SNI `Category` property expects.
190    #[must_use]
191    pub const fn sni_name(self) -> &'static str {
192        match self {
193            Self::ApplicationStatus => "ApplicationStatus",
194            Self::Communications => "Communications",
195            Self::SystemServices => "SystemServices",
196            Self::Hardware => "Hardware",
197        }
198    }
199}
200
201/// Attention state. Maps to SNI's `Status`.
202///
203/// On Windows and macOS only `Passive` is meaningful (it hides the icon);
204/// `Active` and `NeedsAttention` both simply show it, because neither platform
205/// has a "demanding attention" tray state.
206#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
207#[repr(C)]
208#[derive(Default)]
209pub enum TrayStatus {
210    /// The host MAY hide the item.
211    Passive,
212    #[default]
213    Active,
214    /// Draws attention; SNI hosts swap in `AttentionIcon`.
215    NeedsAttention,
216}
217
218impl TrayStatus {
219    #[must_use]
220    pub const fn sni_name(self) -> &'static str {
221        match self {
222            Self::Passive => "Passive",
223            Self::Active => "Active",
224            Self::NeedsAttention => "NeedsAttention",
225        }
226    }
227}
228
229/// What the user did to the tray icon.
230#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
231#[repr(C)]
232pub enum TrayEventType {
233    /// Primary activation. **Do not document a precise gesture**: it is left
234    /// click or keyboard Enter on Windows, a click on macOS, and entirely
235    /// desktop-dependent on Linux (the SNI spec does not specify it).
236    Activate,
237    /// Middle click on Windows/Linux; not emitted on macOS.
238    SecondaryActivate,
239    /// The user asked for the context menu. On Windows and macOS we then open
240    /// it; **on Linux the panel already opened it itself** from the exported
241    /// dbusmenu, so this is informational there.
242    ContextMenu,
243    /// Scroll wheel over the icon. Linux only (SNI `Scroll`); Windows and
244    /// macOS never emit it.
245    Scroll,
246    /// A menu item was chosen. Carries the item's command id.
247    MenuItem,
248}
249
250/// Scroll axis for [`TrayEventType::Scroll`].
251#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
252#[repr(C)]
253#[derive(Default)]
254pub enum TrayScrollAxis {
255    #[default]
256    Vertical,
257    Horizontal,
258}
259
260/// One thing that happened on the tray icon.
261#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
262#[repr(C)]
263pub struct TrayEvent {
264    pub kind: TrayEventType,
265    /// Set for [`TrayEventType::MenuItem`]; the chosen item's command id.
266    pub menu_command: u32,
267    /// Set for [`TrayEventType::Scroll`].
268    pub scroll_delta: i32,
269    pub scroll_axis: TrayScrollAxis,
270}
271
272impl TrayEvent {
273    #[must_use]
274    pub const fn simple(kind: TrayEventType) -> Self {
275        Self {
276            kind,
277            menu_command: 0,
278            scroll_delta: 0,
279            scroll_axis: TrayScrollAxis::Vertical,
280        }
281    }
282    #[must_use]
283    pub const fn menu_item(command: u32) -> Self {
284        Self {
285            kind: TrayEventType::MenuItem,
286            menu_command: command,
287            scroll_delta: 0,
288            scroll_axis: TrayScrollAxis::Vertical,
289        }
290    }
291}
292
293impl_option!(
294    TrayEvent,
295    OptionTrayEvent,
296    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
297);
298
299/// Everything a tray icon shows.
300///
301/// This is *state*: set it, mutate it, and the backend republishes. It is
302/// deliberately not a set of imperative calls, because the Linux backend has
303/// to be able to answer the panel's `GetLayout` at any moment.
304#[derive(Debug, Clone, PartialEq)]
305#[repr(C)]
306pub struct TrayIconData {
307    /// Stable identifier for this app's tray item. Used as SNI's `Id`, and as
308    /// the seed for macOS's `NSStatusItem.autosaveName` (which is what makes
309    /// the item keep its position in the menu bar across launches).
310    ///
311    /// Must be stable across runs and must NOT be derived from a path or pid.
312    /// Reverse-DNS is the convention: `"org.example.myapp"`.
313    pub id: AzString,
314    /// Human-readable application name (SNI `Title`).
315    pub title: AzString,
316    /// Where the icon's pixels come from  -  a named icon-pack entry (preferred)
317    /// or explicit RGBA bitmaps.
318    pub icon: TrayIconSource,
319    /// Shown when `status == NeedsAttention` on SNI hosts. Ignored elsewhere.
320    pub attention_icon: TrayIconSource,
321    pub tooltip: OptionString,
322    pub category: TrayCategory,
323    pub status: TrayStatus,
324    /// The retained context menu. `None` means "no menu": on Windows and macOS
325    /// a context-menu request then just reports [`TrayEventType::ContextMenu`],
326    /// and on Linux the `Menu` property is left unset and hosts fall back to
327    /// calling `ContextMenu()`.
328    pub menu: OptionMenu,
329}
330
331impl Default for TrayIconData {
332    fn default() -> Self {
333        Self {
334            id: AzString::from_const_str("azul.tray"),
335            title: AzString::from_const_str(""),
336            icon: TrayIconSource::None,
337            attention_icon: TrayIconSource::None,
338            tooltip: OptionString::None,
339            category: TrayCategory::ApplicationStatus,
340            status: TrayStatus::Active,
341            menu: OptionMenu::None,
342        }
343    }
344}
345
346impl TrayIconData {
347    #[must_use]
348    pub fn new(id: AzString, title: AzString) -> Self {
349        Self {
350            id,
351            title,
352            ..Self::default()
353        }
354    }
355
356    /// Use an icon from the icon registry, by the same spec an `<icon>` node
357    /// takes  -  `"settings"`, `"mypack:logo"`, or a fallback list. Preferred:
358    /// it renders at whatever size each platform asks for.
359    #[must_use]
360    pub fn with_named_icon(mut self, spec: AzString) -> Self {
361        self.icon = TrayIconSource::Named(spec);
362        self
363    }
364
365    /// Use an explicit RGBA bitmap. Prefer [`Self::with_named_icon`] unless the
366    /// icon genuinely is not in a pack.
367    #[must_use]
368    pub fn with_icon(mut self, icon: TrayIconImage) -> Self {
369        self.icon = TrayIconSource::Rgba(TrayIconImageVec::from_vec(alloc::vec![icon]));
370        self
371    }
372
373    #[must_use]
374    pub fn with_menu(mut self, menu: Menu) -> Self {
375        self.menu = OptionMenu::Some(menu);
376        self
377    }
378
379    #[must_use]
380    pub fn with_tooltip(mut self, tooltip: AzString) -> Self {
381        self.tooltip = OptionString::Some(tooltip);
382        self
383    }
384
385    /// The icon closest to `target_px`, preferring the smallest one that is at
386    /// least `target_px` (upscaling a small icon looks far worse than
387    /// downscaling a large one  -  this is why Windows' own `LoadIconMetric`
388    /// scales down from a larger frame rather than up).
389    /// Only meaningful for [`TrayIconSource::Rgba`]; a `Named` icon is
390    /// rasterized at the exact size instead, so it never needs picking.
391    ///
392    /// Returns an owned `OptionTrayIconImage` rather than `Option<&_>` because
393    /// a borrow cannot cross the C ABI. The clone is one `U8Vec` bump.
394    #[must_use]
395    pub fn best_icon(&self, target_px: u32) -> OptionTrayIconImage {
396        let TrayIconSource::Rgba(ref icons) = self.icon else {
397            return OptionTrayIconImage::None;
398        };
399        let icons = icons.as_ref();
400        icons
401            .iter()
402            .filter(|i| i.width >= target_px)
403            .min_by_key(|i| i.width)
404            .or_else(|| icons.iter().max_by_key(|i| i.width))
405            .cloned()
406            .into()
407    }
408}
409
410#[cfg(test)]
411#[path = "tray_test.rs"]
412mod tray_test;