diff --git a/pkg/keybinding/messages.go b/pkg/keybinding/messages.go index 08771d2..4d82713 100644 --- a/pkg/keybinding/messages.go +++ b/pkg/keybinding/messages.go @@ -3,6 +3,7 @@ package keybinding import "errors" var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered") +var ErrorNotRegistered = errors.New("keybinding: accelerator not registered") // BindingInfo describes a registered global key binding. type BindingInfo struct { @@ -19,11 +20,19 @@ type TaskAdd struct { Description string `json:"description"` } -// TaskRemove unregisters a global key binding by accelerator. +// TaskRemove unregisters a global key binding by accelerator. Error: ErrorNotRegistered if not found. type TaskRemove struct { Accelerator string `json:"accelerator"` } +// TaskProcess triggers a registered key binding programmatically. +// Returns ActionTriggered if the accelerator was handled, ErrorNotRegistered if not found. +// +// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"}) +type TaskProcess struct { + Accelerator string `json:"accelerator"` +} + // ActionTriggered is broadcast when a registered key binding fires. type ActionTriggered struct { Accelerator string `json:"accelerator"` diff --git a/pkg/keybinding/platform.go b/pkg/keybinding/platform.go index 732ad20..154e08a 100644 --- a/pkg/keybinding/platform.go +++ b/pkg/keybinding/platform.go @@ -12,6 +12,12 @@ type Platform interface { // Remove unregisters a previously registered keyboard shortcut. Remove(accelerator string) error + // Process triggers the registered handler for the given accelerator programmatically. + // Returns true if a handler was found and invoked, false if not registered. + // + // handled := platform.Process("Ctrl+S") + Process(accelerator string) bool + // GetAll returns all currently registered accelerator strings. // Used for adapter-level reconciliation only — not read by QueryList. GetAll() []string diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go index 3afd23b..c88fb6b 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -53,6 +53,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskAdd(t) case TaskRemove: return nil, true, s.taskRemove(t) + case TaskProcess: + return nil, true, s.taskProcess(t) default: return nil, false, nil } @@ -80,7 +82,7 @@ func (s *Service) taskAdd(t TaskAdd) error { func (s *Service) taskRemove(t TaskRemove) error { if _, exists := s.registeredBindings[t.Accelerator]; !exists { - return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil) + return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, ErrorNotRegistered) } err := s.platform.Remove(t.Accelerator) @@ -91,3 +93,20 @@ func (s *Service) taskRemove(t TaskRemove) error { delete(s.registeredBindings, t.Accelerator) return nil } + +// taskProcess triggers the registered handler for the given accelerator programmatically. +// Broadcasts ActionTriggered if handled; returns ErrorNotRegistered if the accelerator is unknown. +// +// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"}) +func (s *Service) taskProcess(t TaskProcess) error { + if _, exists := s.registeredBindings[t.Accelerator]; !exists { + return coreerr.E("keybinding.taskProcess", "not registered: "+t.Accelerator, ErrorNotRegistered) + } + + handled := s.platform.Process(t.Accelerator) + if !handled { + return coreerr.E("keybinding.taskProcess", "platform did not handle: "+t.Accelerator, nil) + } + + return nil +} diff --git a/pkg/keybinding/service_test.go b/pkg/keybinding/service_test.go index b586e07..bf8766e 100644 --- a/pkg/keybinding/service_test.go +++ b/pkg/keybinding/service_test.go @@ -37,6 +37,17 @@ func (m *mockPlatform) Remove(accelerator string) error { return nil } +func (m *mockPlatform) Process(accelerator string) bool { + m.mu.Lock() + h, ok := m.handlers[accelerator] + m.mu.Unlock() + if ok && h != nil { + h() + return true + } + return false +} + func (m *mockPlatform) GetAll() []string { m.mu.Lock() defer m.mu.Unlock() @@ -199,3 +210,88 @@ func TestQueryList_Bad_NoService(t *testing.T) { _, handled, _ := c.QUERY(QueryList{}) assert.False(t, handled) } + +// --- TaskProcess tests --- + +func TestTaskProcess_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"}) + + 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 + }) + + _, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"}) + require.NoError(t, err) + assert.True(t, handled) + + mu.Lock() + assert.Equal(t, "Ctrl+P", triggered.Accelerator) + mu.Unlock() +} + +func TestTaskProcess_Bad_NotRegistered(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"}) + assert.True(t, handled) + assert.ErrorIs(t, err, ErrorNotRegistered) +} + +func TestTaskProcess_Ugly_RemovedBinding(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"}) + _, _, _ = c.PERFORM(TaskRemove{Accelerator: "Ctrl+P"}) + + // After remove, process should fail with ErrorNotRegistered + _, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"}) + assert.True(t, handled) + assert.ErrorIs(t, err, ErrorNotRegistered) +} + +// --- TaskRemove ErrorNotRegistered sentinel tests --- + +func TestTaskRemove_Bad_ErrorSentinel(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"}) + assert.True(t, handled) + assert.ErrorIs(t, err, ErrorNotRegistered) +} + +// --- QueryList Ugly: concurrent adds --- + +func TestQueryList_Ugly_ConcurrentAdds(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + accelerators := []string{"Ctrl+1", "Ctrl+2", "Ctrl+3", "Ctrl+4", "Ctrl+5"} + var wg sync.WaitGroup + for _, accelerator := range accelerators { + wg.Add(1) + go func(acc string) { + defer wg.Done() + _, _, _ = c.PERFORM(TaskAdd{Accelerator: acc, Description: acc}) + }(accelerator) + } + wg.Wait() + + result, handled, err := c.QUERY(QueryList{}) + require.NoError(t, err) + assert.True(t, handled) + list := result.([]BindingInfo) + assert.Len(t, list, len(accelerators)) +}