From 573eb5216ad13738a8bdfc1c8f7ab8927d6be148 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 13:39:36 +0000 Subject: [PATCH] feat(systray): wire tray mutations and submenus Co-Authored-By: Virgil --- pkg/display/display.go | 12 ++++++++++ pkg/display/display_test.go | 18 +++++++++++++++ pkg/mcp/tools_tray.go | 12 ++++++---- pkg/systray/menu.go | 12 ++++++---- pkg/systray/messages.go | 6 +++++ pkg/systray/mock_platform.go | 11 ++++++++- pkg/systray/mock_test.go | 15 ++++++++---- pkg/systray/platform.go | 1 + pkg/systray/service.go | 4 ++++ pkg/systray/service_test.go | 45 ++++++++++++++++++++++++++++++++++++ pkg/systray/wails.go | 4 ++++ 11 files changed, 126 insertions(+), 14 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index 8eabd36..3bad064 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -667,6 +667,18 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { Title: title, Message: message, }) + case "tray:set-tooltip": + tooltip, e := wsRequire(msg.Data, "tooltip") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(systray.TaskSetTooltip{Tooltip: tooltip}) + case "tray:set-label": + label, e := wsRequire(msg.Data, "label") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(systray.TaskSetLabel{Label: label}) case "dialog:prompt": title, e := wsRequire(msg.Data, "title") if e != nil { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 42bad09..bee4377 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -786,6 +786,24 @@ func TestHandleWSMessage_Extended_Good(t *testing.T) { assert.True(t, handled) }) + t.Run("tray tooltip", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "tray:set-tooltip", + Data: map[string]any{"tooltip": "Updated"}, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + + t.Run("tray label", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "tray:set-label", + Data: map[string]any{"label": "Updated"}, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + t.Run("prompt dialog", func(t *testing.T) { result, handled, err := svc.handleWSMessage(WSMessage{ Action: "dialog:prompt", diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index bb54844..f11f11d 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -36,8 +36,10 @@ type TraySetTooltipOutput struct { } func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) { - // Tooltip is set via the tray menu items; for now this is a no-op placeholder - _ = input.Tooltip + _, _, err := s.core.PERFORM(systray.TaskSetTooltip{Tooltip: input.Tooltip}) + if err != nil { + return nil, TraySetTooltipOutput{}, err + } return nil, TraySetTooltipOutput{Success: true}, nil } @@ -51,8 +53,10 @@ type TraySetLabelOutput struct { } func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) { - // Label is part of the tray configuration; placeholder for now - _ = input.Label + _, _, err := s.core.PERFORM(systray.TaskSetLabel{Label: input.Label}) + if err != nil { + return nil, TraySetLabelOutput{}, err + } return nil, TraySetLabelOutput{Success: true}, nil } diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 3032a6d..418e38c 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -16,16 +16,19 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error { // buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors. func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { menu := m.platform.NewMenu() + m.buildMenuInto(menu, items) + return menu +} + +func (m *Manager) buildMenuInto(menu PlatformMenu, items []TrayMenuItem) { 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 + sub := menu.AddSubmenu(item.Label) + m.buildMenuInto(sub, item.Submenu) continue } mi := menu.Add(item.Label) @@ -47,7 +50,6 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { }) } } - return menu } // RegisterCallback registers a callback for a menu action ID. diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go index 07e0030..cb51648 100644 --- a/pkg/systray/messages.go +++ b/pkg/systray/messages.go @@ -9,6 +9,12 @@ type QueryConfig struct{} // TaskSetTrayIcon sets the tray icon. type TaskSetTrayIcon struct{ Data []byte } +// TaskSetTooltip updates the tray tooltip text. +type TaskSetTooltip struct{ Tooltip string } + +// TaskSetLabel updates the tray label text. +type TaskSetLabel struct{ Label string } + // TaskSetTrayMenu sets the tray menu items. type TaskSetTrayMenu struct{ Items []TrayMenuItem } diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go index 7e41d04..7d9b21b 100644 --- a/pkg/systray/mock_platform.go +++ b/pkg/systray/mock_platform.go @@ -20,7 +20,10 @@ func (t *exportedMockTray) SetLabel(text string) { t.label = text } func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} func (t *exportedMockTray) AttachWindow(w WindowHandle) {} -type exportedMockMenu struct{ items []exportedMockMenuItem } +type exportedMockMenu struct { + items []exportedMockMenuItem + submenus []*exportedMockMenu +} func (m *exportedMockMenu) Add(label string) PlatformMenuItem { mi := &exportedMockMenuItem{label: label} @@ -28,6 +31,12 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem { return mi } func (m *exportedMockMenu) AddSeparator() {} +func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu { + sub := &exportedMockMenu{} + m.items = append(m.items, exportedMockMenuItem{label: label}) + m.submenus = append(m.submenus, sub) + return sub +} type exportedMockMenuItem struct { label, tooltip string diff --git a/pkg/systray/mock_test.go b/pkg/systray/mock_test.go index 9082805..64dbc38 100644 --- a/pkg/systray/mock_test.go +++ b/pkg/systray/mock_test.go @@ -21,7 +21,8 @@ func (p *mockPlatform) NewMenu() PlatformMenu { } type mockTrayMenu struct { - items []string + items []string + submenus []*mockTrayMenu } func (m *mockTrayMenu) Add(label string) PlatformMenuItem { @@ -29,10 +30,16 @@ func (m *mockTrayMenu) Add(label string) PlatformMenuItem { return &mockTrayMenuItem{} } func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") } +func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu { + m.items = append(m.items, label) + sub := &mockTrayMenu{} + m.submenus = append(m.submenus, sub) + return sub +} type mockTrayMenuItem struct{} -func (mi *mockTrayMenuItem) SetTooltip(text string) {} +func (mi *mockTrayMenuItem) SetTooltip(text string) {} func (mi *mockTrayMenuItem) SetChecked(checked bool) {} func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {} func (mi *mockTrayMenuItem) OnClick(fn func()) {} @@ -45,9 +52,9 @@ type mockTray struct { attachedWindow WindowHandle } -func (t *mockTray) SetIcon(data []byte) { t.icon = data } +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 } +func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w } diff --git a/pkg/systray/platform.go b/pkg/systray/platform.go index 1d76ec5..283f638 100644 --- a/pkg/systray/platform.go +++ b/pkg/systray/platform.go @@ -21,6 +21,7 @@ type PlatformTray interface { type PlatformMenu interface { Add(label string) PlatformMenuItem AddSeparator() + AddSubmenu(label string) PlatformMenu } // PlatformMenuItem is a single item in a tray menu. diff --git a/pkg/systray/service.go b/pkg/systray/service.go index f6a77ff..fc05633 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -54,6 +54,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskSetTrayIcon: return nil, true, s.manager.SetIcon(t.Data) + case TaskSetTooltip: + return nil, true, s.manager.SetTooltip(t.Tooltip) + case TaskSetLabel: + return nil, true, s.manager.SetLabel(t.Label) case TaskSetTrayMenu: return nil, true, s.taskSetTrayMenu(t) case TaskShowPanel: diff --git a/pkg/systray/service_test.go b/pkg/systray/service_test.go index 52de38c..b22c793 100644 --- a/pkg/systray/service_test.go +++ b/pkg/systray/service_test.go @@ -39,6 +39,24 @@ func TestTaskSetTrayIcon_Good(t *testing.T) { assert.True(t, handled) } +func TestTaskSetTooltip_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetTooltip{Tooltip: "Updated"}) + require.NoError(t, err) + assert.True(t, handled) +} + +func TestTaskSetLabel_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetLabel{Label: "Updated"}) + require.NoError(t, err) + assert.True(t, handled) +} + func TestTaskSetTrayMenu_Good(t *testing.T) { svc, c := newTestSystrayService(t) @@ -54,6 +72,33 @@ func TestTaskSetTrayMenu_Good(t *testing.T) { assert.True(t, handled) } +func TestTaskSetTrayMenu_Submenu_Good(t *testing.T) { + p := newMockPlatform() + c, err := core.New( + core.WithService(Register(p)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + svc := core.MustServiceFor[*Service](c, "systray") + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetTrayMenu{Items: []TrayMenuItem{ + { + Label: "File", + Submenu: []TrayMenuItem{ + {Label: "Open", ActionID: "open"}, + }, + }, + }}) + require.NoError(t, err) + assert.True(t, handled) + require.Len(t, p.trays, 1) + require.NotEmpty(t, p.menus) + require.Len(t, p.menus[0].submenus, 1) +} + func TestTaskSetTrayIcon_Bad(t *testing.T) { // No systray service — PERFORM returns handled=false c, err := core.New(core.WithServiceLock()) diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go index f1bc8de..6cbb5bf 100644 --- a/pkg/systray/wails.go +++ b/pkg/systray/wails.go @@ -56,6 +56,10 @@ func (m *wailsTrayMenu) AddSeparator() { m.menu.AddSeparator() } +func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu { + return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)} +} + // wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface. type wailsTrayMenuItem struct { item *application.MenuItem