feat(keybinding): add keybinding core.Service with Platform interface and IPC
Implements pkg/keybinding with three-layer pattern: IPC Bus -> Service -> Platform. Service maintains in-memory registry, ErrAlreadyRegistered on duplicates. QueryList reads from service registry, not platform.GetAll(). ActionTriggered broadcast on shortcut trigger via platform callback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b5b5282349
commit
3954725d45
5 changed files with 375 additions and 0 deletions
40
pkg/keybinding/messages.go
Normal file
40
pkg/keybinding/messages.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// pkg/keybinding/messages.go
|
||||
package keybinding
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrAlreadyRegistered is returned when attempting to add a binding
|
||||
// that already exists. Callers must TaskRemove first to rebind.
|
||||
var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered")
|
||||
|
||||
// BindingInfo describes a registered keyboard shortcut.
|
||||
type BindingInfo struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
// QueryList returns all registered bindings. Result: []BindingInfo
|
||||
type QueryList struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskAdd registers a new keyboard shortcut. Result: nil
|
||||
// Returns ErrAlreadyRegistered if the accelerator is already bound.
|
||||
type TaskAdd struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TaskRemove unregisters a keyboard shortcut. Result: nil
|
||||
type TaskRemove struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// ActionTriggered is broadcast when a registered shortcut is activated.
|
||||
type ActionTriggered struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
18
pkg/keybinding/platform.go
Normal file
18
pkg/keybinding/platform.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// pkg/keybinding/platform.go
|
||||
package keybinding
|
||||
|
||||
// Platform abstracts the keyboard shortcut backend (Wails v3).
|
||||
type Platform interface {
|
||||
// Add registers a global keyboard shortcut with the given accelerator string.
|
||||
// The handler is called when the shortcut is triggered.
|
||||
// Accelerator syntax is platform-aware: "Cmd+S" (macOS), "Ctrl+S" (Windows/Linux).
|
||||
// Special keys: F1-F12, Escape, Enter, Space, Tab, Backspace, Delete, arrow keys.
|
||||
Add(accelerator string, handler func()) error
|
||||
|
||||
// Remove unregisters a previously registered keyboard shortcut.
|
||||
Remove(accelerator string) error
|
||||
|
||||
// GetAll returns all currently registered accelerator strings.
|
||||
// Used for adapter-level reconciliation only — not read by QueryList.
|
||||
GetAll() []string
|
||||
}
|
||||
16
pkg/keybinding/register.go
Normal file
16
pkg/keybinding/register.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// pkg/keybinding/register.go
|
||||
package keybinding
|
||||
|
||||
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,
|
||||
bindings: make(map[string]BindingInfo),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
100
pkg/keybinding/service.go
Normal file
100
pkg/keybinding/service.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// pkg/keybinding/service.go
|
||||
package keybinding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the keybinding service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing keyboard shortcuts via IPC.
|
||||
// It maintains an in-memory registry of bindings and delegates
|
||||
// platform-level registration to the Platform interface.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
bindings map[string]BindingInfo
|
||||
}
|
||||
|
||||
// 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.(type) {
|
||||
case QueryList:
|
||||
return s.queryList(), true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// queryList reads from the in-memory registry (not platform.GetAll()).
|
||||
func (s *Service) queryList() []BindingInfo {
|
||||
result := make([]BindingInfo, 0, len(s.bindings))
|
||||
for _, info := range s.bindings {
|
||||
result = append(result, info)
|
||||
}
|
||||
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 _, exists := s.bindings[t.Accelerator]; exists {
|
||||
return ErrAlreadyRegistered
|
||||
}
|
||||
|
||||
// Register on platform with a callback that broadcasts ActionTriggered
|
||||
err := s.platform.Add(t.Accelerator, func() {
|
||||
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("keybinding: platform add failed: %w", err)
|
||||
}
|
||||
|
||||
s.bindings[t.Accelerator] = BindingInfo{
|
||||
Accelerator: t.Accelerator,
|
||||
Description: t.Description,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskRemove(t TaskRemove) error {
|
||||
if _, exists := s.bindings[t.Accelerator]; !exists {
|
||||
return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
|
||||
}
|
||||
|
||||
err := s.platform.Remove(t.Accelerator)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keybinding: platform remove failed: %w", err)
|
||||
}
|
||||
|
||||
delete(s.bindings, t.Accelerator)
|
||||
return nil
|
||||
}
|
||||
201
pkg/keybinding/service_test.go
Normal file
201
pkg/keybinding/service_test.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// pkg/keybinding/service_test.go
|
||||
package keybinding
|
||||
|
||||
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 triggering shortcuts.
|
||||
type mockPlatform struct {
|
||||
mu sync.Mutex
|
||||
handlers map[string]func()
|
||||
removed []string
|
||||
}
|
||||
|
||||
func newMockPlatform() *mockPlatform {
|
||||
return &mockPlatform{handlers: make(map[string]func())}
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Add(accelerator string, handler func()) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.handlers[accelerator] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Remove(accelerator string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.handlers, accelerator)
|
||||
m.removed = append(m.removed, accelerator)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) GetAll() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]string, 0, len(m.handlers))
|
||||
for k := range m.handlers {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trigger simulates a shortcut keypress by calling the registered handler.
|
||||
func (m *mockPlatform) trigger(accelerator string) {
|
||||
m.mu.Lock()
|
||||
h, ok := m.handlers[accelerator]
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
h()
|
||||
}
|
||||
}
|
||||
|
||||
func newTestKeybindingService(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, "keybinding")
|
||||
return svc, c
|
||||
}
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
svc, _ := newTestKeybindingService(t, mp)
|
||||
assert.NotNil(t, svc)
|
||||
assert.NotNil(t, svc.platform)
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskAdd{
|
||||
Accelerator: "Ctrl+S", Description: "Save",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify binding registered on platform
|
||||
assert.Contains(t, mp.GetAll(), "Ctrl+S")
|
||||
}
|
||||
|
||||
func TestTaskAdd_Bad_Duplicate(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||
|
||||
// Second add with same accelerator should fail
|
||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrAlreadyRegistered)
|
||||
}
|
||||
|
||||
func TestTaskRemove_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||
_, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify removed from platform
|
||||
assert.NotContains(t, mp.GetAll(), "Ctrl+S")
|
||||
}
|
||||
|
||||
func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestQueryList_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"})
|
||||
|
||||
result, handled, err := c.QUERY(QueryList{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
list := result.([]BindingInfo)
|
||||
assert.Len(t, list, 2)
|
||||
}
|
||||
|
||||
func TestQueryList_Good_Empty(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
result, handled, err := c.QUERY(QueryList{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
list := result.([]BindingInfo)
|
||||
assert.Len(t, list, 0)
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good_TriggerBroadcast(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
// Capture broadcast actions
|
||||
var triggered ActionTriggered
|
||||
var mu sync.Mutex
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionTriggered); ok {
|
||||
mu.Lock()
|
||||
triggered = a
|
||||
mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||
|
||||
// Simulate shortcut trigger via mock
|
||||
mp.trigger("Ctrl+S")
|
||||
|
||||
mu.Lock()
|
||||
assert.Equal(t, "Ctrl+S", triggered.Accelerator)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestTaskAdd_Good_RebindAfterRemove(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||
_, _, _ = c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"})
|
||||
|
||||
// Should succeed after remove
|
||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify new description
|
||||
result, _, _ := c.QUERY(QueryList{})
|
||||
list := result.([]BindingInfo)
|
||||
assert.Len(t, list, 1)
|
||||
assert.Equal(t, "Save v2", list[0].Description)
|
||||
}
|
||||
|
||||
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