tray

package module
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: BSD-3-Clause Imports: 17 Imported by: 0

README

tray — cross-platform system tray for go-widgets

A system-tray / menu-bar icon with menus, submenus, checkboxes and separators. A tray is OS-integration, not a pixel-blitted widget, so it lives outside the pure-blitting toolkit and drives the native APIs through a small Backend interface — all CGO_ENABLED=0:

platform native API mechanism
darwin NSStatusItem + NSMenu/NSMenuItem purego + the Objective-C runtime
windows Shell_NotifyIcon + TrackPopupMenu golang.org/x/sys/windows syscalls
linux StatusNotifierItem + com.canonical.dbusmenu pure-Go DBus

Usage

menu := tray.NewMenu().Add(
    tray.Item("Open", func() { open() }),
    tray.IconItem("Pause", pausePNG, func() { pause() }), // a glyph beside the label

    tray.Checkbox("Notifications", true, func(on bool) { setNotify(on) }),
    tray.SubMenu("Recent", tray.NewMenu().Add(tray.Item("file.txt", nil))),
    tray.Separator(),
    tray.Item("Quit", func() { t.Quit() }),
)

t := tray.New(iconPNG).SetTooltip("My App").SetMenu(menu)
t.OnReady(func() { /* live */ })
t.Run() // blocks on the platform event loop until Quit

Status

  • Core (Tray, Menu, MenuItem, item activation/toggle, Backend interface, headless backend) — done, 100% covered, builds on every arch.
  • Native backends are on by default. tray.New(icon).SetMenu(m).Run() puts an icon in the menu bar of the platform you built for, with no build tag and nothing else to know.
    • darwinNSStatusItem + NSMenu via ebitengine/purego, CGO=0. Runtime-confirmed on a real macOS session: the item is the thing that leaves the menu bar when the program stops and comes back when it starts, and clicking it opens its menu.
    • windows / linux — implemented and compile-verified; runtime confirmation pending. They ignore MenuItem.Icon: a row carrying one draws as it did before, which is a gap, not a promise kept.
    • anything else — defaultBackend is nil and Run reports ErrNoBackend, which is the difference between "there is no tray here" and "your tray silently does nothing".

A caller that wants no native tray at all — a test, a headless service — passes one in: WithBackend(tray.NewHeadless()).

It used to need a build tag, and the tag was on the wrong thing

The native backends were opt-in behind -tags tray_native, so that the core could keep a 100% coverage figure over the whole package. The cost was paid by every caller: Run returned ErrNoBackend, nothing appeared anywhere, and nothing said why. A program that did the obvious thing got a tray that quietly did not exist.

The coverage gate now selects by SHAPE instead — everything that is not a platform file (_darwin, _linux, _windows, _android, _js, _other) is held at 100% — which is what the rest of this fleet does, and which gates a new portable file the day it is written rather than the day somebody remembers it. A library's default behaviour is not the place to keep its CI tidy.

BSD-3-Clause. Copyright the go-widgets authors.

Documentation

Overview

Package tray is a cross-platform system-tray (menu-bar) widget for go-widgets.

A tray icon is OS-integration, not a pixel-blitted widget, so it cannot live in the pure-blitting toolkit. This package models the tray, its menu and menu items in a platform-agnostic core, and drives them through a small Backend interface implemented per-OS:

  • darwin: NSStatusItem + NSMenu via purego + the Objective-C runtime
  • windows: Shell_NotifyIcon + TrackPopupMenu via x/sys/windows
  • linux: StatusNotifierItem + com.canonical.dbusmenu over DBus

All CGO_ENABLED=0. A headless backend backs tests and display-less CI.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoApplication reports that +[NSApplication sharedApplication] yielded
	// nil, which on a Mac with a window server means one thing: AppKit is not
	// loaded in this process, so the NSApplication class does not exist and the
	// class lookup returned the nil class.
	ErrNoApplication = errors.New("tray: +[NSApplication sharedApplication] returned nil (AppKit is not loaded in this process)")

	// ErrNoStatusBar reports that +[NSStatusBar systemStatusBar] yielded nil.
	// There is no menu bar to put anything in: no window server, or a session
	// that has none.
	ErrNoStatusBar = errors.New("tray: +[NSStatusBar systemStatusBar] returned nil (no menu bar in this session)")

	// ErrNoStatusItem reports that -[NSStatusBar statusItemWithLength:] yielded
	// nil: the menu bar exists but refused this process an item in it.
	ErrNoStatusItem = errors.New("tray: -[NSStatusBar statusItemWithLength:] returned nil (the menu bar refused this process an item)")

	// ErrNoTargetClass reports that the Objective-C class carrying the menu
	// action could not be created. Every clickable row needs an instance of it
	// as its target, and a row whose target is nil draws perfectly and answers
	// no click.
	ErrNoTargetClass = errors.New("tray: the Objective-C action target class could not be created")
)

