Tray Icon¶
nv.TrayIcon puts your app in the system tray — the right side of the menu
bar on macOS, the notification area on Windows, the status area on Linux. It
is declarative data registered on the App, exactly like the menu bar on a
Window: construct it, hand it to App(window, tray=...), and it is installed
when the app starts and removed when the app exits. It lives exactly as long
as the app runs — it has no lifecycle of its own, and it never changes when
the app exits (that stays the exit_policy's job).
tray = nv.TrayIcon(
icon="assets/tray.png",
tooltip="My Sync App",
menu=[
nv.MenuEntry("Open", on_select=lambda: window.show()),
nv.MenuEntry.separator(),
nv.MenuEntry.quit(),
],
)
app = nv.App(window, tray=tray)
Runnable demos:
samples/window/tray_icon.py
(the basics) and
samples/window/close_to_tray.py
(a resident app).
The menu is the same model as the menu bar¶
menu= takes the same MenuEntry entries as
the menu bar: actions, separators, nested submenus, checkable
items, and Observable label / enabled / checked that update the native
menu live. Two differences:
- Include
MenuEntry.quit(). For a resident app the tray menu is the only exit path while no window is visible; nothing is injected for you. - Window-scoped standard items (
close_window(),minimize(), ...) have no target window in a tray menu and are ignored with a warning.quit()works — it is app-scoped.
The tray menu always renders natively — a Win32 popup on Windows, the
desktop shell's menu on Linux, NSMenu on macOS — because it must work while
no window exists. shortcut= is not displayed there (tray menus have no
accelerator convention).
installed: whether the icon is actually showing¶
tray.installed is an Observable[bool]: False until the backend installs
the icon, True while it shows, False again after removal — or forever, on
a platform that cannot host one. A failed install never takes the app
down; it is logged once and the app runs without a tray. If your app treats
the tray as essential, read installed and decide yourself — fall back to a
normal close button (the recipe below), show a hint, or exit.
Close to tray: the resident-app recipe¶
A resident app — one that hides instead of closing and is summoned from the tray — is three independent declarations. Each keeps its meaning if the others are removed:
tray = nv.TrayIcon(
tooltip="My Sync App",
dock_visibility="auto",
menu=[
nv.MenuEntry("Open", on_select=lambda: window.show()),
nv.MenuEntry.separator(),
nv.MenuEntry.quit(),
],
)
window = nv.Window(
content=build,
close_action=tray.installed.map(lambda ok: "hide" if ok else "close"),
)
app = nv.App(window, tray=tray, exit_policy=nv.ExitPolicy.EXPLICIT)
exit_policy=EXPLICIT— onlyMenuEntry.quit()/app.exit()ends the app. See Multiple Windows for the exit policies; the tray never overrides them.close_action=...— what the OS close button does:"close"(default) destroys the window,"hide"parks it. It accepts an Observable, and the binding above is the important part: hide only while the tray icon is actually showing. If the tray failed to install (see the Linux section), the close button quietly keeps meaning close, and the user is never locked out of an app they cannot reach. Hard-codingclose_action="hide"skips that safety; hiding the last visible window with no tray showing logs a warning.tray=...— the way back in, plus the way out (quit()).
hide() and show()¶
Window.hide() and Window.show() are ordinary window operations, useful
with or without a tray:
- Hidden is not closed: the widget tree, its state, and the window
geometry stay alive, and the window still counts for the exit policy.
show()brings everything back instantly, focused — and on an already-visible window it acts as "summon": raise and refocus. - On Windows and Linux the taskbar entry disappears with the window and returns with it — nothing to configure.
- A hidden window renders no frames, so a parked app costs no idle CPU for drawing.
window.is_visibleis anObservable[bool]if you need to react to it.- Calling
hide()beforeapp.run()makes the window start hidden — the start-in-tray launch shape for sync clients and monitors.
dock_visibility (macOS)¶
On macOS the Dock icon belongs to the app, not to a window, so hiding every
window still leaves the Dock icon by default. TrayIcon(dock_visibility=...)
chooses the policy:
"always"(default) — the app stays in the Dock and Cmd+Tab."auto"— in the Dock only while some window is visible; hide the last window and the app becomes a menu-bar-only presence until summoned. This is the close-to-tray convention."never"— a pure menu-bar extra: no Dock icon, no Cmd+Tab entry, ever.
Windows and Linux ignore this — their taskbars already follow window visibility on their own.
on_activate and platform conventions¶
on_activate= runs when the icon itself is activated the way the platform
does it — but treat it as an optional shortcut and always keep an equivalent
menu entry, because support varies:
| Platform | Gesture |
|---|---|
| macOS | Only without a menu — a menu owns the click there |
| Windows | Double-click |
| Linux (AppIndicator) | Not deliverable (a pystray limitation) |
The icon image¶
Pass icon= a path to a small PNG. On macOS, a filename stem ending in
Template (e.g. trayTemplate.png) is loaded as a template image, so the
system recolors it correctly for light and dark menu bars — the native
convention for menu-bar icons. Without icon=, macOS shows the tooltip text
in the menu bar and the other platforms show a neutral placeholder; ship a
real icon.
Linux needs two system packages¶
Windows and macOS need nothing. On Linux the icon and its menu are drawn by the desktop, and nuiitivet reaches that through two pieces pip cannot supply:
- PyGObject — the binding that lets Python talk to the desktop
libraries (the
gimodule). - The Ayatana AppIndicator typelib — the description of the tray API
itself, named
AyatanaAppIndicator3-0.1(older systems:AppIndicator3-0.1).
Both have to come from the system package manager, so the names differ per distribution — search yours for "PyGObject" and "Ayatana AppIndicator". On Debian and Ubuntu:
Then let the venv see them, which is not the default:
Without all of this a tray carrying a menu refuses to install. installed
stays False, so the recipe above degrades on its own: the close button keeps
meaning close, and nothing hides into an icon that was never there.
GNOME needs one more thing¶
With those packages in place the icon appears on KDE, XFCE and most other desktops. GNOME is the exception — it has nowhere to put tray icons until the AppIndicator extension is installed.
This case is quieter, and worth knowing before you go looking for the bug in
your app: the icon is handed over successfully, so installed is True, but
nothing appears. installed reports that nuiitivet delivered the icon — not
that the desktop decided to show it. On GNOME, check the extension first.