Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tray Icon

Perry ships a cross-platform system tray API on perry/ui (issue #490). The same six functions work on every desktop target — macOS, Windows, Linux/GTK4 — and link as no-ops on the mobile / embedded backends.

The API is handle-based and free-function: build a tray with trayCreate(iconPath), attach a context menu built with the existing menuCreate / menuAddItem API via trayAttachMenu(tray, menu), and register a left-click callback with trayOnClick.

Basic Usage

// Build the tray BEFORE App() — the tray icon installs while the
// runtime is starting up, so it's already live when the main window
// appears.
const tray = trayCreate("")  // empty path → "●" placeholder
traySetTooltip(tray, "My App")

// Right-click (or left-click on macOS) opens the menu attached below.
const menu = menuCreate()
menuAddItem(menu, "Show", () => status.set("tray/show"))
menuAddSeparator(menu)
menuAddItem(menu, "Quit", () => status.set("tray/quit"))
trayAttachMenu(tray, menu)

// Optional: left-click handler. On macOS the menu pops on left-click,
// so this fires only when no menu is attached. On Windows / Linux,
// left-click and the menu are independent — typical usage is
// "left-click → show main window, right-click → menu".
trayOnClick(tray, () => {
    status.set("tray/click")
})

API

FunctionDescription
trayCreate(iconPath: string): WidgetCreate the tray icon. iconPath is a filesystem path to a PNG (or .icns on macOS, .ico on Windows). Pass "" to use a “●” placeholder.
traySetIcon(tray, iconPath)Hot-swap the icon image. Empty path is a no-op.
traySetTooltip(tray, tooltip)Set the tooltip text shown on hover.
trayAttachMenu(tray, menu)Attach a context menu (built with menuCreate / menuAddItem). Right-click — or left-click on macOS — opens the menu.
trayOnClick(tray, callback)Register a left-click handler. On macOS the menu pops on left-click, so this only fires when no menu is attached; on Windows / Linux, left-click and menu are independent.
trayDestroy(tray)Remove the icon. The handle stays valid (subsequent setters are no-ops) so existing closures don’t crash.

Updating the Icon

// Hot-swap the icon. The path can be a PNG (every platform), .icns
// (macOS), or .ico (Windows). Empty path is a no-op.
traySetIcon(tray, "./assets/tray.png")

Removal

// Remove the tray icon. After this, the handle is dead — set_icon /
// set_tooltip / attach_menu calls become no-ops.
trayDestroy(tray)

Platform Notes

PlatformBackendNotes
macOSNSStatusItem from NSStatusBar.systemIcon appears top-right of the menu bar. Click auto-pops the attached menu. Tooltip routes through the button’s toolTip. PNG and .icns paths supported. Icons are rendered as templates — single-color glyphs adapt to light/dark mode.
WindowsShell_NotifyIconW + TrackPopupMenuIcon appears in the notification area (bottom-right). Left-click → onClick callback. Right-click → menu. PNG and .ico paths supported (PNG via LoadImageW with LR_LOADFROMFILE). trayCreate must come after App({...}) since the tray reuses the main window’s WndProc.
Linux/GTK4StatusNotifierItem (KSNI) over D-BusWorks on KDE Plasma, GNOME-with-appindicator-extension, XFCE, Cinnamon, MATE, Budgie, LXQt out of the box. Vanilla GNOME without the extension keeps the service alive but the icon doesn’t render — a one-line warning logs at create time.
iOS / tvOS / visionOS / watchOSno-opThese platforms have no system-tray concept. Calls link cleanly and return 0 / no-op so cross-platform code compiles unchanged.
Androidno-opAndroid’s “tray” is the notifications shade, which is a different concept. The functions link as no-ops.
HarmonyOSno-opAuto-stubbed at compile time.
Webno-op (warns)Browser tabs have no tray equivalent.

Click vs. Menu

Different desktops have different click conventions; Perry exposes both hooks so a single TypeScript app can do the right thing everywhere:

PlatformLeft-clickRight-click
macOSPops the attached menuSame as left-click
WindowsFires onClickPops the attached menu
LinuxFires onClick (KSNI activate)Pops the attached menu

The typical pattern: use onClick to “show / focus the main window” and attachMenu for the user-facing actions. macOS users will see the menu pop on every click, which is the platform-native behavior.

Common Patterns

Background app (no Dock icon, tray-only)

On macOS, set the activation policy to "accessory" so the app has no Dock icon and lives only as a tray-resident process. (See the platform docs for activation-policy details.)

Building the menu after the tray

The menu lookup on every backend happens at click time, not at attach time. This means you can rebuild the menu (menuClear + fresh menuAddItem calls) between user clicks — the new menu wins on the next click without re-attaching.

Next Steps

  • Menus — Full menu / submenu / shortcut API used by trayAttachMenu
  • State Management — Make tray menu items react to app state
  • Multi-Window — Show / hide windows from tray actions