Errors reported by the native macOS backend when AppKit hands back nothing. They are stable and may be tested with errors.Is.

View Source
var ErrNoBackend = errors.New("tray: no backend for this platform")

ErrNoBackend is returned by Run when no platform backend has been set (and none was selected for the current OS).

Functions

func BindIcon added in v0.5.0

func BindIcon[S comparable](t *Tray, state *mvvm.Observable[S], icons Icons[S], period time.Duration) (stop func())

BindIcon makes the tray's icon follow state.

Whenever state changes the icon becomes that state's entry, animating through its frames over period when there is more than one. It is a package function rather than a method because a method cannot carry its own type parameter, and the state a caller watches is theirs to name — a string, an enum, a bool.

A state with no entry leaves the icon alone rather than blanking it: a tray that goes blank on an unmapped state looks broken, and it is the caller's map that is incomplete, not the tray.

The returned function stops the animation and unsubscribes. It is safe to call more than once, and it must be called: neither the goroutine nor the subscription ends on its own.

func IsTemplate added in v0.7.0

func IsTemplate(iconPNG []byte) bool

IsTemplate reports whether these PNG bytes should be drawn as a TEMPLATE image: a shape the platform recolours to suit its menu bar.

It is decided from the picture rather than asked of the caller, because the answer is IN the picture. A monochrome glyph is a template -- macOS draws it dark on a light bar, light on a dark one, white while the item is pressed, and correct in a tinted bar without anybody choosing a colour. An icon that carries colour is not: a template is recoloured, so a green dot meant to say "this is running" would come out the same shade as everything around it, and the one thing it was for would be gone.

Anything that cannot be decoded is treated as a template, which is what every icon was before this existed.

Types

type Backend

type Backend interface {
	// Run shows the tray and blocks on the platform event loop until Quit.
	Run(t *Tray) error
	// Refresh re-applies the tray's icon, tooltip and menu after a change.
	Refresh(t *Tray)
	// Quit stops the event loop started by Run.
	Quit()
}

Backend drives a Tray on a specific platform.

type Headless

type Headless struct {
	Started   bool
	Refreshes int
	LastIcon  []byte
	LastTip   string
	LastMenu  *Menu
	// contains filtered or unexported fields
}

Headless is a display-less Backend for tests and CI. It records the tray state applied to it and blocks Run until Quit, so a tray can be exercised end-to-end without a real desktop session.

func NewHeadless

func NewHeadless() *Headless

NewHeadless returns a ready headless backend.

func (*Headless) Quit

func (h *Headless) Quit()

Quit unblocks Run (idempotent).

func (*Headless) Refresh

func (h *Headless) Refresh(t *Tray)

Refresh snapshots the tray's current state.

func (*Headless) Run

func (h *Headless) Run(t *Tray) error

Run marks the tray started, snapshots its state, signals readiness and blocks until Quit.

func (*Headless) Snapshot added in v0.5.0

func (h *Headless) Snapshot() (icon []byte, tip string, menu *Menu)

Snapshot returns the state this backend last recorded, under the lock.

Reading the fields directly is safe only while nothing can refresh concurrently. That used to be every caller; an icon bound to an observable state refreshes from the animator's own goroutine, so a reader that wants to watch it happen needs this.

type Icons added in v0.5.0

type Icons[S comparable] map[S][][]byte

Icons is what the menu bar shows for each state of whatever the tray is watching: one entry per state, and each entry is one or more PNG frames.

A single frame is a still icon. Several frames are an animation, which is the point — "something is happening" is the one thing a menu bar can say without the user opening anything, and a still icon cannot say it.

type Menu struct {
	Items []*MenuItem
}

Menu is an ordered list of items.

func NewMenu

func NewMenu() *Menu

NewMenu returns an empty menu.

func (m *Menu) Add(items ...*MenuItem) *Menu

Add appends items and returns the menu for chaining.

func (m *Menu) Find(path ...int) *MenuItem

Find returns the item at the given path of indices (descending into submenus), or nil if the path is invalid.

