feat(menu): add Manager with platform abstraction and builder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
940ed0bdae
commit
12a612bba0
5 changed files with 359 additions and 0 deletions
71
pkg/menu/menu.go
Normal file
71
pkg/menu/menu.go
Normal 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
94
pkg/menu/menu_test.go
Normal 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
70
pkg/menu/mock_test.go
Normal 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
38
pkg/menu/platform.go
Normal 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
86
pkg/menu/wails.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue