1package input
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/puregotk/v4/gdk"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11)
12
13func newTmuxWorkspace() *entity.WorkspaceConfig {
14 ws := newTestWorkspace()
15 ws.Keymap = entity.KeymapTmux
16 ws.VimMode.ActivationShortcut = "ctrl+y"
17 ws.PrefixMode = entity.PrefixModeConfig{
18 ActivationShortcut: "ctrl+space",
19 Actions: map[string]entity.ActionBinding{
20 "split-right": {Keys: []string{"%"}},
21 "split-down": {Keys: []string{"\""}},
22 "new-tab": {Keys: []string{"c"}},
23 "next-tab": {Keys: []string{"n"}},
24 "enter-resize-mode": {Keys: []string{"r"}},
25 "cancel": {Keys: []string{"escape"}},
26 },
27 }
28 return ws
29}
30
31func recordActions(h *KeyboardHandler) *[]Action {
32 var got []Action
33 h.SetOnAction(func(_ context.Context, action Action) error {
34 got = append(got, action)
35 return nil
36 })
37 return &got
38}
39
40func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) {
41 set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession())
42
43 prefix, _ := ParseKeyString("ctrl+space")
44 assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix])
45 for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} {
46 binding, ok := ParseKeyString(key)
47 require.True(t, ok)
48 _, bound := set.Global[binding]
49 assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key)
50 }
51 vim, _ := ParseKeyString("ctrl+y")
52 assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation")
53}
54
55func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) {
56 ws := newTmuxWorkspace()
57 ws.Keymap = entity.KeymapZellij
58 set := NewShortcutSet(context.Background(), ws, newTestSession())
59
60 prefix, _ := ParseKeyString("ctrl+space")
61 _, bound := set.Global[prefix]
62 assert.False(t, bound)
63 tab, _ := ParseKeyString("ctrl+t")
64 assert.Equal(t, ActionEnterTabMode, set.Global[tab])
65}
66
67func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) {
68 h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
69 got := recordActions(h)
70
71 require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
72 require.Equal(t, ModePrefix, h.Mode())
73
74 // '%' is typed with Shift on most layouts; the binding omits Shift.
75 require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue))
76 assert.Equal(t, []Action{ActionSplitRight}, *got)
77 assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot")
78
79 // A plain key after the prefix exited must not dispatch again.
80 assert.False(t, h.handleKeyPress(uint('n'), 0, 0))
81 assert.Len(t, *got, 1)
82}
83
84func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) {
85 h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
86 got := recordActions(h)
87
88 h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
89 assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed")
90 assert.Equal(t, ModeNormal, h.Mode())
91 assert.Empty(t, *got)
92}
93
94func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) {
95 h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
96
97 h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
98 h.handleKeyPress(uint('r'), 0, 0)
99 assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode")
100}
101
102func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
103 h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
104
105 h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
106 h.handleKeyRelease(uint(gdk.KEY_space))
107 h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
108 assert.Equal(t, ModeNormal, h.Mode())
109}
110
111func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
112 ws := newTestWorkspace()
113 ws.Shortcuts.Actions = map[string]entity.ActionBinding{
114 "reload": {Keys: []string{}}, // disabled by user
115 "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user
116 }
117 set := NewShortcutSet(context.Background(), ws, newTestSession())
118
119 lookup := func(key string) (Action, bool) {
120 binding, ok := ParseKeyString(key)
121 require.True(t, ok, key)
122 action, found := set.Global[binding]
123 return action, found
124 }
125
126 action, ok := lookup("ctrl+l")
127 assert.True(t, ok, "missing builtin action falls back to its default key")
128 assert.Equal(t, ActionOpenOmnibox, action)
129
130 _, ok = lookup("ctrl+r")
131 assert.False(t, ok, "empty keys disable a builtin shortcut")
132
133 _, ok = lookup("ctrl+q")
134 assert.False(t, ok, "rebound builtin drops its default key")
135 action, _ = lookup("ctrl+shift+q")
136 assert.Equal(t, ActionQuit, action)
137}
138
139func TestParseKeyString_PrintableSymbols(t *testing.T) {
140 for _, key := range []string{"%", "\"", "&", ",", "!"} {
141 binding, ok := ParseKeyString(key)
142 require.Truef(t, ok, "%q should parse", key)
143 assert.Equal(t, uint(key[0]), binding.Keyval)
144 assert.Equal(t, ModNone, binding.Modifiers)
145 }
146}
147
148type keyEvent struct {
149 keyval uint
150 state gdk.ModifierType
151}
152
153// TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers
154// them: modifier and dead-key presses arrive before the character.
155func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) {
156 shift := gdk.ShiftMaskValue
157 tests := []struct {
158 name string
159 events []keyEvent
160 wantAction []Action
161 wantMode Mode
162 lastResult bool
163 }{
164 {
165 name: "shift then percent splits right",
166 events: []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}},
167 wantAction: []Action{ActionSplitRight},
168 wantMode: ModeNormal,
169 lastResult: true,
170 },
171 {
172 name: "shift then quote splits down",
173 events: []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}},
174 wantAction: []Action{ActionSplitDown},
175 wantMode: ModeNormal,
176 lastResult: true,
177 },
178 {
179 name: "dead diaeresis splits down on international layouts",
180 events: []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}},
181 wantAction: []Action{ActionSplitDown},
182 wantMode: ModeNormal,
183 lastResult: true,
184 },
185 {
186 name: "altgr alone keeps the prefix waiting",
187 events: []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}},
188 wantMode: ModePrefix,
189 lastResult: true,
190 },
191 {
192 name: "unbound modified key cancels and passes through",
193 events: []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}},
194 wantMode: ModeNormal,
195 lastResult: false,
196 },
197 }
198
199 for _, tt := range tests {
200 t.Run(tt.name, func(t *testing.T) {
201 h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
202 got := recordActions(h)
203 require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
204 h.handleKeyRelease(uint(gdk.KEY_space))
205
206 var result bool
207 for _, ev := range tt.events {
208 result = h.handleKeyPress(ev.keyval, 0, ev.state)
209 }
210 assert.Equal(t, tt.lastResult, result)
211 assert.Equal(t, tt.wantMode, h.Mode())
212 assert.Equal(t, tt.wantAction, *got)
213 })
214 }
215}
216
217func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) {
218 ws := newTestWorkspace()
219 ws.Shortcuts.Actions = map[string]entity.ActionBinding{
220 "toggle-floating-pane": {Keys: []string{"ctrl+l"}},
221 "open-omnibox": {Keys: []string{"ctrl+l"}},
222 }
223 for range 20 {
224 set := NewShortcutSet(context.Background(), ws, newTestSession())
225 binding, _ := ParseKeyString("ctrl+l")
226 require.Equal(t, ActionOpenOmnibox, set.Global[binding])
227 }
228}