feat(menu): add Manager with platform abstraction and builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 12:15:32 +00:00
parent 940ed0bdae
commit 12a612bba0
5 changed files with 359 additions and 0 deletions

71
pkg/menu/menu.go Normal file
View file

@ -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
}

94
pkg/menu/menu_test.go Normal file
View file

@ -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)
}

70
pkg/menu/mock_test.go Normal file
View file

@ -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
}

38
pkg/menu/platform.go Normal file
View file

@ -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
)

86
pkg/menu/wails.go Normal file
View file

@ -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
}