diff --git a/pkg/systray/assets/apptray.png b/pkg/systray/assets/apptray.png new file mode 100644 index 0000000..0778fc6 Binary files /dev/null and b/pkg/systray/assets/apptray.png differ diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go new file mode 100644 index 0000000..3032a6d --- /dev/null +++ b/pkg/systray/menu.go @@ -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(), + } +} diff --git a/pkg/systray/mock_test.go b/pkg/systray/mock_test.go new file mode 100644 index 0000000..9082805 --- /dev/null +++ b/pkg/systray/mock_test.go @@ -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 } diff --git a/pkg/systray/platform.go b/pkg/systray/platform.go new file mode 100644 index 0000000..1d76ec5 --- /dev/null +++ b/pkg/systray/platform.go @@ -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) +} diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go new file mode 100644 index 0000000..05ffcdf --- /dev/null +++ b/pkg/systray/tray.go @@ -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 +} diff --git a/pkg/systray/tray_test.go b/pkg/systray/tray_test.go new file mode 100644 index 0000000..f802828 --- /dev/null +++ b/pkg/systray/tray_test.go @@ -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)) +} diff --git a/pkg/systray/types.go b/pkg/systray/types.go new file mode 100644 index 0000000..35be2f3 --- /dev/null +++ b/pkg/systray/types.go @@ -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"` +} diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go new file mode 100644 index 0000000..cbd9ed2 --- /dev/null +++ b/pkg/systray/wails.go @@ -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()} +}