501. A tray icon follows the desktop’s theme, and stops when it closes
Date: 2026-09-30
Status
Accepted. Builds the “theme-aware light/dark variants” in docs/core-widgets.md
§9’s tray-icon line, which
ADR-0191 left as a mechanism
without a user. Changes what
ADR-0322’s
Host.onSystemThemeChanged returns.
Context
The TODO entry said nothing paints a tray icon for you and nothing swaps it on a
theme switch. The parts were there: TrayIcon.icon(pixels), BackendTray.icon,
and SDL_SetTrayIcon, bound with a comment saying it is what a theme switch
calls. Nothing called it. An application that wanted §9’s variants had to hold
two PixelBuffers, listen for the setting and rebuild the tray itself.
Writing the swap exposed three facts the entry did not mention:
- The listener could not be released.
Host.onSystemThemeChangedreturned nothing, and its documentation said a listener lives as long as the window. That holds for an application’s own listener. It does not hold for a tray. A tray menu cannot change while the icon is up, so an application that changes its menu closes the tray and shows it again. Each showing that listened would leave one listener behind, holding two pictures and a closed tray. It is the same leak ADR-0498 found in the launcher’s model subscriptions, at a smaller scale.onFullscreenChangedalready returns aSubscription, and for the same reason: whatever listens is gone long before the window. - The setting is not always the panel’s shade. SDL reports the desktop’s
application theme. On Windows that is
AppsUseLightTheme. The taskbar has its ownSystemUsesLightTheme, which SDL does not read. On Linux it is the portal’scolor-scheme, and GNOME’s top bar is dark whichever way that is set. The toolkit can pick a variant only by the signal it has. - A desktop may say nothing.
Host.systemTheme()is empty on a session with no setting, and a pair has to show one of its two pictures there too.
Decision
TrayIcon holds a Picture, sealed, and it is one of two things. A
Single is one buffer, shown whatever the desktop says. A ThemePair is two:
forLightShell and forDarkShell, set by
TrayIcon.icons(forLightShell, forDarkShell). Each is named for the
background it sits on, not for its ink. “The light icon” can mean dark ink for
a light panel or a light mark for a dark one, and the two readings are opposite.
icon(pixels) still makes a Single. A tray with no picture asks for the
platform’s default, as before.
Where the desktop says nothing, a pair shows forLightShell. That is how
CSS reads “no preference”: Media Queries 5 removed prefers-color-scheme: no-preference, and a user agent with no setting matches light.
Trays.show keeps a pair matched to the setting. It starts on the picture
for host.systemTheme(), and on each change it calls BackendTray.icon with the
other one. The menu is not touched, so the rule that a menu cannot change while
the icon is up still holds. The swap is skipped when the picture is already the
one shown, compared by identity, because PixelBuffer is a record and on Linux
every SDL_SetTrayIcon writes a PNG for AppIndicator to read back.
The handle Trays.show returns owns the listening. For a pair it is a
ThemedTray wrapped around the backend’s tray. Three things stop it:
- closing the handle;
- setting an icon through it, because an application that sets a picture of its own (a badge, a busy state) has taken the icon over, and a swap at dusk would undo it;
- the backend closing the tray underneath it, as
HeadlessBackend.closedoes. The next change finds the tray closed and closes the subscription.
A Single tray, or one with no picture, gets the backend’s handle unwrapped and
listens to nothing.
Host.onSystemThemeChanged returns a Subscription. The launcher wraps
each registration in an object of its own and removes it by identity. Closing
one of two registrations of the same listener leaves the other, and closing it
twice is harmless. A lambda would not do, because the JLS does not promise a
fresh instance for one.
The toolkit ships no default mark. A tray icon names the application.
Goldberry’s own mark on every application that forgot to supply one would
misname all of them, which is worse than the desktop’s generic picture. With no
icon the platform shows its own: IDI_APPLICATION on Windows, a blank indicator
on Linux, an empty status item on macOS. An application that wants a picture
has one line to write, and it is the line that decides what the picture is.
Consequences
- An application gets §9’s variants by writing
TrayIcon.of(tooltip, menu).icons(darkInk, lightMark). No listener or rebuild is needed. Host.onSystemThemeChangedchanged its return type fromvoidtoSubscription. Callers that ignore the result compile unchanged. Anything that implementsHostmust return one: the launcher,TestHostandTourTestHosthere.TrayIcon’s first component is nowpicture, aPicture, where it wasicon, aPixelBuffer.TrayIcon.spec()still describes the tray for a desktop that says nothing, andspec(Optional<SystemTheme>)describes it for a given setting.- The pair follows the application theme, not the panel. On GNOME, whose top bar
is dark in both settings, a light setting shows
forLightShellon a dark bar. On Windows it follows the app mode, not the taskbar mode. Neither can be fixed from here without platform code SDL does not have. An application that knows its panel better sets one picture and follows nothing. - Tested headlessly in
TraysTest: a pair starts on the desktop’s setting and onforLightShellwhen there is none; a change swaps the icon on the same tray and leaves its rows alone; closing the tray removes its listener; rebuilding the tray five times leaves one listener; an explicit icon ends the following; a tray closed underneath lets go; a single-icon tray is shown as described and registers nothing.SystemThemeTestchecks through the real launcher that a closed subscription is not told and that closing one of two registrations of a listener leaves the other. - The showcase’s tray has no icon, so it does not use the pair. Giving it one would mean inventing an asset.