type MenuItem struct {
	Label     string
	Tooltip   string
	Checked   bool
	Disabled  bool
	Separator bool
	// Icon is a small PNG drawn to the left of the label, in the same encoding
	// as the tray's own icon (see [New]). Nil leaves the row text-only.
	//
	// It is PNG bytes rather than an image.Image for the same reason the tray
	// icon is: a caller ships one artefact that every backend decodes, instead
	// of each backend agreeing on a pixel layout with the caller.
	//
	// A backend scales it to the height its platform draws menu rows at and
	// keeps the aspect ratio, so an icon does not have to be authored at a
	// particular size -- extra pixels become resolution, not dimensions. A
	// monochrome glyph is drawn as a TEMPLATE (see [IsTemplate]) and so follows
	// a light or dark menu; one that carries colour keeps its colour.
	//
	// Honoured today by the macOS backend. The Windows and Linux backends
	// ignore it, and a row that carries one there simply draws as it did
	// before -- the field is not a promise those platforms have kept yet.
	Icon []byte
	// OnClick is invoked when the item is activated. For a checkbox item the
	// Checked field is toggled before OnClick runs.
	OnClick func()
	// Submenu, when non-nil, makes this item open a nested menu (its OnClick is
	// then ignored).
	Submenu *Menu
	// contains filtered or unexported fields
}

MenuItem is one entry in a tray menu.

func Checkbox

func Checkbox(label string, checked bool, onToggle func(bool)) *MenuItem

Checkbox is a toggleable item; onToggle receives the new checked state.

func IconItem added in v0.8.0

func IconItem(label string, iconPNG []byte, onClick func()) *MenuItem

IconItem is a clickable menu item carrying a PNG icon; see MenuItem.Icon.

func Item

func Item(label string, onClick func()) *MenuItem

Item is a plain clickable menu item.

func Separator

func Separator() *MenuItem

Separator is a divider line.

func SubMenu(label string, sub *Menu) *MenuItem

SubMenu is an item that opens a nested menu.

func (it *MenuItem) Activate()

Activate dispatches a click on the item: it flips a checkbox's state and invokes the appropriate callback. Separators, disabled items and submenu parents do nothing. Backends call this when the user picks an item.

type Tray

type Tray struct {
	// contains filtered or unexported fields
}

Tray is a system-tray icon with a tooltip and a menu.

What it SHOWS -- icon, tooltip, menu -- is read by the platform loop and written by whoever changes it, and those are not the same goroutine: an icon bound to application state (see BindIcon) is written by a ticker while the loop is drawing. So the three are behind a lock. The backend and the ready callback are not: they are set while the tray is being built, before anything runs, and a tray whose backend changed underneath a running loop would be a different bug entirely.

func New

func New(iconPNG []byte) *Tray

New creates a tray showing iconPNG (PNG-encoded bytes). The platform backend is selected automatically; use WithBackend to override (eg. for tests).

func (*Tray) Attach

func (t *Tray) Attach() error

Attach shows the tray inside a host-owned event loop and returns immediately, instead of Run's block-until-Quit. Use it from an application that already drives the platform's main run loop (its own window): Run would try to start a second loop, whereas Attach just registers the tray with the running one. It must be called on the platform's main/UI thread. Returns ErrNoBackend when the active backend does not support attaching.

func (*Tray) Icon

func (t *Tray) Icon() []byte

Accessors used by backends, safe to call from the platform loop while another goroutine is setting them.

func (*Tray) Menu

func (t *Tray) Menu() *Menu

func (*Tray) OnReady

func (t *Tray) OnReady(fn func()) *Tray

OnReady registers a callback run once the tray is live (after Run starts).

func (*Tray) Quit

func (t *Tray) Quit()

Quit stops the tray's event loop.

func (*Tray) Run

func (t *Tray) Run() error

Run shows the tray and blocks until Quit. It errors if no backend is set.

func (*Tray) SetIcon

func (t *Tray) SetIcon(iconPNG []byte) *Tray

SetIcon replaces the icon (PNG bytes) and refreshes if running.

func (*Tray) SetMenu

func (t *Tray) SetMenu(m *Menu) *Tray

SetMenu sets the tray menu and refreshes if running.

func (*Tray) SetTooltip

func (t *Tray) SetTooltip(s string) *Tray

SetTooltip sets the hover tooltip and refreshes if running.

func (*Tray) Tooltip

func (t *Tray) Tooltip() string

func (*Tray) WithBackend

func (t *Tray) WithBackend(b Backend) *Tray

WithBackend overrides the platform backend and returns the tray.

Directories

Path Synopsis
examples
traydemo command
Command traydemo is a runnable go-widgets/tray example.
Command traydemo is a runnable go-widgets/tray example.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL