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;