RFC 0019 — A window that opens from the tray
Status: implemented (2026-11-03) · Target: 0.32.0 · builds on RFC 0016 · see Decisions
Summary
A small window that opens under the tray icon when it's clicked, and goes away when the user clicks elsewhere: today's hours, a quick note, the next three invoices due. The app's own page, in a window given only what it needs:
App::new()
.tray(
TrayConfig::new()
.tooltip("Timer")
.popover(
WindowConfig::new("quick")
.path("quick")
.size(320.0, 420.0)
.only_abilities(["time.*"]),
)
.item("open", "Open Timer")
.quit("Quit"),
)
.run();
A left click on the icon shows quick under it (above it, with the
taskbar at the bottom); a click elsewhere, Esc, or another click on the
icon hides it. A right click shows the menu, as now.
Motivation
- A glance without the app. Much of what a business app is asked is small: is the timer running, what's due today, jot this down. Bringing up the main window for it is a context switch; a menu can't show it.
- The pieces are there. The tray (
tray-icon) and windows (tao) are in place, and since RFC 0016 a second window can be given less than the app grants: a window over every other app that can only start and stop a timer is a safe one to have. - Every app writes it badly. Placing a window by the icon on each platform, hiding it on blur without the click on the icon reopening it, keeping it out of the taskbar: small, fiddly, and the same every time.
Current state (0.31)
- The tray (
tray.rs,trayfeature):TrayConfigwith a tooltip, a title and a menu of items, a separator and Quit. An item emits its id on the"tray"channel. The icon has its menu on both clicks; clicks on the icon itself aren't listened for. - Windows (
window.rs):WindowConfigwith size, decorations,always_on_top, a path, and its scope (RFC 0016).Windowsopens, shows, hides, focuses, closes. The app exits when its last window closes.elyra:windowtells pages a window's size and focus. tray-icon0.24 sendsTrayIconEvent::Clickwith the icon's rect on macOS and Windows, and can keep the menu off the left click (with_menu_on_left_click(false)). On Linux it sends no click events at all (AppIndicator), and has no rect: the icon only has its menu.- tao 0.35 has what placing needs: the monitor at a point, the cursor's position (not on Wayland), a hidden window, skip-taskbar on Windows and Linux.
Design
TrayConfig::popover(WindowConfig)
The tray has at most one popover, a window like any other: its own label,
path, size and scope. What popover sets on it:
- No decorations, not resizable, always on top, out of the taskbar (Windows, Linux), with a shadow (macOS).
- Made hidden at start, so the first click shows it at once, loaded (open question 2).
- Hides, not closes: a click elsewhere (it loses focus), Esc (in the runtime), or a click on the icon. Its page stays loaded, its live queries keep running, so it's current when shown.
- Doesn't keep the app alive: when the last other window closes, the app exits as now (open question 5).
On macOS and Windows a popover takes the left click and leaves the menu
to the right click (with_menu_on_left_click(false)). Without a popover,
nothing changes.
Where it opens
A pure function places it, so it's tested on every platform:
fn place(icon: Rect, size: Size, monitor: Rect) -> Position
- By the nearest edge of the monitor to the icon: at the top (macOS's menu bar, a top panel) it opens below the icon; at the bottom (the Windows taskbar) above it; at the left or right (a vertical taskbar) beside it. 4 px from the icon.
- Centered on the icon along that edge, then kept inside the monitor with an 8 px margin.
- In physical pixels, as
tray-icongives the rect, on the monitor the icon is on.
Showing and hiding
- A click on the icon (left button, on release) shows it if hidden, and hides it if shown.
- Losing focus hides it. A click on the icon while it's shown first takes its focus, which hides it; the click itself then mustn't show it again. So a click within 300 ms of a hide by blur does nothing.
- Shown, it takes focus, so typing goes to it, and Esc and a click
elsewhere work. On macOS tao's
set_focusactivates the app (activateIgnoringOtherApps); if that brings the main window forward too, the popover is shown without activating the rest, and the build says which it took. - From code:
Windows::show_popover(),hide_popover()andtoggle_popover(), for a global shortcut (App::global_shortcut), say. Without an icon click, it opens where it last opened, or by the cursor, or centered at the top of the main monitor.
On Linux
No clicks reach the app (above), so on Linux the popover opens from the
menu: popover adds an item at the top of the tray's menu, the
window's title ("Timer"), that shows it. It opens by the cursor on X11,
which is by the icon a moment ago; on Wayland, where a window can't place
itself, centered at the top. It hides on blur and Esc as elsewhere
(open question 3).
In the page
elyra:windowsays whether the window isvisible, and is sent when the popover is shown or hidden: a page refreshes, or focuses its input, onappWindow.onState(..).- Esc in a popover hides it (the runtime, unless the page handled it).
appWindow.hide()hides it, as for any window.
Security
- Nothing new is reached: the popover is a window, and RFC 0016 applies. It asks through its own protocol handler, as itself, within its scope; the docs' example narrows it, and the guide says to.
- It's over every other app. It shows only when the user clicks the
icon or the app calls
show_popover, which an app can do for any window today, and hides on the first click elsewhere.
What it doesn't do
- A menu-bar-only app (no main window, no dock icon): a popover is how one would look, but the app's lifetime and activation policy are their own question (open question 5).
- Over a full-screen app on macOS: that takes a window collection behavior tao doesn't set. It opens in the current space, not over a full-screen one.
- More than one tray icon or popover.
- An arrow pointing at the icon, or a translucent background: the page draws its own frame.
Testing
place: the four edges, a corner where it must be pushed inside, a monitor left of or above the main one (negative coordinates), and a window bigger than the room on that side.- Showing and hiding as a small state machine, with the clock given:
click shows, click hides, blur hides, a click soon after a blur doesn't
reopen, one later does;
show_popoverwith no rect yet. - The config: a popover's window is undecorated, on top, hidden at start, out of the taskbar; the menu loses the left click; on Linux the menu gains the item.
- The runtime: Esc hides a popover and not another window;
visibleinelyra:window. - The platforms: CI's matrix compiles the macOS, Windows and Linux code; the example app gets a popover, launch-smoked on macOS, with what isn't seen on Windows and Linux called out as unverified.
Implementation plan
TrayConfig::popover,placeand the show/hide state machine, with their tests.- The event loop: tray clicks, the popover's window, blur, the Linux
menu item;
Windows::show_popoverand the others. - The runtime:
visibleinelyra:window, Esc. - The example app's popover; docs: the tray guide, windows, the changelog.
As built
TrayConfig::popover(window)sets the window undecorated, not resizable, on top, hidden and out of the taskbar (WindowConfighasvisibleandskip_taskbarfor it, usable on any window). The event loop makes it with the others, hidden, and takes the icon's left click (on release) fromTrayIconEvent.Windows::show_popover(),hide_popover()andtoggle_popover().- Placing is
popover::place(by the nearest edge, centered on the icon, 4 px from it, 8 px inside the monitor) and showing and hidingpopover::Popover(300 ms after a hide by blur, a click doesn't reopen it); both pure, and tested. - macOS needed points. tao's "physical" pixels aren't one space there:
a monitor's position is its points times its own scale, the cursor's its
points times the main monitor's,
tray-icon's rect its points times the scale of the monitor the icon is on, andmonitor_from_pointtakes points. On three monitors of mixed scale the popover opened on the wrong one. So on macOS the icon, cursor and monitors are taken back to points (popover::Space) and the popover placed in points; on Windows and X11 physical pixels are one space and used as they are. Checked on a Retina laptop with a scale-1 monitor to its left and a scale-2 one to its right: by the cursor on each, and kept inside in a corner. - macOS brought the main window forward, as the RFC feared: tao's
set_focusactivates the app, and the app's main window came in front of the app in front. The popover is now made key and main first, then the app activated (activateIgnoringOtherApps): only the popover comes forward. Activating without "ignoring other apps" was refused by macOS 14's cooperative activation (the popover showed without focus, and so never hid). Checked against Mail in front: the main window stayed behind it, the popover had focus, and activating Mail hid it, six times out of six from a fresh start. - Linux has
POPOVER_ID, an item at the top of the tray's menu (the window's title), and a separator before the app's items. It opens by the cursor; on Wayland, where tao says 0, 0, at the top of the main monitor. Compiled in CI, not seen. - The page:
__ELYRA_POPOVER__is set in the popover's webview, and the runtime hides it on Esc unless the key was handled. The runtime's own Esc handlers (About, its dialogs, the assistant panel) now callpreventDefault(), so closing one of them in a popover doesn't hide it.elyra:windowhasvisible, sent on show and hide. - A route was a
404. The example's popover is atquick, and a built app answered/quickwith404: the shell served files only, thoughWindowConfig::pathwas documented as deep-linking into the SPA (Vite falls back toindex.htmlinrata dev). A path without a file extension that names no file now getsindex.html. A scaffolded app's router is hash-based, so its popover ispath("#/quick"). - Not seen: a real click on the icon. The test machine's menu bar was
full, so the icon wasn't on screen, and posting clicks needs
accessibility access the test didn't have. The click goes through the
same path as
show_popoverwith the icon's rect; the rect's handling is tested inpopover::Space.
Alternatives considered
WindowConfig::tray_popover()(as first sketched): a flag on any window. But the popover needs the tray's click and its rect, and there's one tray; on the tray's config it can't be set on two windows, or without a tray.- A native popover (
NSPopoveron macOS, a flyout on Windows): the platform's look, with an arrow, but a webview inside a native popover per platform, three times over, and nothing on Linux. - Leave it to apps:
TrayIconEventre-emitted on the bus, and a window the app places itself. The fiddly parts (the blur-then-click race, the taskbar's edge, keeping it on the monitor) are the whole job.
Open questions
- On
TrayConfigorWindowConfig?TrayConfig::popover(window)(recommended: one tray, one popover, and its click) orWindowConfig::tray_popover(). - Made at start, hidden, or on the first click? At start (recommended): the first click shows it at once. It costs a webview for the run (tens of MB), as any open window does. On the first click it costs a moment's blank window, each run.
- On Linux: a menu item that opens it, by the cursor (recommended), or no popover on Linux (its items only)?
- Live queries while it's hidden: keep running (recommended; it's current when shown, at the cost of an open window), or paused until shown?
- Does the popover keep the app running when the main window closes? No (recommended): as now, the app ends with its last ordinary window. A menu-bar-only app, where the tray is the app, is its own RFC: its activation policy on macOS, how it's quit, and how the main window comes back.
- The hide-on-blur window: 300 ms (recommended), or follow the platform's double-click time?
Decisions
Settled on 2026-11-03, all as recommended:
- On
TrayConfig:TrayConfig::popover(window). - Made at start, hidden, so the first click shows it at once.
- On Linux, a menu item opens it, by the cursor (centered at the top on Wayland).
- Live queries keep running while it's hidden.
- It doesn't keep the app running; a menu-bar-only app is its own RFC.
- 300 ms after a hide by blur, a click on the icon doesn't reopen it.