Skip to main content

i_slint_core/items/
system_tray.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4//! System tray integration.
5//!
6//! This module hosts the `SystemTrayIcon` native item (the element exposed to `.slint`) and
7//! wraps the platform-specific tray icon backends: `ksni` on Linux/BSD, AppKit
8//! (`NSStatusBar` / `NSStatusItem`) on macOS, and `Shell_NotifyIconW` on Windows.
9
10#![allow(unsafe_code)]
11
12use crate::cursor::MouseCursorInner;
13use crate::graphics::Image;
14use crate::input::{
15    FocusEvent, FocusEventResult, InputEventFilterResult, InputEventResult, InternalKeyEvent,
16    KeyEventResult, MouseEvent,
17};
18use crate::item_rendering::CachedRenderingData;
19use crate::items::{ColorScheme, Item, ItemConsts, ItemRc, Orientation, RenderingResult, VoidArg};
20use crate::layout::LayoutInfo;
21use crate::lengths::{LogicalRect, LogicalSize};
22#[cfg(feature = "rtti")]
23use crate::rtti::*;
24use crate::window::WindowAdapter;
25use crate::{Callback, Coord, Property, SharedString};
26use alloc::boxed::Box;
27use alloc::rc::Rc;
28use const_field_offset::FieldOffsets;
29use core::pin::Pin;
30use i_slint_core_macros::*;
31
32// Pick the per-platform tray backend. The `dummy` arm catches anything without a
33// real native tray (the `system-tray` feature is off, or Android, WASM, embedded
34// targets, …) so a `SystemTrayIcon`-rooted component constructs without surfacing
35// an icon to any host shell.
36cfg_if::cfg_if! {
37    if #[cfg(all(feature = "system-tray", target_os = "macos"))] {
38        mod appkit;
39        use self::appkit::PlatformTray;
40    } else if #[cfg(all(feature = "system-tray", target_os = "windows"))] {
41        mod windows;
42        use self::windows::PlatformTray;
43    } else if #[cfg(all(feature = "system-tray", target_family = "unix", not(target_vendor = "apple"), not(target_os = "android")))] {
44        mod ksni;
45        use self::ksni::PlatformTray;
46    } else {
47        mod dummy;
48        use self::dummy::PlatformTray;
49    }
50}
51
52/// Parameters passed to the platform-specific tray backend when building a tray icon.
53pub struct Params<'a> {
54    pub icon: &'a Image,
55    pub tooltip: &'a str,
56    pub title: &'a str,
57}
58
59/// Errors raised while constructing a platform tray icon.
60#[allow(dead_code)]
61#[derive(Debug, derive_more::Display)]
62pub enum Error {
63    #[display("Failed to create a rgba8 buffer from an icon image")]
64    Rgba8,
65    #[display("{_0}")]
66    PlatformError(crate::platform::PlatformError),
67    #[display("{_0}")]
68    EventLoopError(crate::api::EventLoopError),
69    #[display(
70        "no system tray backend compiled in (missing `system-tray` feature or unsupported platform)"
71    )]
72    Unsupported,
73}
74
75/// Owning handle to a live platform tray icon. Dropping it removes the icon.
76pub struct SystemTrayIconHandle(PlatformTray);
77
78impl SystemTrayIconHandle {
79    pub fn new(
80        params: Params,
81        self_weak: crate::item_tree::ItemWeak,
82        context: &crate::SlintContext,
83    ) -> Result<Self, Error> {
84        PlatformTray::new(params, self_weak, context).map(Self)
85    }
86
87    pub fn rebuild_menu(
88        &self,
89        menu: vtable::VRef<'_, crate::menus::MenuVTable>,
90        entries_out: &mut alloc::vec::Vec<crate::items::MenuEntry>,
91    ) {
92        self.0.rebuild_menu(menu, entries_out);
93    }
94
95    pub fn set_visible(&self, visible: bool) {
96        self.0.set_visible(visible);
97    }
98
99    pub fn set_icon(&self, icon: &Image) {
100        self.0.set_icon(icon);
101    }
102
103    pub fn set_tooltip(&self, tooltip: &str) {
104        self.0.set_tooltip(tooltip);
105    }
106
107    pub fn set_title(&self, title: &str) {
108        self.0.set_title(title);
109    }
110}
111
112// ---------------------------------------------------------------------------
113// Native `SystemTrayIcon` item, exposed to `.slint`.
114// ---------------------------------------------------------------------------
115
116#[repr(C)]
117/// Wraps the internal data structure for the SystemTrayIcon
118pub struct SystemTrayIconDataBox(core::ptr::NonNull<SystemTrayIconData>);
119
120impl Default for SystemTrayIconDataBox {
121    fn default() -> Self {
122        SystemTrayIconDataBox(Box::leak(Box::<SystemTrayIconData>::default()).into())
123    }
124}
125impl Drop for SystemTrayIconDataBox {
126    fn drop(&mut self) {
127        // Safety: the self.0 was constructed from a Box::leak in SystemTrayIconDataBox::default
128        drop(unsafe { Box::from_raw(self.0.as_ptr()) });
129    }
130}
131
132impl core::ops::Deref for SystemTrayIconDataBox {
133    type Target = SystemTrayIconData;
134    fn deref(&self) -> &Self::Target {
135        // Safety: initialized in SystemTrayIconDataBox::default
136        unsafe { self.0.as_ref() }
137    }
138}
139
140#[derive(Default)]
141pub struct SystemTrayIconData {
142    inner: core::cell::OnceCell<SystemTrayIconHandle>,
143    change_tracker: crate::properties::ChangeTracker,
144    visible_tracker: crate::properties::ChangeTracker,
145    icon_tracker: crate::properties::ChangeTracker,
146    tooltip_tracker: crate::properties::ChangeTracker,
147    title_tracker: crate::properties::ChangeTracker,
148    /// Whether this tray currently contributes to the SlintContext keepalive
149    /// counter. Flipped in lockstep with `acquire_keepalive`/`release_keepalive`
150    /// so that a re-fired tracker can't double-increment.
151    keepalive_live: core::cell::Cell<bool>,
152    menu: core::cell::RefCell<Option<MenuState>>,
153    /// The context of the component this tray belongs to, handed over by the generated
154    /// code. A tray has no window, so this is the only way it can tell which context it
155    /// is part of. Empty for a component built with `new()`, which uses the thread's.
156    context: core::cell::OnceCell<crate::SlintContextWeak>,
157}
158
159impl SystemTrayIconData {
160    /// The context this tray belongs to: the one its component was built with, falling
161    /// back to the thread's for a component built with `new()`.
162    fn context(&self) -> Option<crate::SlintContext> {
163        match self.context.get() {
164            Some(ctx) => ctx.upgrade(),
165            None => crate::context::GLOBAL_CONTEXT.with(|p| p.get().cloned()),
166        }
167    }
168}
169
170impl Drop for SystemTrayIconData {
171    fn drop(&mut self) {
172        if self.keepalive_live.get()
173            && let Some(ctx) = self.context()
174        {
175            ctx.release_keepalive();
176        }
177    }
178}
179
180struct MenuState {
181    menu_vrc: vtable::VRc<crate::menus::MenuVTable>,
182    entries: alloc::vec::Vec<crate::items::MenuEntry>,
183    tracker: Pin<Box<crate::properties::PropertyTracker<false, MenuDirtyHandler>>>,
184}
185
186struct MenuDirtyHandler {
187    self_weak: crate::item_tree::ItemWeak,
188}
189
190impl crate::properties::PropertyDirtyHandler for MenuDirtyHandler {
191    fn notify(self: Pin<&Self>) {
192        let self_weak = self.self_weak.clone();
193        let Some(item_rc) = self_weak.upgrade() else { return };
194        let Some(tray) = item_rc.downcast::<SystemTrayIcon>() else { return };
195        let Some(ctx) = tray.as_pin_ref().data.context() else { return };
196        ctx.single_shot(Default::default(), move || {
197            let Some(item_rc) = self_weak.upgrade() else { return };
198            let Some(tray) = item_rc.downcast::<SystemTrayIcon>() else { return };
199            tray.as_pin_ref().rebuild_menu();
200        });
201    }
202}
203
204#[repr(C)]
205#[derive(FieldOffsets, Default, SlintElement)]
206#[pin]
207pub struct SystemTrayIcon {
208    pub icon: Property<Image>,
209    pub tooltip: Property<SharedString>,
210    pub title: Property<SharedString>,
211    pub visible: Property<bool>,
212    pub color_scheme: Property<ColorScheme>,
213    pub clicked: Callback<VoidArg>,
214    pub cached_rendering_data: CachedRenderingData,
215    data: SystemTrayIconDataBox,
216}
217
218impl SystemTrayIcon {
219    /// Called from a tray-rooted component's `new_with_context` to tell the item which
220    /// context it belongs to. Without it the item would fall back to the thread's context,
221    /// which is the wrong one when the component was built on another.
222    pub fn set_context(self: Pin<&Self>, ctx: &crate::SlintContext) {
223        let _ = self.data.context.set(ctx.downgrade());
224    }
225
226    /// Called from generated code (via the `SetupSystemTrayIcon` builtin) to hand off the
227    /// lowered menu's `VRc<MenuVTable>` to the native item. The item walks the menu via
228    /// this vtable inside its own `PropertyTracker`, so property changes inside the menu
229    /// tree automatically trigger a rebuild of the platform tray menu. Subsequent calls
230    /// replace any previously installed menu.
231    pub fn set_menu(
232        self: Pin<&Self>,
233        self_rc: &ItemRc,
234        menu_vrc: vtable::VRc<crate::menus::MenuVTable>,
235    ) {
236        let tracker = Box::pin(crate::properties::PropertyTracker::new_with_dirty_handler(
237            MenuDirtyHandler { self_weak: self_rc.downgrade() },
238        ));
239        *self.data.menu.borrow_mut() =
240            Some(MenuState { menu_vrc, entries: alloc::vec::Vec::new(), tracker });
241        // If the platform tray is already up (icon was set before the menu), populate
242        // the menu now; otherwise the icon tracker's notify will call rebuild_menu
243        // once the handle exists.
244        self.rebuild_menu();
245    }
246
247    fn rebuild_menu(self: Pin<&Self>) {
248        let Some(handle) = self.data.inner.get() else { return };
249        let mut menu_borrow = self.data.menu.borrow_mut();
250        let Some(MenuState { menu_vrc, entries, tracker }) = menu_borrow.as_mut() else {
251            return;
252        };
253        tracker.as_ref().evaluate(|| {
254            handle.rebuild_menu(vtable::VRc::borrow(menu_vrc), entries);
255        });
256    }
257
258    pub fn set_color_scheme(self: Pin<&Self>, scheme: ColorScheme) {
259        Self::FIELD_OFFSETS.color_scheme().apply_pin(self).set(scheme);
260    }
261
262    /// Reconcile the SlintContext keepalive counter with this tray's state.
263    /// A tray contributes to the counter only while it has a live platform
264    /// handle and its `visible` property is `true`; everything else is a
265    /// no-op so a re-fired tracker can't double-increment.
266    fn update_keepalive(self: Pin<&Self>) {
267        let want_live = self.data.inner.get().is_some() && self.visible();
268        let was_live = self.data.keepalive_live.get();
269        if want_live == was_live {
270            return;
271        }
272        let Some(ctx) = self.data.context() else {
273            return;
274        };
275        if want_live {
276            ctx.acquire_keepalive();
277            self.data.keepalive_live.set(true);
278        } else {
279            self.data.keepalive_live.set(false);
280            ctx.release_keepalive();
281        }
282    }
283}
284
285impl Item for SystemTrayIcon {
286    fn init(self: Pin<&Self>, self_rc: &ItemRc) {
287        self.data.change_tracker.init_delayed(
288            self_rc.downgrade(),
289            |_| true,
290            |self_weak, has_icon| {
291                let Some(tray_rc) = self_weak.upgrade() else {
292                    return;
293                };
294                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else {
295                    return;
296                };
297                if !*has_icon {
298                    return;
299                }
300                let Some(ctx) = tray.as_pin_ref().data.context() else {
301                    return;
302                };
303                let tray = tray.as_pin_ref();
304                let handle = match SystemTrayIconHandle::new(
305                    Params { icon: &tray.icon(), tooltip: &tray.tooltip(), title: &tray.title() },
306                    self_weak.clone(),
307                    &ctx,
308                ) {
309                    Ok(handle) => handle,
310                    Err(err) => {
311                        crate::debug_log!("Slint: Failed to create system tray icon: {err}");
312                        return;
313                    }
314                };
315
316                let _ = tray.data.inner.set(handle);
317                // If a menu was already installed before the icon was set, build it now
318                // that we have a platform handle.
319                tray.rebuild_menu();
320                tray.update_keepalive();
321            },
322        );
323
324        self.data.visible_tracker.init_delayed(
325            self_rc.downgrade(),
326            |self_weak| {
327                let Some(tray_rc) = self_weak.upgrade() else { return false };
328                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else { return false };
329                tray.as_pin_ref().visible()
330            },
331            |self_weak, visible| {
332                let Some(tray_rc) = self_weak.upgrade() else { return };
333                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else { return };
334                let tray = tray.as_pin_ref();
335                if let Some(handle) = tray.data.inner.get() {
336                    handle.set_visible(*visible);
337                }
338                tray.update_keepalive();
339                // If the platform handle isn't up yet, the icon-driven init path
340                // will create it later and call update_keepalive itself.
341            },
342        );
343
344        // Push live icon / title changes through to the platform handle. The
345        // initial spawn always uses the latest values (the icon-driven init
346        // path reads them at fire time), so these trackers are no-ops until
347        // the user mutates the property after the tray is up.
348        self.data.icon_tracker.init_delayed(
349            self_rc.downgrade(),
350            |self_weak| {
351                let Some(tray_rc) = self_weak.upgrade() else { return Image::default() };
352                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else {
353                    return Image::default();
354                };
355                tray.as_pin_ref().icon()
356            },
357            |self_weak, icon| {
358                let Some(tray_rc) = self_weak.upgrade() else { return };
359                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else { return };
360                if let Some(handle) = tray.as_pin_ref().data.inner.get() {
361                    handle.set_icon(icon);
362                }
363            },
364        );
365
366        self.data.tooltip_tracker.init_delayed(
367            self_rc.downgrade(),
368            |self_weak| {
369                let Some(tray_rc) = self_weak.upgrade() else { return SharedString::default() };
370                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else {
371                    return SharedString::default();
372                };
373                tray.as_pin_ref().tooltip()
374            },
375            |self_weak, tooltip| {
376                let Some(tray_rc) = self_weak.upgrade() else { return };
377                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else { return };
378                if let Some(handle) = tray.as_pin_ref().data.inner.get() {
379                    handle.set_tooltip(tooltip.as_str());
380                }
381            },
382        );
383
384        self.data.title_tracker.init_delayed(
385            self_rc.downgrade(),
386            |self_weak| {
387                let Some(tray_rc) = self_weak.upgrade() else { return SharedString::default() };
388                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else {
389                    return SharedString::default();
390                };
391                tray.as_pin_ref().title()
392            },
393            |self_weak, title| {
394                let Some(tray_rc) = self_weak.upgrade() else { return };
395                let Some(tray) = tray_rc.downcast::<SystemTrayIcon>() else { return };
396                if let Some(handle) = tray.as_pin_ref().data.inner.get() {
397                    handle.set_title(title.as_str());
398                }
399            },
400        );
401    }
402
403    fn deinit(self: Pin<&Self>, _window_adapter: &Rc<dyn WindowAdapter>) {}
404
405    fn layout_info(
406        self: Pin<&Self>,
407        _orientation: Orientation,
408        _cross_axis_constraint: Coord,
409        _window_adapter: &Rc<dyn WindowAdapter>,
410        _self_rc: &ItemRc,
411    ) -> LayoutInfo {
412        LayoutInfo::default()
413    }
414
415    fn input_event_filter_before_children(
416        self: Pin<&Self>,
417        _: &MouseEvent,
418        _window_adapter: &Rc<dyn WindowAdapter>,
419        _self_rc: &ItemRc,
420        _: &mut MouseCursorInner,
421    ) -> InputEventFilterResult {
422        InputEventFilterResult::ForwardAndIgnore
423    }
424
425    fn input_event(
426        self: Pin<&Self>,
427        _: &MouseEvent,
428        _window_adapter: &Rc<dyn WindowAdapter>,
429        _self_rc: &ItemRc,
430        _: &mut MouseCursorInner,
431    ) -> InputEventResult {
432        InputEventResult::EventIgnored
433    }
434
435    fn capture_key_event(
436        self: Pin<&Self>,
437        _: &InternalKeyEvent,
438        _window_adapter: &Rc<dyn WindowAdapter>,
439        _self_rc: &ItemRc,
440    ) -> KeyEventResult {
441        KeyEventResult::EventIgnored
442    }
443
444    fn key_event(
445        self: Pin<&Self>,
446        _: &InternalKeyEvent,
447        _window_adapter: &Rc<dyn WindowAdapter>,
448        _self_rc: &ItemRc,
449    ) -> KeyEventResult {
450        KeyEventResult::EventIgnored
451    }
452
453    fn focus_event(
454        self: Pin<&Self>,
455        _: &FocusEvent,
456        _window_adapter: &Rc<dyn WindowAdapter>,
457        _self_rc: &ItemRc,
458    ) -> FocusEventResult {
459        FocusEventResult::FocusIgnored
460    }
461
462    fn render(
463        self: Pin<&Self>,
464        _backend: &mut &mut dyn crate::item_rendering::ItemRenderer,
465        _self_rc: &ItemRc,
466        _size: LogicalSize,
467    ) -> RenderingResult {
468        RenderingResult::ContinueRenderingChildren
469    }
470
471    fn bounding_rect(
472        self: core::pin::Pin<&Self>,
473        _window_adapter: &Rc<dyn WindowAdapter>,
474        _self_rc: &ItemRc,
475        geometry: LogicalRect,
476    ) -> LogicalRect {
477        geometry
478    }
479
480    fn clips_children(self: core::pin::Pin<&Self>) -> bool {
481        false
482    }
483}
484
485impl ItemConsts for SystemTrayIcon {
486    const cached_rendering_data_offset: const_field_offset::FieldOffset<Self, CachedRenderingData> =
487        Self::FIELD_OFFSETS.cached_rendering_data().as_unpinned_projection();
488}
489
490/// # Safety
491/// This must be called using a non-null pointer pointing to a chunk of memory big enough to
492/// hold a SystemTrayIconDataBox
493#[cfg(feature = "ffi")]
494#[unsafe(no_mangle)]
495pub unsafe extern "C" fn slint_system_tray_icon_data_init(data: *mut SystemTrayIconDataBox) {
496    unsafe { core::ptr::write(data, SystemTrayIconDataBox::default()) };
497}
498
499/// # Safety
500/// This must be called using a non-null pointer pointing to an initialized SystemTrayIconDataBox
501#[cfg(feature = "ffi")]
502#[unsafe(no_mangle)]
503pub unsafe extern "C" fn slint_system_tray_icon_data_free(data: *mut SystemTrayIconDataBox) {
504    unsafe { core::ptr::drop_in_place(data) };
505}
506
507#[cfg(feature = "ffi")]
508#[unsafe(no_mangle)]
509pub unsafe extern "C" fn slint_system_tray_icon_set_menu(
510    system_tray: &SystemTrayIcon,
511    item_rc: &ItemRc,
512    menu_vrc: &vtable::VRc<crate::menus::MenuVTable>,
513) {
514    unsafe { Pin::new_unchecked(system_tray) }.set_menu(item_rc, menu_vrc.clone());
515}