feat(contextmenu): add contextmenu core.Service with Platform interface and IPC
Completes full Wails v3 Manager API coverage through the IPC bus. Service maintains in-memory registry, delegates to Platform for native context menu operations, broadcasts ActionItemClicked on menu item clicks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2099fa5bd3
commit
94c17b88c2
5 changed files with 514 additions and 0 deletions
44
pkg/contextmenu/messages.go
Normal file
44
pkg/contextmenu/messages.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// pkg/contextmenu/messages.go
|
||||
package contextmenu
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrMenuNotFound is returned when attempting to remove or get a menu
|
||||
// that does not exist in the registry.
|
||||
var ErrMenuNotFound = errors.New("contextmenu: menu not found")
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
|
||||
type QueryGet struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
|
||||
type QueryList struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskAdd registers a context menu. Result: nil
|
||||
// If a menu with the same name already exists it is replaced (remove + re-add).
|
||||
type TaskAdd struct {
|
||||
Name string `json:"name"`
|
||||
Menu ContextMenuDef `json:"menu"`
|
||||
}
|
||||
|
||||
// TaskRemove unregisters a context menu. Result: nil
|
||||
// Returns ErrMenuNotFound if the menu does not exist.
|
||||
type TaskRemove struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// ActionItemClicked is broadcast when a context menu item is clicked.
|
||||
// The Data field is populated from the CSS --custom-contextmenu-data property
|
||||
// on the element that triggered the context menu.
|
||||
type ActionItemClicked struct {
|
||||
MenuName string `json:"menuName"`
|
||||
ActionID string `json:"actionId"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
41
pkg/contextmenu/platform.go
Normal file
41
pkg/contextmenu/platform.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// pkg/contextmenu/platform.go
|
||||
package contextmenu
|
||||
|
||||
// Platform abstracts the context menu backend (Wails v3).
|
||||
// The Add callback must broadcast ActionItemClicked via s.Core().ACTION()
|
||||
// when a menu item is clicked — the adapter translates MenuItemDef.ActionID
|
||||
// to a callback that does this.
|
||||
type Platform interface {
|
||||
// Add registers a context menu by name.
|
||||
// The onItemClick callback is called with (menuName, actionID, data)
|
||||
// when any item in the menu is clicked. The adapter creates per-item
|
||||
// OnClick handlers that call this with the appropriate ActionID.
|
||||
Add(name string, menu ContextMenuDef, onItemClick func(menuName, actionID, data string)) error
|
||||
|
||||
// Remove unregisters a context menu by name.
|
||||
Remove(name string) error
|
||||
|
||||
// Get returns a context menu definition by name, or false if not found.
|
||||
Get(name string) (*ContextMenuDef, bool)
|
||||
|
||||
// GetAll returns all registered context menu definitions.
|
||||
GetAll() map[string]ContextMenuDef
|
||||
}
|
||||
|
||||
// ContextMenuDef describes a context menu and its items.
|
||||
type ContextMenuDef struct {
|
||||
Name string `json:"name"`
|
||||
Items []MenuItemDef `json:"items"`
|
||||
}
|
||||
|
||||
// MenuItemDef describes a single item in a context menu.
|
||||
// Items may be nested (submenu children via Items field).
|
||||
type MenuItemDef struct {
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type,omitempty"` // "" (normal), "separator", "checkbox", "radio", "submenu"
|
||||
Accelerator string `json:"accelerator,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"` // nil = true (default)
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"` // identifies which item was clicked
|
||||
Items []MenuItemDef `json:"items,omitempty"` // submenu children (recursive)
|
||||
}
|
||||
16
pkg/contextmenu/register.go
Normal file
16
pkg/contextmenu/register.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// pkg/contextmenu/register.go
|
||||
package contextmenu
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
menus: make(map[string]ContextMenuDef),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
114
pkg/contextmenu/service.go
Normal file
114
pkg/contextmenu/service.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// pkg/contextmenu/service.go
|
||||
package contextmenu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the context menu service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing context menus via IPC.
|
||||
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
|
||||
// and delegates platform-level registration to the Platform interface.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
menus map[string]ContextMenuDef
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Query Handlers ---
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q := q.(type) {
|
||||
case QueryGet:
|
||||
return s.queryGet(q), true, nil
|
||||
case QueryList:
|
||||
return s.queryList(), true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// queryGet returns a single menu definition by name, or nil if not found.
|
||||
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
|
||||
menu, ok := s.menus[q.Name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &menu
|
||||
}
|
||||
|
||||
// queryList returns a copy of all registered menus.
|
||||
func (s *Service) queryList() map[string]ContextMenuDef {
|
||||
result := make(map[string]ContextMenuDef, len(s.menus))
|
||||
for k, v := range s.menus {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Task Handlers ---
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskAdd:
|
||||
return nil, true, s.taskAdd(t)
|
||||
case TaskRemove:
|
||||
return nil, true, s.taskRemove(t)
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) taskAdd(t TaskAdd) error {
|
||||
// If menu already exists, remove it first (replace semantics)
|
||||
if _, exists := s.menus[t.Name]; exists {
|
||||
_ = s.platform.Remove(t.Name)
|
||||
delete(s.menus, t.Name)
|
||||
}
|
||||
|
||||
// Register on platform with a callback that broadcasts ActionItemClicked
|
||||
err := s.platform.Add(t.Name, t.Menu, func(menuName, actionID, data string) {
|
||||
_ = s.Core().ACTION(ActionItemClicked{
|
||||
MenuName: menuName,
|
||||
ActionID: actionID,
|
||||
Data: data,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("contextmenu: platform add failed: %w", err)
|
||||
}
|
||||
|
||||
s.menus[t.Name] = t.Menu
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskRemove(t TaskRemove) error {
|
||||
if _, exists := s.menus[t.Name]; !exists {
|
||||
return ErrMenuNotFound
|
||||
}
|
||||
|
||||
err := s.platform.Remove(t.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("contextmenu: platform remove failed: %w", err)
|
||||
}
|
||||
|
||||
delete(s.menus, t.Name)
|
||||
return nil
|
||||
}
|
||||
299
pkg/contextmenu/service_test.go
Normal file
299
pkg/contextmenu/service_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
// pkg/contextmenu/service_test.go
|
||||
package contextmenu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockPlatform records Add/Remove calls and allows simulating clicks.
|
||||
type mockPlatform struct {
|
||||
mu sync.Mutex
|
||||
menus map[string]ContextMenuDef
|
||||
clickHandlers map[string]func(menuName, actionID, data string)
|
||||
removed []string
|
||||
addErr error
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func newMockPlatform() *mockPlatform {
|
||||
return &mockPlatform{
|
||||
menus: make(map[string]ContextMenuDef),
|
||||
clickHandlers: make(map[string]func(menuName, actionID, data string)),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Add(name string, menu ContextMenuDef, onItemClick func(string, string, string)) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.addErr != nil {
|
||||
return m.addErr
|
||||
}
|
||||
m.menus[name] = menu
|
||||
m.clickHandlers[name] = onItemClick
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Remove(name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.removeErr != nil {
|
||||
return m.removeErr
|
||||
}
|
||||
delete(m.menus, name)
|
||||
delete(m.clickHandlers, name)
|
||||
m.removed = append(m.removed, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Get(name string) (*ContextMenuDef, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
menu, ok := m.menus[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &menu, true
|
||||
}
|
||||
|
||||
func (m *mockPlatform) GetAll() map[string]ContextMenuDef {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[string]ContextMenuDef, len(m.menus))
|
||||
for k, v := range m.menus {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// simulateClick simulates a context menu item click by calling the registered handler.
|
||||
func (m *mockPlatform) simulateClick(menuName, actionID, data string) {
|
||||
m.mu.Lock()
|
||||
h, ok := m.clickHandlers[menuName]
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
h(menuName, actionID, data)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
||||
t.Helper()
|
||||
c, err := core.New(
|
||||
core.WithService(Register(mp)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
svc := core.MustServiceFor[*Service](c, "contextmenu")
|
||||
return svc, c
|
||||
}
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
svc, _ := newTestContextMenuService(t, mp)
|
||||
assert.NotNil(t, svc)
|
||||
assert.NotNil(t, svc.platform)
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskAdd{
|
||||
Name: "file-menu",
|
||||
Menu: ContextMenuDef{
|
||||
Name: "file-menu",
|
||||
Items: []MenuItemDef{
|
||||
{Label: "Open", ActionID: "open"},
|
||||
{Label: "Delete", ActionID: "delete"},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify menu registered on platform
|
||||
_, ok := mp.Get("file-menu")
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good_ReplaceExisting(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
// Add initial menu
|
||||
_, _, _ = c.PERFORM(TaskAdd{
|
||||
Name: "ctx",
|
||||
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}},
|
||||
})
|
||||
|
||||
// Replace with new menu
|
||||
_, handled, err := c.PERFORM(TaskAdd{
|
||||
Name: "ctx",
|
||||
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify registry has new menu
|
||||
result, _, _ := c.QUERY(QueryGet{Name: "ctx"})
|
||||
def := result.(*ContextMenuDef)
|
||||
require.Len(t, def.Items, 1)
|
||||
assert.Equal(t, "B", def.Items[0].Label)
|
||||
}
|
||||
|
||||
func TestTaskRemove_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
// Add then remove
|
||||
_, _, _ = c.PERFORM(TaskAdd{
|
||||
Name: "test",
|
||||
Menu: ContextMenuDef{Name: "test"},
|
||||
})
|
||||
_, handled, err := c.PERFORM(TaskRemove{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify removed from registry
|
||||
result, _, _ := c.QUERY(QueryGet{Name: "test"})
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrMenuNotFound)
|
||||
}
|
||||
|
||||
func TestQueryGet_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{
|
||||
Name: "my-menu",
|
||||
Menu: ContextMenuDef{
|
||||
Name: "my-menu",
|
||||
Items: []MenuItemDef{{Label: "Edit", ActionID: "edit"}},
|
||||
},
|
||||
})
|
||||
|
||||
result, handled, err := c.QUERY(QueryGet{Name: "my-menu"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
def := result.(*ContextMenuDef)
|
||||
assert.Equal(t, "my-menu", def.Name)
|
||||
assert.Len(t, def.Items, 1)
|
||||
}
|
||||
|
||||
func TestQueryGet_Good_NotFound(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
result, handled, err := c.QUERY(QueryGet{Name: "missing"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestQueryList_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}})
|
||||
_, _, _ = c.PERFORM(TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}})
|
||||
|
||||
result, handled, err := c.QUERY(QueryList{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
list := result.(map[string]ContextMenuDef)
|
||||
assert.Len(t, list, 2)
|
||||
}
|
||||
|
||||
func TestQueryList_Good_Empty(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
result, handled, err := c.QUERY(QueryList{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
list := result.(map[string]ContextMenuDef)
|
||||
assert.Len(t, list, 0)
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good_ClickBroadcast(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
// Capture broadcast actions
|
||||
var clicked ActionItemClicked
|
||||
var mu sync.Mutex
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionItemClicked); ok {
|
||||
mu.Lock()
|
||||
clicked = a
|
||||
mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{
|
||||
Name: "file-menu",
|
||||
Menu: ContextMenuDef{
|
||||
Name: "file-menu",
|
||||
Items: []MenuItemDef{
|
||||
{Label: "Open", ActionID: "open"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Simulate click via mock
|
||||
mp.simulateClick("file-menu", "open", "file-123")
|
||||
|
||||
mu.Lock()
|
||||
assert.Equal(t, "file-menu", clicked.MenuName)
|
||||
assert.Equal(t, "open", clicked.ActionID)
|
||||
assert.Equal(t, "file-123", clicked.Data)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good_SubmenuItems(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestContextMenuService(t, mp)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskAdd{
|
||||
Name: "nested",
|
||||
Menu: ContextMenuDef{
|
||||
Name: "nested",
|
||||
Items: []MenuItemDef{
|
||||
{Label: "File", Type: "submenu", Items: []MenuItemDef{
|
||||
{Label: "New", ActionID: "new"},
|
||||
{Label: "Open", ActionID: "open"},
|
||||
}},
|
||||
{Type: "separator"},
|
||||
{Label: "Quit", ActionID: "quit"},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
result, _, _ := c.QUERY(QueryGet{Name: "nested"})
|
||||
def := result.(*ContextMenuDef)
|
||||
assert.Len(t, def.Items, 3)
|
||||
assert.Len(t, def.Items[0].Items, 2) // submenu children
|
||||
}
|
||||
|
||||
func TestQueryList_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.QUERY(QueryList{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue