Skip to content

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 — only MenuEntry.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-coding close_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_visible is an Observable[bool] if you need to react to it.
  • Calling hide() before app.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:

  1. PyGObject — the binding that lets Python talk to the desktop libraries (the gi module).
  2. 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:

sudo apt install python3-gi gir1.2-ayatanaappindicator3-0.1

Then let the venv see them, which is not the default:

python -m venv .venv --system-site-packages

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.