Skip to content

System Tray: Finalize API#11575

Open
tronical wants to merge 36 commits intofeature/systrayfrom
simon/systray-api
Open

System Tray: Finalize API#11575
tronical wants to merge 36 commits intofeature/systrayfrom
simon/systray-api

Conversation

@tronical
Copy link
Copy Markdown
Member

@tronical tronical commented Apr 29, 2026

As concluded in #6053 (comment) , this public API in Rust, C++, etc. when exporting a SystemTray sub-class should not be a standard Window/ComponentHandle.

This PR does that and fixes up various other parts of the API match what we want, including visible property, conditional menu support, and a rename to SystemTrayIcon.

@tronical tronical requested a review from ogoffart April 29, 2026 12:36
Copy link
Copy Markdown
Member

@ogoffart ogoffart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, appart from the fact that run doesn't use the SlintContext from the system tray.

In fact, do we even want run in the SystemTray API? I believe it might have been a mistake to have that on the component instead of asking to use slint::run_event_loop

(side question, but i guess the system tray counts as a "window" as far as slint is concerned for the quitting the application when all window are hidden)

Comment thread internal/compiler/generator/rust.rs Outdated

pub fn run(&self) -> ::core::result::Result<(), slint::PlatformError> {
self.show()?;
slint::run_event_loop()?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no context for a SystemTray?
Maybe there should be.

Comment thread internal/compiler/generator/rust.rs Outdated
Comment on lines +411 to +417
pub fn show(&self) -> ::core::result::Result<(), slint::PlatformError> {
self.0.globals.get().unwrap().window_adapter_ref()?.window().show()
}

pub fn hide(&self) -> ::core::result::Result<(), slint::PlatformError> {
self.0.globals.get().unwrap().window_adapter_ref()?.window().hide()
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still use a window

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, my plan was to route this to the system tray itself in a follow-up. But I'll then move that into this PR.

Comment thread internal/compiler/generator/rust.rs Outdated
}
}
)
} else {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this code is shared. Can it be put in a #component_impl or something like that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried splitting it first but it became quite an ugly quote! mess. Let me see how it looks like after the next round.

Comment thread internal/interpreter/api.rs Outdated
@tronical
Copy link
Copy Markdown
Member Author

Looks good, appart from the fact that run doesn't use the SlintContext from the system tray.

In fact, do we even want run in the SystemTray API? I believe it might have been a mistake to have that on the component instead of asking to use slint::run_event_loop

I'm fine with removing run. I was trying to keep it generic, but then I'll special case everything exactly for the SystemTray then. I wanted to do the removal of the Item nature of the SystemTray in a separate PR, but as it looks that will also have to go into this one then.

(side question, but i guess the system tray counts as a "window" as far as slint is concerned for the quitting the application when all window are hidden)

Sounds okay to me.

Comment thread internal/compiler/llr/item_tree.rs Outdated
@tronical tronical marked this pull request as draft April 30, 2026 06:40
@tronical
Copy link
Copy Markdown
Member Author

Converting this back to a draft as more work is needed to not use a window.

@github-actions github-actions Bot temporarily deployed to slintpad-staging April 30, 2026 06:54 Inactive
@tronical tronical force-pushed the simon/systray-api branch from 2a19329 to d1de060 Compare April 30, 2026 12:10
@github-actions github-actions Bot temporarily deployed to slintpad-staging April 30, 2026 12:53 Inactive
@tronical tronical force-pushed the simon/systray-api branch from 2417f3a to e5625e9 Compare April 30, 2026 12:54
@github-actions github-actions Bot temporarily deployed to slintpad-staging April 30, 2026 13:15 Inactive
@github-actions github-actions Bot temporarily deployed to slintpad-staging April 30, 2026 21:43 Inactive
@github-actions github-actions Bot temporarily deployed to slintpad-staging April 30, 2026 22:04 Inactive
@github-actions github-actions Bot temporarily deployed to slintpad-staging May 1, 2026 05:53 Inactive
@github-actions github-actions Bot temporarily deployed to slintpad-staging May 1, 2026 06:41 Inactive
@github-actions github-actions Bot temporarily deployed to slintpad-staging May 1, 2026 07:01 Inactive
@github-actions github-actions Bot temporarily deployed to slintpad-staging May 1, 2026 07:29 Inactive
tronical added 30 commits May 4, 2026 16:25
Mirror the Rust/C++ codegen change for the dynamic interpreter path
that backs Node, Python, and live-preview. `ComponentInstance::show`/
`hide` for a SystemTray-rooted component now set the `visible`
property rather than going through the (non-real) window adapter;
the change-tracker on the SystemTray native item dispatches to the
platform handle.

`run()` still exists on the trait but is unchanged here — invoking
it on a tray-rooted instance would already panic via the window
adapter, matching the pre-existing limitation.
SNI has no hide operation, so toggling visibility means dropping the
registered handle (deregisters from the watcher) and spawning a fresh
one on show. Move the icon, title, event sender, and menu cache onto
PlatformTray so a respawn can rebuild the KsniTray without the caller
having to re-supply state.
Previously the icon and title properties were only read once at handle
creation. Add a SystemTrayHandle::set_icon / set_title pair and watch
the corresponding properties via change trackers, so mutations made
after the tray is up reach the platform backend. Implemented for ksni
(via Handle::update); stubbed on AppKit and Windows alongside the
existing set_visible TODOs.
NSStatusItem has a real visibility toggle since macOS 10.12, so the
AppKit backend doesn't need the deregister/respawn dance the SNI
backend has to do. Just forward to setVisible:.
Shell_NotifyIconW supports toggling the icon's visibility via
NIM_MODIFY with NIF_STATE + NIS_HIDDEN, which keeps the icon
registered (HWND, HICON, tooltip, and the WM_TRAYICON routing all
survive). That's a real toggle, not a re-add, so unlike the SNI
backend there's no recreation and no flicker.
Replace the set_icon / set_title stubs with real implementations using
Shell_NotifyIconW(NIM_MODIFY). The hicon and tooltip slots on Inner are
now interior-mutable so the TaskbarCreated re-add path in the wnd proc
sees the latest values. On set_icon failure the new HICON is freed
rather than leaked; on success the previous one is destroyed only after
the modify is confirmed.
Forward set_icon and set_title to the status item button's setImage and
setToolTip. On icon-conversion failure the previous image is left in
place rather than blanked.
A tray-only program no longer materializes a hidden `WindowAdapter`
as a side effect of constructing the component.
…ponents

Mirror the Rust parent commit: tray-rooted public components no longer
eagerly invoke `self->m_globals.window()` in `create`.
Annotate SystemTray with //-is_non_item_type so ElementType::lookup_property
returns Invalid for any property not declared on the builtin instead of
falling through to the auto-injected reserved_properties (x, y, width,
height, z, padding, layout-info, accessibility, ...). Setting those on a
tray would have no effect at runtime — the OS shell decides where to place
the tray icon — so a clear 'Unknown property' diagnostic at compile time
is preferable to silent dead code.

Same annotation pattern as Menu / MenuItem / MenuSeparator / MenuBar,
which are all logically non-rendered items.
A SystemTray must be the root of an exported component: the platform
tray APIs only know how to bind to a top-level SystemTray-rooted
component, so nesting one inside another element has no meaningful
lowering. Promote this case to a compile-time error in
`warn_about_child_windows` (sibling to the existing Window-child
warning) and add a syntax test covering both the direct-builtin case
and the user-subclass case.

Also harden the `lower_menus` pass to only call `process_system_tray`
on elements whose `base_type` is the directly-Builtin SystemTray. Before
this change, a still-Component subclass child (`MyTray {}`) would slip
through the `builtin_type()` filter and trip an `as_builtin()` panic
because lower_menus runs before inlining. The legitimate root case is
unaffected — `visit_all_used_components` enters user components
directly, where the root_element IS the Builtin SystemTray.
Spell out how the generated handle differs from a Window-rooted one:
no `run()`, no `window()`, and `show()`/`hide()` route through the
`visible` property. Include a table comparing the two, the per-platform
behavior of `show`/`hide`, Rust and C++ entry-point examples driving
the event loop themselves, and a note that a tray-only program never
materializes a `WindowAdapter` even though it still goes through the
backend selector for platform initialization.
The property is the hover-text shown over the tray icon on every
backend. Calling it "title" was a leak of SNI's terminology — SNI's
`Title` is a separate descriptive-name slot, not the hover tooltip —
and it didn't match what users see on macOS or Windows, where the
property had always mapped to a real OS tooltip (NSStatusItem.button
toolTip / Shell_NotifyIcon szTip). Rename the slint property to
`tooltip` so the name matches the user-visible behavior.

On Linux, also fix the SNI mapping: the tooltip now populates the SNI
`ToolTip` property (i.e. the hover slot the spec actually intends),
not SNI `Title`. The SNI `Title` slot is left at its default for
now; a separate `title` property targeting it is a future addition.

The rename touches the builtin declaration, the native item, all
three backends (ksni / AppKit / Win32), the rust test case, and the
reference page.
Separate from `tooltip` (the hover text), `title` is the descriptive
name shown next to / for the icon. The platform mapping reflects what
each shell actually renders:

  - Linux (SNI): populate `Tray::title()`, the descriptive-name slot
    watchers use for accessibility and overflow listings.
  - macOS: `NSStatusBarButton.setTitle:` — a real visible text label
    next to the icon (battery indicator, clock, mixer-style).
  - Windows: no native equivalent; the notification area renders only
    the icon. The `set_title` hook on the Win32 backend is a documented
    no-op so a cross-platform binding still compiles.

Plumb the property through `Params`, `SystemTrayHandle::set_title`,
the `title_tracker` change tracker, and each backend; document the
per-platform behavior in the reference page.
The Slint event loop already auto-quits when the last window is hidden,
via a counter on `SlintContext` (`window_count`) that
`WindowInner::show`/`hide` increment and decrement. Extend the same
mechanism so a visible system tray icon counts too, and an invisible
one doesn't:

  - Add `acquire_keepalive` / `release_keepalive` helpers on
    `SlintContext` that wrap the counter math + post-decrement
    quit-on-zero. `WindowInner::show`/`hide` now call them in place
    of the inline logic.
  - On `SystemTray`, track an explicit `keepalive_live: Cell<bool>`
    and reconcile it through a small `update_keepalive` method that
    fires from the icon-driven tracker (when the platform handle comes
    up) and from the `visible_tracker` (on every visibility flip).
    The tray contributes to the counter exactly while it has a live
    platform handle and `visible == true`.
  - `SystemTrayData::drop` releases the counter if the tray was still
    live, so dropping the last tray-only program quits the loop.

This makes a tray-only program work without anyone explicitly calling
`quit_event_loop`, and a hybrid window+tray program keep the loop
alive across hide/show cycles of either side.
Update the public docs everywhere the "last window closed" trigger is
described. With the previous commit a visible SystemTray contributes to
the same keepalive counter as a visible window, so the loop quits only
once nothing is visible — windows or tray icons.

Touches:

  - `slint::run_event_loop` and `slint::run_event_loop_until_quit` in the
    Rust API doc-comments.
  - `EventLoopMode` and `slint::run_event_loop` in the C++ header.
  - `ComponentHandle.run` and `runEventLoop`'s `quitOnLastWindowClosed`
    parameter doc in the NodeJS binding.
  - The SystemTray reference page, which had been telling tray-only users
    to "drive the event loop yourself". They still call
    `run_event_loop` directly, but the loop now stays alive while the
    tray is visible without needing `run_event_loop_until_quit".
Replace the icon-only demo with a window that drives the tray and a
tray that drives the window:

  - Window has LineEdit / CheckBox controls for the tray's tooltip,
    title, and visibility, plus a 'Hide to tray' button and a Quit
    button. Edits propagate via per-property *-changed callbacks.
  - ExampleTray exposes `tray-tooltip` / `tray-title` /
    `tray-visible` two-way-aliased to the inherited SystemTray
    properties, so Rust gets typed `set_*` accessors. The inherited
    properties on the builtin aren't auto-exposed on the public
    component handle.
  - Left-click on the tray (or the 'Show / hide window' menu item)
    toggles the window via `window().is_visible()`. The OS close
    button still hides via the default
    `CloseRequestResponse::HideWindow`, so the tray's keepalive
    keeps the loop running until the user picks Quit.
  - The window has an 'activation log' line that records the most
    recent tray click, so it's obvious the wiring is live.
Move the .slint sources out of the inline `slint::slint!` macro into a
dedicated `system_tray.slint` so the same UI and tray definitions can be
loaded from every binding. Add main.{cpp,py,js} entrypoints with
matching CMakeLists.txt / pyproject.toml / package.json scaffolding,
mirroring the layout of examples/memory.

Per-binding caveats:

  - C++: gated behind `SLINT_FEATURE_EXPERIMENTAL` in the example's
    CMakeLists. The host examples/CMakeLists.txt now picks up
    examples/system_tray/ alongside the other compiler-driven examples.
  - Python: `window` is not yet exposed on the binding's component
    handle, so the toggle tracks the window's visibility in a small
    Python-side cell. Sets `SLINT_ENABLE_EXPERIMENTAL_FEATURES=1`
    before importing slint.
  - Node.js: uses `main_window.window` for the visibility check, which
    is conditionally installed for Window-rooted components.
    Sets `process.env.SLINT_ENABLE_EXPERIMENTAL_FEATURES` before
    importing slint-ui.

The Rust main.rs is now a thin wiring layer (12 lines of slint! are
gone) that loads the same .slint file the other bindings load.
Drop the line in `typeregister::TypeRegister::builtin` that removes
`SystemTray` from the non-experimental element set. SystemTray and
its plumbing are now stable enough — visibility, icon/title/tooltip
mapping, the keepalive integration, and per-platform behaviour are all
landed and documented — that hiding it behind
`SLINT_ENABLE_EXPERIMENTAL_FEATURES` no longer adds anything.

Tidy up everything that was setting the env var only to unlock
SystemTray:

  - examples/system_tray: delete build.rs (and its slint-build dep) and
    drop the env-var lines in main.py / main.js. The C++ CMakeLists
    drops its EXPERIMENTAL feature gate.
  - api/node and api/python tests: stop setting the env var in the
    'non-windowed components have no `window` property' /
    'test_system_tray_has_no_window_attribute' tests.

Test drivers (test-driver-rust / interpreter / cpp / nodejs / python)
keep setting `SLINT_ENABLE_EXPERIMENTAL_FEATURES` because they need
it for other experimental features.
Mass rename of the slint type and its supporting Rust / C++ symbols.
The new name is more descriptive about what the element actually
controls (an icon in the system tray), and matches conventions like
NSStatusItem on macOS.

Renamed identifiers:

  - .slint type: `SystemTray` -> `SystemTrayIcon`
  - Rust types in internal/core/items/system_tray.rs:
      `SystemTray` -> `SystemTrayIcon`
      `SystemTrayHandle` -> `SystemTrayIconHandle`
      `SystemTrayData` -> `SystemTrayIconData`
      `SystemTrayDataBox` -> `SystemTrayIconDataBox`
      `SystemTrayVTable` -> `SystemTrayIconVTable`
  - LLR enum: `TopLevelComponentType::SystemTray` -> `SystemTrayIcon`
  - Builtin function: `SetupSystemTray` -> `SetupSystemTrayIcon`
  - Helpers: `inherits_system_tray` -> `inherits_system_tray_icon`,
    `process_system_tray` -> `process_system_tray_icon`
  - FFI symbols: `slint_system_tray_*` -> `slint_system_tray_icon_*`

Updated:

  - all .slint files (tests, examples, docs)
  - internal compiler / interpreter / codegen
  - C++ binding header + cbindgen config
  - Node TypeScript wrapper + python tests
  - examples/system_tray (the directory name and the .slint filename
    are kept; only the type name inside changes)
  - docs reference page renamed
    docs/.../reference/window/systemtray.mdx -> systemtrayicon.mdx
    plus linkMap entry in internal/core-macros/link-data.json updated to
    "SystemTrayIcon" / "reference/window/systemtrayicon/". The single
    cross-reference in globals.mdx now points at the new key.

Verified:
  - cargo check -p i-slint-compiler / -p slint-interpreter / -p i-slint-core
  - cargo test -p i-slint-compiler --test syntax_tests
  - SLINT_TEST_FILTER=systemtray cargo test -p test-driver-rust --test elements
  - astro check (docs type-check) reports 0 errors / warnings
Mirror the existing MenuBar pattern: `if cond : Menu { ... }` inside a
SystemTrayIcon is now lowered to a closure that gates the menu's shadow
tree, so the platform menu is empty (no popup contents) while the
condition is false and re-populates when it flips back to true.

  - lower_menus.rs: take the `repeated` info off the Menu element; the
    conditional case threads its model expression as a trailing argument
    to `SetupSystemTrayIcon`. The `for`-loop case keeps erroring, with
    the message tightened to "cannot be in a repeated element".
  - generator/rust.rs: SetupSystemTrayIcon codegen destructures with
    `rest @ ..` and switches between `MenuFromItemTree::new` and
    `new_with_condition`, mirroring SetupMenuBar's shape.
  - generator/cpp.rs: the same; `create_menu_wrapper` already takes the
    optional condition pointer.
  - interpreter/eval.rs: forward `rest.first()` as the
    `condition: Option<&Expression>` argument to `make_menu_item_tree`.

Updated the syntax test (case A is now a positive test for
`if show-menu : Menu { ... }`; case B keeps the error with the new
phrasing) and added a runtime test
`tests/cases/elements/systemtray_conditional_menu.slint` that toggles
the condition. Both `elements_systemtray_menu` and
`elements_systemtray_conditional_menu` pass under
`SLINT_TEST_FILTER=systemtray cargo test -p test-driver-rust --test elements`.
Wire the `NSStatusItem` button's target/action to the `MenuAction`
ObjC class so click events are routed back to slint's `activated`
callback. AppKit only invokes the action when no menu is set on the
status item, so:

  - A `SystemTrayIcon` with no `Menu` declared gets no `setMenu`
    call (we never enter `rebuild_menu` for that case), the action
    fires on click, and `activated` is invoked. This brings macOS in
    line with Linux/Windows for the common menu-less tray.
  - A `SystemTrayIcon` with `if cond : Menu { ... }` works the same
    way dynamically: `rebuild_menu` now calls `setMenu(None)` when the
    wrapper reports zero entries (which is what the conditional
    evaluates to while the condition is false), and `setMenu(Some)`
    when entries are present. AppKit forwards clicks to the action
    while the menu is detached and pops the menu open while it's
    attached.

Updated the activation-behavior table in the SystemTrayIcon reference
page to drop the "macOS doesn't fire `activated`" caveat and describe
the new semantics.

Cross-checked clean for `aarch64-apple-darwin`. No public-API change.
Wire a `tray-menu-enabled` checkbox in the window through to a
`menu-enabled` property on `ExampleTray`, which now wraps the slint
`Menu` in `if root.menu-enabled : Menu { ... }`. Toggling the checkbox
exercises the conditional-menu lowering and, on macOS specifically,
the new `setMenu(None)` <-> button-action behavior:

  - menu-enabled = true  -> Menu attached. Click on the tray pops the
    menu open. `activated` does not fire.
  - menu-enabled = false -> the conditional reports zero entries, the
    appkit backend detaches the NSMenu, and a click on the tray fires
    `activated` (which routes through `toggle-window` and updates the
    activation log so the change is visible).

The wiring follows the same shape as the existing tray-{title,tooltip,
visible} state pushes — one initial sync plus one `*-changed` callback
per binding (Rust / C++ / NodeJS / Python).
…cing the quit() handler with the typicaly window show pattern
So a `SystemTrayIcon`-rooted component still constructs on Android,
WASM, and other targets without a real tray; the icon simply never
reaches a host shell.
Follow the PR #11356 pattern: the SystemTrayIcon documentation now
lives as doc comments on the component in builtins.slint, with a
`\doc-file:window/systemtrayicon.mdx` annotation; the standalone
mdx is auto-generated by slint-doc-generator and ignored by git.

The generator gains an auto-import for `Tabs` / `TabItem` (used in the
"Language Bindings" footer to show per-language code in tabs), and the
SystemTrayIcon properties are reordered to match the doc order.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants