From 12a612bba0631f99606809ba7ef0fc824067595c Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 12:15:32 +0000 Subject: [PATCH] feat(menu): add Manager with platform abstraction and builder Co-Authored-By: Claude Opus 4.6 --- pkg/menu/menu.go | 71 ++++++++++++++++++++++++++++++++ pkg/menu/menu_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++ pkg/menu/mock_test.go | 70 ++++++++++++++++++++++++++++++++ pkg/menu/platform.go | 38 +++++++++++++++++ pkg/menu/wails.go | 86 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+) create mode 100644 pkg/menu/menu.go create mode 100644 pkg/menu/menu_test.go create mode 100644 pkg/menu/mock_test.go create mode 100644 pkg/menu/platform.go create mode 100644 pkg/menu/wails.go diff --git a/pkg/menu/menu.go b/pkg/menu/menu.go new file mode 100644 index 0000000..26c552e --- /dev/null +++ b/pkg/menu/menu.go @@ -0,0 +1,71 @@ +// pkg/menu/menu.go +package menu + +// MenuItem describes a menu item for construction (structure only — no handlers). +type MenuItem struct { + Label string + Accelerator string + Type string // "normal", "separator", "checkbox", "radio", "submenu" + Checked bool + Disabled bool + Tooltip string + Children []MenuItem + Role *MenuRole + OnClick func() // Injected by orchestrator, not by menu package consumer +} + +// Manager builds application menus via a Platform backend. +type Manager struct { + platform Platform +} + +// NewManager creates a menu Manager. +func NewManager(platform Platform) *Manager { + return &Manager{platform: platform} +} + +// Build constructs a PlatformMenu from a tree of MenuItems. +func (m *Manager) Build(items []MenuItem) PlatformMenu { + menu := m.platform.NewMenu() + m.buildItems(menu, items) + return menu +} + +func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) { + for _, item := range items { + if item.Role != nil { + menu.AddRole(*item.Role) + continue + } + if item.Type == "separator" { + menu.AddSeparator() + continue + } + if len(item.Children) > 0 { + sub := menu.AddSubmenu(item.Label) + m.buildItems(sub, item.Children) + continue + } + mi := menu.Add(item.Label) + if item.Accelerator != "" { + mi.SetAccelerator(item.Accelerator) + } + if item.Tooltip != "" { + mi.SetTooltip(item.Tooltip) + } + if item.OnClick != nil { + mi.OnClick(item.OnClick) + } + } +} + +// SetApplicationMenu builds and sets the application menu. +func (m *Manager) SetApplicationMenu(items []MenuItem) { + menu := m.Build(items) + m.platform.SetApplicationMenu(menu) +} + +// Platform returns the underlying platform. +func (m *Manager) Platform() Platform { + return m.platform +} diff --git a/pkg/menu/menu_test.go b/pkg/menu/menu_test.go new file mode 100644 index 0000000..1936daf --- /dev/null +++ b/pkg/menu/menu_test.go @@ -0,0 +1,94 @@ +// pkg/menu/menu_test.go +package menu + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func newTestManager() (*Manager, *mockPlatform) { + p := newMockPlatform() + return NewManager(p), p +} + +func TestManager_Build_Good(t *testing.T) { + m, p := newTestManager() + items := []MenuItem{ + {Label: "File"}, + {Label: "Edit"}, + } + menu := m.Build(items) + assert.NotNil(t, menu) + assert.Len(t, p.menus, 1) + assert.Len(t, p.menus[0].items, 2) + assert.Equal(t, "File", p.menus[0].items[0].label) +} + +func TestManager_Build_Separator_Good(t *testing.T) { + m, p := newTestManager() + items := []MenuItem{ + {Label: "Above"}, + {Type: "separator"}, + {Label: "Below"}, + } + m.Build(items) + assert.Len(t, p.menus[0].items, 3) + assert.Equal(t, "---", p.menus[0].items[1].label) +} + +func TestManager_Build_Submenu_Good(t *testing.T) { + m, p := newTestManager() + items := []MenuItem{ + {Label: "Parent", Children: []MenuItem{ + {Label: "Child 1"}, + {Label: "Child 2"}, + }}, + } + m.Build(items) + assert.Len(t, p.menus[0].subs, 1) + assert.Len(t, p.menus[0].subs[0].items, 2) +} + +func TestManager_Build_Accelerator_Good(t *testing.T) { + m, p := newTestManager() + items := []MenuItem{ + {Label: "Save", Accelerator: "CmdOrCtrl+S"}, + } + m.Build(items) + assert.Equal(t, "CmdOrCtrl+S", p.menus[0].items[0].accel) +} + +func TestManager_Build_OnClick_Good(t *testing.T) { + m, p := newTestManager() + called := false + items := []MenuItem{ + {Label: "Action", OnClick: func() { called = true }}, + } + m.Build(items) + p.menus[0].items[0].onClick() + assert.True(t, called) +} + +func TestManager_Build_Role_Good(t *testing.T) { + m, p := newTestManager() + appMenu := RoleAppMenu + items := []MenuItem{ + {Role: &appMenu}, + } + m.Build(items) + assert.Contains(t, p.menus[0].roles, RoleAppMenu) +} + +func TestManager_SetApplicationMenu_Good(t *testing.T) { + m, p := newTestManager() + items := []MenuItem{{Label: "Test"}} + m.SetApplicationMenu(items) + assert.NotNil(t, p.appMenu) +} + +func TestManager_Build_Empty_Good(t *testing.T) { + m, _ := newTestManager() + menu := m.Build(nil) + assert.NotNil(t, menu) +} diff --git a/pkg/menu/mock_test.go b/pkg/menu/mock_test.go new file mode 100644 index 0000000..359af72 --- /dev/null +++ b/pkg/menu/mock_test.go @@ -0,0 +1,70 @@ +// pkg/menu/mock_test.go +package menu + +type mockPlatform struct { + menus []*mockMenu + appMenu PlatformMenu +} + +func newMockPlatform() *mockPlatform { return &mockPlatform{} } + +func (p *mockPlatform) NewMenu() PlatformMenu { + m := &mockMenu{} + p.menus = append(p.menus, m) + return m +} + +func (p *mockPlatform) SetApplicationMenu(menu PlatformMenu) { p.appMenu = menu } + +type mockMenu struct { + items []*mockMenuItem + subs []*mockMenu + roles []MenuRole +} + +func (m *mockMenu) Add(label string) PlatformMenuItem { + mi := &mockMenuItem{label: label} + m.items = append(m.items, mi) + return mi +} + +func (m *mockMenu) AddSeparator() { + m.items = append(m.items, &mockMenuItem{label: "---"}) +} + +func (m *mockMenu) AddSubmenu(label string) PlatformMenu { + sub := &mockMenu{} + m.subs = append(m.subs, sub) + m.items = append(m.items, &mockMenuItem{label: label, isSubmenu: true}) + return sub +} + +func (m *mockMenu) AddRole(role MenuRole) { m.roles = append(m.roles, role) } + +type mockMenuItem struct { + label, accel, tooltip string + checked, enabled bool + isSubmenu bool + onClick func() +} + +func (mi *mockMenuItem) SetAccelerator(accel string) PlatformMenuItem { + mi.accel = accel + return mi +} +func (mi *mockMenuItem) SetTooltip(text string) PlatformMenuItem { + mi.tooltip = text + return mi +} +func (mi *mockMenuItem) SetChecked(checked bool) PlatformMenuItem { + mi.checked = checked + return mi +} +func (mi *mockMenuItem) SetEnabled(enabled bool) PlatformMenuItem { + mi.enabled = enabled + return mi +} +func (mi *mockMenuItem) OnClick(fn func()) PlatformMenuItem { + mi.onClick = fn + return mi +} diff --git a/pkg/menu/platform.go b/pkg/menu/platform.go new file mode 100644 index 0000000..01d219b --- /dev/null +++ b/pkg/menu/platform.go @@ -0,0 +1,38 @@ +// pkg/menu/platform.go +package menu + +// Platform abstracts the menu backend. +type Platform interface { + NewMenu() PlatformMenu + SetApplicationMenu(menu PlatformMenu) +} + +// PlatformMenu is a live menu handle. +type PlatformMenu interface { + Add(label string) PlatformMenuItem + AddSeparator() + AddSubmenu(label string) PlatformMenu + // Roles — macOS menu roles + AddRole(role MenuRole) +} + +// PlatformMenuItem is a single menu item. +type PlatformMenuItem interface { + SetAccelerator(accel string) PlatformMenuItem + SetTooltip(text string) PlatformMenuItem + SetChecked(checked bool) PlatformMenuItem + SetEnabled(enabled bool) PlatformMenuItem + OnClick(fn func()) PlatformMenuItem +} + +// MenuRole is a predefined platform menu role. +type MenuRole int + +const ( + RoleAppMenu MenuRole = iota + RoleFileMenu + RoleEditMenu + RoleViewMenu + RoleWindowMenu + RoleHelpMenu +) diff --git a/pkg/menu/wails.go b/pkg/menu/wails.go new file mode 100644 index 0000000..bba9391 --- /dev/null +++ b/pkg/menu/wails.go @@ -0,0 +1,86 @@ +// pkg/menu/wails.go +package menu + +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) NewMenu() PlatformMenu { + return &wailsMenu{menu: application.NewMenu()} +} + +func (wp *WailsPlatform) SetApplicationMenu(menu PlatformMenu) { + if wm, ok := menu.(*wailsMenu); ok { + wp.app.Menu.SetApplicationMenu(wm.menu) + } +} + +type wailsMenu struct { + menu *application.Menu +} + +func (wm *wailsMenu) Add(label string) PlatformMenuItem { + return &wailsMenuItem{item: wm.menu.Add(label)} +} + +func (wm *wailsMenu) AddSeparator() { + wm.menu.AddSeparator() +} + +func (wm *wailsMenu) AddSubmenu(label string) PlatformMenu { + sub := wm.menu.AddSubmenu(label) + return &wailsMenu{menu: sub} +} + +func (wm *wailsMenu) AddRole(role MenuRole) { + switch role { + case RoleAppMenu: + wm.menu.AddRole(application.AppMenu) + case RoleFileMenu: + wm.menu.AddRole(application.FileMenu) + case RoleEditMenu: + wm.menu.AddRole(application.EditMenu) + case RoleViewMenu: + wm.menu.AddRole(application.ViewMenu) + case RoleWindowMenu: + wm.menu.AddRole(application.WindowMenu) + case RoleHelpMenu: + wm.menu.AddRole(application.HelpMenu) + } +} + +type wailsMenuItem struct { + item *application.MenuItem +} + +func (mi *wailsMenuItem) SetAccelerator(accel string) PlatformMenuItem { + mi.item.SetAccelerator(accel) + return mi +} + +func (mi *wailsMenuItem) SetTooltip(text string) PlatformMenuItem { + mi.item.SetTooltip(text) + return mi +} + +func (mi *wailsMenuItem) SetChecked(checked bool) PlatformMenuItem { + mi.item.SetChecked(checked) + return mi +} + +func (mi *wailsMenuItem) SetEnabled(enabled bool) PlatformMenuItem { + mi.item.SetEnabled(enabled) + return mi +} + +func (mi *wailsMenuItem) OnClick(fn func()) PlatformMenuItem { + mi.item.OnClick(func(ctx *application.Context) { fn() }) + return mi +}