diff --git a/pkg/keybinding/messages.go b/pkg/keybinding/messages.go new file mode 100644 index 0000000..7f037f3 --- /dev/null +++ b/pkg/keybinding/messages.go @@ -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"` +} diff --git a/pkg/keybinding/platform.go b/pkg/keybinding/platform.go new file mode 100644 index 0000000..732ad20 --- /dev/null +++ b/pkg/keybinding/platform.go @@ -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 +} diff --git a/pkg/keybinding/register.go b/pkg/keybinding/register.go new file mode 100644 index 0000000..417819e --- /dev/null +++ b/pkg/keybinding/register.go @@ -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 + } +} diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go new file mode 100644 index 0000000..048c259 --- /dev/null +++ b/pkg/keybinding/service.go @@ -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 +} diff --git a/pkg/keybinding/service_test.go b/pkg/keybinding/service_test.go new file mode 100644 index 0000000..14749f2 --- /dev/null +++ b/pkg/keybinding/service_test.go @@ -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) +}