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:
Snider 2026-03-13 14:34:30 +00:00
parent b5b5282349
commit 3954725d45
5 changed files with 375 additions and 0 deletions

View 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"`
}

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

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

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