feat(systray): add Manager with platform abstraction and callback registry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7c066ba3d8
commit
940ed0bdae
8 changed files with 444 additions and 0 deletions
BIN
pkg/systray/assets/apptray.png
Normal file
BIN
pkg/systray/assets/apptray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
80
pkg/systray/menu.go
Normal file
80
pkg/systray/menu.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// pkg/systray/menu.go
|
||||
package systray
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
|
||||
func (m *Manager) SetMenu(items []TrayMenuItem) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
}
|
||||
menu := m.buildMenu(items)
|
||||
m.tray.SetMenu(menu)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
|
||||
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
|
||||
menu := m.platform.NewMenu()
|
||||
for _, item := range items {
|
||||
if item.Type == "separator" {
|
||||
menu.AddSeparator()
|
||||
continue
|
||||
}
|
||||
if len(item.Submenu) > 0 {
|
||||
sub := m.buildMenu(item.Submenu)
|
||||
mi := menu.Add(item.Label)
|
||||
_ = mi.AddSubmenu()
|
||||
_ = sub // TODO: wire sub into parent via platform
|
||||
continue
|
||||
}
|
||||
mi := menu.Add(item.Label)
|
||||
if item.Tooltip != "" {
|
||||
mi.SetTooltip(item.Tooltip)
|
||||
}
|
||||
if item.Disabled {
|
||||
mi.SetEnabled(false)
|
||||
}
|
||||
if item.Checked {
|
||||
mi.SetChecked(true)
|
||||
}
|
||||
if item.ActionID != "" {
|
||||
actionID := item.ActionID
|
||||
mi.OnClick(func() {
|
||||
if cb, ok := m.GetCallback(actionID); ok {
|
||||
cb()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
// RegisterCallback registers a callback for a menu action ID.
|
||||
func (m *Manager) RegisterCallback(actionID string, callback func()) {
|
||||
m.mu.Lock()
|
||||
m.callbacks[actionID] = callback
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// UnregisterCallback removes a callback.
|
||||
func (m *Manager) UnregisterCallback(actionID string) {
|
||||
m.mu.Lock()
|
||||
delete(m.callbacks, actionID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetCallback returns the callback for an action ID.
|
||||
func (m *Manager) GetCallback(actionID string) (func(), bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
cb, ok := m.callbacks[actionID]
|
||||
return cb, ok
|
||||
}
|
||||
|
||||
// GetInfo returns tray status information.
|
||||
func (m *Manager) GetInfo() map[string]any {
|
||||
return map[string]any{
|
||||
"active": m.IsActive(),
|
||||
}
|
||||
}
|
||||
53
pkg/systray/mock_test.go
Normal file
53
pkg/systray/mock_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// pkg/systray/mock_test.go
|
||||
package systray
|
||||
|
||||
type mockPlatform struct {
|
||||
trays []*mockTray
|
||||
menus []*mockTrayMenu
|
||||
}
|
||||
|
||||
func newMockPlatform() *mockPlatform { return &mockPlatform{} }
|
||||
|
||||
func (p *mockPlatform) NewTray() PlatformTray {
|
||||
t := &mockTray{}
|
||||
p.trays = append(p.trays, t)
|
||||
return t
|
||||
}
|
||||
|
||||
func (p *mockPlatform) NewMenu() PlatformMenu {
|
||||
m := &mockTrayMenu{}
|
||||
p.menus = append(p.menus, m)
|
||||
return m
|
||||
}
|
||||
|
||||
type mockTrayMenu struct {
|
||||
items []string
|
||||
}
|
||||
|
||||
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
||||
m.items = append(m.items, label)
|
||||
return &mockTrayMenuItem{}
|
||||
}
|
||||
func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
|
||||
|
||||
type mockTrayMenuItem struct{}
|
||||
|
||||
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
|
||||
|
||||
type mockTray struct {
|
||||
icon, templateIcon []byte
|
||||
tooltip, label string
|
||||
menu PlatformMenu
|
||||
attachedWindow WindowHandle
|
||||
}
|
||||
|
||||
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
|
||||
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
||||
func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
|
||||
func (t *mockTray) SetLabel(text string) { t.label = text }
|
||||
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
|
||||
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
|
||||
44
pkg/systray/platform.go
Normal file
44
pkg/systray/platform.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// pkg/systray/platform.go
|
||||
package systray
|
||||
|
||||
// Platform abstracts the system tray backend.
|
||||
type Platform interface {
|
||||
NewTray() PlatformTray
|
||||
NewMenu() PlatformMenu // Menu factory for building tray menus
|
||||
}
|
||||
|
||||
// PlatformTray is a live tray handle from the backend.
|
||||
type PlatformTray interface {
|
||||
SetIcon(data []byte)
|
||||
SetTemplateIcon(data []byte)
|
||||
SetTooltip(text string)
|
||||
SetLabel(text string)
|
||||
SetMenu(menu PlatformMenu)
|
||||
AttachWindow(w WindowHandle)
|
||||
}
|
||||
|
||||
// PlatformMenu is a tray menu built by the backend.
|
||||
type PlatformMenu interface {
|
||||
Add(label string) PlatformMenuItem
|
||||
AddSeparator()
|
||||
}
|
||||
|
||||
// PlatformMenuItem is a single item in a tray menu.
|
||||
type PlatformMenuItem interface {
|
||||
SetTooltip(text string)
|
||||
SetChecked(checked bool)
|
||||
SetEnabled(enabled bool)
|
||||
OnClick(fn func())
|
||||
AddSubmenu() PlatformMenu
|
||||
}
|
||||
|
||||
// WindowHandle is a cross-package interface for window operations.
|
||||
// Defined locally to avoid circular imports (display imports systray).
|
||||
// pkg/window.PlatformWindow satisfies this implicitly.
|
||||
type WindowHandle interface {
|
||||
Name() string
|
||||
Show()
|
||||
Hide()
|
||||
SetPosition(x, y int)
|
||||
SetSize(width, height int)
|
||||
}
|
||||
95
pkg/systray/tray.go
Normal file
95
pkg/systray/tray.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// pkg/systray/tray.go
|
||||
package systray
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed assets/apptray.png
|
||||
var defaultIcon []byte
|
||||
|
||||
// Manager manages the system tray lifecycle.
|
||||
// State that was previously in package-level vars is now on the Manager.
|
||||
type Manager struct {
|
||||
platform Platform
|
||||
tray PlatformTray
|
||||
callbacks map[string]func()
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a systray Manager.
|
||||
func NewManager(platform Platform) *Manager {
|
||||
return &Manager{
|
||||
platform: platform,
|
||||
callbacks: make(map[string]func()),
|
||||
}
|
||||
}
|
||||
|
||||
// Setup creates the system tray with default icon and tooltip.
|
||||
func (m *Manager) Setup(tooltip, label string) error {
|
||||
m.tray = m.platform.NewTray()
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("platform returned nil tray")
|
||||
}
|
||||
m.tray.SetTemplateIcon(defaultIcon)
|
||||
m.tray.SetTooltip(tooltip)
|
||||
m.tray.SetLabel(label)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetIcon sets the tray icon.
|
||||
func (m *Manager) SetIcon(data []byte) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
}
|
||||
m.tray.SetIcon(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTemplateIcon sets the template icon (macOS).
|
||||
func (m *Manager) SetTemplateIcon(data []byte) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
}
|
||||
m.tray.SetTemplateIcon(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTooltip sets the tray tooltip.
|
||||
func (m *Manager) SetTooltip(text string) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
}
|
||||
m.tray.SetTooltip(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLabel sets the tray label.
|
||||
func (m *Manager) SetLabel(text string) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
}
|
||||
m.tray.SetLabel(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachWindow attaches a panel window to the tray.
|
||||
func (m *Manager) AttachWindow(w WindowHandle) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
}
|
||||
m.tray.AttachWindow(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tray returns the underlying platform tray for direct access.
|
||||
func (m *Manager) Tray() PlatformTray {
|
||||
return m.tray
|
||||
}
|
||||
|
||||
// IsActive returns whether a tray has been created.
|
||||
func (m *Manager) IsActive() bool {
|
||||
return m.tray != nil
|
||||
}
|
||||
86
pkg/systray/tray_test.go
Normal file
86
pkg/systray/tray_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// pkg/systray/tray_test.go
|
||||
package systray
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestManager() (*Manager, *mockPlatform) {
|
||||
p := newMockPlatform()
|
||||
return NewManager(p), p
|
||||
}
|
||||
|
||||
func TestManager_Setup_Good(t *testing.T) {
|
||||
m, p := newTestManager()
|
||||
err := m.Setup("Core", "Core")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, m.IsActive())
|
||||
assert.Len(t, p.trays, 1)
|
||||
assert.Equal(t, "Core", p.trays[0].tooltip)
|
||||
assert.Equal(t, "Core", p.trays[0].label)
|
||||
assert.NotEmpty(t, p.trays[0].templateIcon) // default icon embedded
|
||||
}
|
||||
|
||||
func TestManager_SetIcon_Good(t *testing.T) {
|
||||
m, p := newTestManager()
|
||||
_ = m.Setup("Core", "Core")
|
||||
err := m.SetIcon([]byte{1, 2, 3})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte{1, 2, 3}, p.trays[0].icon)
|
||||
}
|
||||
|
||||
func TestManager_SetIcon_Bad(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
err := m.SetIcon([]byte{1})
|
||||
assert.Error(t, err) // tray not initialised
|
||||
}
|
||||
|
||||
func TestManager_SetTooltip_Good(t *testing.T) {
|
||||
m, p := newTestManager()
|
||||
_ = m.Setup("Core", "Core")
|
||||
_ = m.SetTooltip("New Tooltip")
|
||||
assert.Equal(t, "New Tooltip", p.trays[0].tooltip)
|
||||
}
|
||||
|
||||
func TestManager_SetLabel_Good(t *testing.T) {
|
||||
m, p := newTestManager()
|
||||
_ = m.Setup("Core", "Core")
|
||||
_ = m.SetLabel("New Label")
|
||||
assert.Equal(t, "New Label", p.trays[0].label)
|
||||
}
|
||||
|
||||
func TestManager_RegisterCallback_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
called := false
|
||||
m.RegisterCallback("test-action", func() { called = true })
|
||||
cb, ok := m.GetCallback("test-action")
|
||||
assert.True(t, ok)
|
||||
cb()
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestManager_RegisterCallback_Bad(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, ok := m.GetCallback("nonexistent")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestManager_UnregisterCallback_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
m.RegisterCallback("remove-me", func() {})
|
||||
m.UnregisterCallback("remove-me")
|
||||
_, ok := m.GetCallback("remove-me")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestManager_GetInfo_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
info := m.GetInfo()
|
||||
assert.False(t, info["active"].(bool))
|
||||
_ = m.Setup("Core", "Core")
|
||||
info = m.GetInfo()
|
||||
assert.True(t, info["active"].(bool))
|
||||
}
|
||||
13
pkg/systray/types.go
Normal file
13
pkg/systray/types.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// pkg/systray/types.go
|
||||
package systray
|
||||
|
||||
// TrayMenuItem describes a menu item for dynamic tray menus.
|
||||
type TrayMenuItem struct {
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // "normal", "separator", "checkbox", "radio"
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Tooltip string `json:"tooltip,omitempty"`
|
||||
Submenu []TrayMenuItem `json:"submenu,omitempty"`
|
||||
ActionID string `json:"action_id,omitempty"`
|
||||
}
|
||||
73
pkg/systray/wails.go
Normal file
73
pkg/systray/wails.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// pkg/systray/wails.go
|
||||
package systray
|
||||
|
||||
import (
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// WailsPlatform implements Platform using Wails v3.
|
||||
type WailsPlatform struct {
|
||||
app *application.App
|
||||
}
|
||||
|
||||
func NewWailsPlatform(app *application.App) *WailsPlatform {
|
||||
return &WailsPlatform{app: app}
|
||||
}
|
||||
|
||||
func (wp *WailsPlatform) NewTray() PlatformTray {
|
||||
return &wailsTray{tray: wp.app.SystemTray.New(), app: wp.app}
|
||||
}
|
||||
|
||||
func (wp *WailsPlatform) NewMenu() PlatformMenu {
|
||||
return &wailsTrayMenu{menu: wp.app.NewMenu()}
|
||||
}
|
||||
|
||||
type wailsTray struct {
|
||||
tray *application.SystemTray
|
||||
app *application.App
|
||||
}
|
||||
|
||||
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
|
||||
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
|
||||
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
|
||||
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
|
||||
|
||||
func (wt *wailsTray) SetMenu(menu PlatformMenu) {
|
||||
if wm, ok := menu.(*wailsTrayMenu); ok {
|
||||
wt.tray.SetMenu(wm.menu)
|
||||
}
|
||||
}
|
||||
|
||||
func (wt *wailsTray) AttachWindow(w WindowHandle) {
|
||||
// Wails systray AttachWindow expects an application.Window interface.
|
||||
// The caller must pass an appropriate wrapper.
|
||||
}
|
||||
|
||||
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
|
||||
type wailsTrayMenu struct {
|
||||
menu *application.Menu
|
||||
}
|
||||
|
||||
func (m *wailsTrayMenu) Add(label string) PlatformMenuItem {
|
||||
return &wailsTrayMenuItem{item: m.menu.Add(label)}
|
||||
}
|
||||
|
||||
func (m *wailsTrayMenu) AddSeparator() {
|
||||
m.menu.AddSeparator()
|
||||
}
|
||||
|
||||
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
|
||||
type wailsTrayMenuItem struct {
|
||||
item *application.MenuItem
|
||||
}
|
||||
|
||||
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
|
||||
func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) }
|
||||
func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) }
|
||||
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
|
||||
mi.item.OnClick(func(ctx *application.Context) { fn() })
|
||||
}
|
||||
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
|
||||
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
|
||||
return &wailsTrayMenu{menu: application.NewMenu()}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue