// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 //! System tray integration. //! //! This module hosts the `SystemTrayIcon` native item (the element exposed to `.slint`) and //! wraps the platform-specific tray icon backends: `ksni` on Linux/BSD, AppKit //! (`NSStatusBar` / `NSStatusItem`) on macOS, and `Shell_NotifyIconW` on Windows. #![allow(unsafe_code)] use crate::cursor::MouseCursorInner; use crate::graphics::Image; use crate::input::{ FocusEvent, FocusEventResult, InputEventFilterResult, InputEventResult, InternalKeyEvent, KeyEventResult, MouseEvent, }; use crate::item_rendering::CachedRenderingData; use crate::items::{ColorScheme, Item, ItemConsts, ItemRc, Orientation, RenderingResult, VoidArg}; use crate::layout::LayoutInfo; use crate::lengths::{LogicalRect, LogicalSize}; #[cfg(feature = "rtti")] use crate::rtti::*; use crate::window::WindowAdapter; use crate::{Callback, Coord, Property, SharedString}; use alloc::boxed::Box; use alloc::rc::Rc; use const_field_offset::FieldOffsets; use core::pin::Pin; use i_slint_core_macros::*; // Pick the per-platform tray backend. The `dummy` arm catches anything without a // real native tray (the `system-tray` feature is off, or Android, WASM, embedded // targets, …) so a `SystemTrayIcon`-rooted component constructs without surfacing // an icon to any host shell. cfg_if::cfg_if! { if #[cfg(all(feature = "system-tray", target_os = "macos"))] { mod appkit; use self::appkit::PlatformTray; } else if #[cfg(all(feature = "system-tray", target_os = "windows"))] { mod windows; use self::windows::PlatformTray; } else if #[cfg(all(feature = "system-tray", target_family = "unix", not(target_vendor = "apple"), not(target_os = "android")))] { mod ksni; use self::ksni::PlatformTray; } else { mod dummy; use self::dummy::PlatformTray; } } /// Parameters passed to the platform-specific tray backend when building a tray icon. pub struct Params<'a> { pub icon: &'a Image, pub tooltip: &'a str, pub title: &'a str, } /// Errors raised while constructing a platform tray icon. #[allow(dead_code)] #[derive(Debug, derive_more::Display)] pub enum Error { #[display("Failed to create a rgba8 buffer from an icon image")] Rgba8, #[display("{_0}")] PlatformError(crate::platform::PlatformError), #[display("{_0}")] EventLoopError(crate::api::EventLoopError), #[display( "no system tray backend compiled in (missing `system-tray` feature or unsupported platform)" )] Unsupported, } /// Owning handle to a live platform tray icon. Dropping it removes the icon. pub struct SystemTrayIconHandle(PlatformTray); impl SystemTrayIconHandle { pub fn new( params: Params, self_weak: crate::item_tree::ItemWeak, context: &crate::SlintContext, ) -> Result { PlatformTray::new(params, self_weak, context).map(Self) } pub fn rebuild_menu( &self, menu: vtable::VRef<'_, crate::menus::MenuVTable>, entries_out: &mut alloc::vec::Vec, ) { self.0.rebuild_menu(menu, entries_out); } pub fn set_visible(&self, visible: bool) { self.0.set_visible(visible); } pub fn set_icon(&self, icon: &Image) { self.0.set_icon(icon); } pub fn set_tooltip(&self, tooltip: &str) { self.0.set_tooltip(tooltip); } pub fn set_title(&self, title: &str) { self.0.set_title(title); } } // --------------------------------------------------------------------------- // Native `SystemTrayIcon` item, exposed to `.slint`. // --------------------------------------------------------------------------- #[repr(C)] /// Wraps the internal data structure for the SystemTrayIcon pub struct SystemTrayIconDataBox(core::ptr::NonNull); impl Default for SystemTrayIconDataBox { fn default() -> Self { SystemTrayIconDataBox(Box::leak(Box::::default()).into()) } } impl Drop for SystemTrayIconDataBox { fn drop(&mut self) { // Safety: the self.0 was constructed from a Box::leak in SystemTrayIconDataBox::default drop(unsafe { Box::from_raw(self.0.as_ptr()) }); } } impl core::ops::Deref for SystemTrayIconDataBox { type Target = SystemTrayIconData; fn deref(&self) -> &Self::Target { // Safety: initialized in SystemTrayIconDataBox::default unsafe { self.0.as_ref() } } } #[derive(Default)] pub struct SystemTrayIconData { inner: core::cell::OnceCell, change_tracker: crate::properties::ChangeTracker, visible_tracker: crate::properties::ChangeTracker, icon_tracker: crate::properties::ChangeTracker, tooltip_tracker: crate::properties::ChangeTracker, title_tracker: crate::properties::ChangeTracker, /// Whether this tray currently contributes to the SlintContext keepalive /// counter. Flipped in lockstep with `acquire_keepalive`/`release_keepalive` /// so that a re-fired tracker can't double-increment. keepalive_live: core::cell::Cell, menu: core::cell::RefCell>, /// The context of the component this tray belongs to, handed over by the generated /// code. A tray has no window, so this is the only way it can tell which context it /// is part of. Empty for a component built with `new()`, which uses the thread's. context: core::cell::OnceCell, } impl SystemTrayIconData { /// The context this tray belongs to: the one its component was built with, falling /// back to the thread's for a component built with `new()`. fn context(&self) -> Option { match self.context.get() { Some(ctx) => ctx.upgrade(), None => crate::context::GLOBAL_CONTEXT.with(|p| p.get().cloned()), } } } impl Drop for SystemTrayIconData { fn drop(&mut self) { if self.keepalive_live.get() && let Some(ctx) = self.context() { ctx.release_keepalive(); } } } struct MenuState { menu_vrc: vtable::VRc, entries: alloc::vec::Vec, tracker: Pin>>, } struct MenuDirtyHandler { self_weak: crate::item_tree::ItemWeak, } impl crate::properties::PropertyDirtyHandler for MenuDirtyHandler { fn notify(self: Pin<&Self>) { let self_weak = self.self_weak.clone(); let Some(item_rc) = self_weak.upgrade() else { return }; let Some(tray) = item_rc.downcast::() else { return }; let Some(ctx) = tray.as_pin_ref().data.context() else { return }; ctx.single_shot(Default::default(), move || { let Some(item_rc) = self_weak.upgrade() else { return }; let Some(tray) = item_rc.downcast::() else { return }; tray.as_pin_ref().rebuild_menu(); }); } } #[repr(C)] #[derive(FieldOffsets, Default, SlintElement)] #[pin] pub struct SystemTrayIcon { pub icon: Property, pub tooltip: Property, pub title: Property, pub visible: Property, pub color_scheme: Property, pub clicked: Callback, pub cached_rendering_data: CachedRenderingData, data: SystemTrayIconDataBox, } impl SystemTrayIcon { /// Called from a tray-rooted component's `new_with_context` to tell the item which /// context it belongs to. Without it the item would fall back to the thread's context, /// which is the wrong one when the component was built on another. pub fn set_context(self: Pin<&Self>, ctx: &crate::SlintContext) { let _ = self.data.context.set(ctx.downgrade()); } /// Called from generated code (via the `SetupSystemTrayIcon` builtin) to hand off the /// lowered menu's `VRc` to the native item. The item walks the menu via /// this vtable inside its own `PropertyTracker`, so property changes inside the menu /// tree automatically trigger a rebuild of the platform tray menu. Subsequent calls /// replace any previously installed menu. pub fn set_menu( self: Pin<&Self>, self_rc: &ItemRc, menu_vrc: vtable::VRc, ) { let tracker = Box::pin(crate::properties::PropertyTracker::new_with_dirty_handler( MenuDirtyHandler { self_weak: self_rc.downgrade() }, )); *self.data.menu.borrow_mut() = Some(MenuState { menu_vrc, entries: alloc::vec::Vec::new(), tracker }); // If the platform tray is already up (icon was set before the menu), populate // the menu now; otherwise the icon tracker's notify will call rebuild_menu // once the handle exists. self.rebuild_menu(); } fn rebuild_menu(self: Pin<&Self>) { let Some(handle) = self.data.inner.get() else { return }; let mut menu_borrow = self.data.menu.borrow_mut(); let Some(MenuState { menu_vrc, entries, tracker }) = menu_borrow.as_mut() else { return; }; tracker.as_ref().evaluate(|| { handle.rebuild_menu(vtable::VRc::borrow(menu_vrc), entries); }); } pub fn set_color_scheme(self: Pin<&Self>, scheme: ColorScheme) { Self::FIELD_OFFSETS.color_scheme().apply_pin(self).set(scheme); } /// Reconcile the SlintContext keepalive counter with this tray's state. /// A tray contributes to the counter only while it has a live platform /// handle and its `visible` property is `true`; everything else is a /// no-op so a re-fired tracker can't double-increment. fn update_keepalive(self: Pin<&Self>) { let want_live = self.data.inner.get().is_some() && self.visible(); let was_live = self.data.keepalive_live.get(); if want_live == was_live { return; } let Some(ctx) = self.data.context() else { return; }; if want_live { ctx.acquire_keepalive(); self.data.keepalive_live.set(true); } else { self.data.keepalive_live.set(false); ctx.release_keepalive(); } } } impl Item for SystemTrayIcon { fn init(self: Pin<&Self>, self_rc: &ItemRc) { self.data.change_tracker.init_delayed( self_rc.downgrade(), |_| true, |self_weak, has_icon| { let Some(tray_rc) = self_weak.upgrade() else { return; }; let Some(tray) = tray_rc.downcast::() else { return; }; if !*has_icon { return; } let Some(ctx) = tray.as_pin_ref().data.context() else { return; }; let tray = tray.as_pin_ref(); let handle = match SystemTrayIconHandle::new( Params { icon: &tray.icon(), tooltip: &tray.tooltip(), title: &tray.title() }, self_weak.clone(), &ctx, ) { Ok(handle) => handle, Err(err) => { crate::debug_log!("Slint: Failed to create system tray icon: {err}"); return; } }; let _ = tray.data.inner.set(handle); // If a menu was already installed before the icon was set, build it now // that we have a platform handle. tray.rebuild_menu(); tray.update_keepalive(); }, ); self.data.visible_tracker.init_delayed( self_rc.downgrade(), |self_weak| { let Some(tray_rc) = self_weak.upgrade() else { return false }; let Some(tray) = tray_rc.downcast::() else { return false }; tray.as_pin_ref().visible() }, |self_weak, visible| { let Some(tray_rc) = self_weak.upgrade() else { return }; let Some(tray) = tray_rc.downcast::() else { return }; let tray = tray.as_pin_ref(); if let Some(handle) = tray.data.inner.get() { handle.set_visible(*visible); } tray.update_keepalive(); // If the platform handle isn't up yet, the icon-driven init path // will create it later and call update_keepalive itself. }, ); // Push live icon / title changes through to the platform handle. The // initial spawn always uses the latest values (the icon-driven init // path reads them at fire time), so these trackers are no-ops until // the user mutates the property after the tray is up. self.data.icon_tracker.init_delayed( self_rc.downgrade(), |self_weak| { let Some(tray_rc) = self_weak.upgrade() else { return Image::default() }; let Some(tray) = tray_rc.downcast::() else { return Image::default(); }; tray.as_pin_ref().icon() }, |self_weak, icon| { let Some(tray_rc) = self_weak.upgrade() else { return }; let Some(tray) = tray_rc.downcast::() else { return }; if let Some(handle) = tray.as_pin_ref().data.inner.get() { handle.set_icon(icon); } }, ); self.data.tooltip_tracker.init_delayed( self_rc.downgrade(), |self_weak| { let Some(tray_rc) = self_weak.upgrade() else { return SharedString::default() }; let Some(tray) = tray_rc.downcast::() else { return SharedString::default(); }; tray.as_pin_ref().tooltip() }, |self_weak, tooltip| { let Some(tray_rc) = self_weak.upgrade() else { return }; let Some(tray) = tray_rc.downcast::() else { return }; if let Some(handle) = tray.as_pin_ref().data.inner.get() { handle.set_tooltip(tooltip.as_str()); } }, ); self.data.title_tracker.init_delayed( self_rc.downgrade(), |self_weak| { let Some(tray_rc) = self_weak.upgrade() else { return SharedString::default() }; let Some(tray) = tray_rc.downcast::() else { return SharedString::default(); }; tray.as_pin_ref().title() }, |self_weak, title| { let Some(tray_rc) = self_weak.upgrade() else { return }; let Some(tray) = tray_rc.downcast::() else { return }; if let Some(handle) = tray.as_pin_ref().data.inner.get() { handle.set_title(title.as_str()); } }, ); } fn deinit(self: Pin<&Self>, _window_adapter: &Rc) {} fn layout_info( self: Pin<&Self>, _orientation: Orientation, _cross_axis_constraint: Coord, _window_adapter: &Rc, _self_rc: &ItemRc, ) -> LayoutInfo { LayoutInfo::default() } fn input_event_filter_before_children( self: Pin<&Self>, _: &MouseEvent, _window_adapter: &Rc, _self_rc: &ItemRc, _: &mut MouseCursorInner, ) -> InputEventFilterResult { InputEventFilterResult::ForwardAndIgnore } fn input_event( self: Pin<&Self>, _: &MouseEvent, _window_adapter: &Rc, _self_rc: &ItemRc, _: &mut MouseCursorInner, ) -> InputEventResult { InputEventResult::EventIgnored } fn capture_key_event( self: Pin<&Self>, _: &InternalKeyEvent, _window_adapter: &Rc, _self_rc: &ItemRc, ) -> KeyEventResult { KeyEventResult::EventIgnored } fn key_event( self: Pin<&Self>, _: &InternalKeyEvent, _window_adapter: &Rc, _self_rc: &ItemRc, ) -> KeyEventResult { KeyEventResult::EventIgnored } fn focus_event( self: Pin<&Self>, _: &FocusEvent, _window_adapter: &Rc, _self_rc: &ItemRc, ) -> FocusEventResult { FocusEventResult::FocusIgnored } fn render( self: Pin<&Self>, _backend: &mut &mut dyn crate::item_rendering::ItemRenderer, _self_rc: &ItemRc, _size: LogicalSize, ) -> RenderingResult { RenderingResult::ContinueRenderingChildren } fn bounding_rect( self: core::pin::Pin<&Self>, _window_adapter: &Rc, _self_rc: &ItemRc, geometry: LogicalRect, ) -> LogicalRect { geometry } fn clips_children(self: core::pin::Pin<&Self>) -> bool { false } } impl ItemConsts for SystemTrayIcon { const cached_rendering_data_offset: const_field_offset::FieldOffset = Self::FIELD_OFFSETS.cached_rendering_data().as_unpinned_projection(); } /// # Safety /// This must be called using a non-null pointer pointing to a chunk of memory big enough to /// hold a SystemTrayIconDataBox #[cfg(feature = "ffi")] #[unsafe(no_mangle)] pub unsafe extern "C" fn slint_system_tray_icon_data_init(data: *mut SystemTrayIconDataBox) { unsafe { core::ptr::write(data, SystemTrayIconDataBox::default()) }; } /// # Safety /// This must be called using a non-null pointer pointing to an initialized SystemTrayIconDataBox #[cfg(feature = "ffi")] #[unsafe(no_mangle)] pub unsafe extern "C" fn slint_system_tray_icon_data_free(data: *mut SystemTrayIconDataBox) { unsafe { core::ptr::drop_in_place(data) }; } #[cfg(feature = "ffi")] #[unsafe(no_mangle)] pub unsafe extern "C" fn slint_system_tray_icon_set_menu( system_tray: &SystemTrayIcon, item_rc: &ItemRc, menu_vrc: &vtable::VRc, ) { unsafe { Pin::new_unchecked(system_tray) }.set_menu(item_rc, menu_vrc.clone()); }