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_BuiltinShortcutsAndOverrides(t *testing.T) {
112 ws := newTestWorkspace()
113 ws.Shortcuts.Actions["reload"] = entity.ActionBinding{Keys: []string{}} // disabled by user
114 ws.Shortcuts.Actions["quit"] = entity.ActionBinding{Keys: []string{"ctrl+shift+q"}} // rebound by user
115 set := NewShortcutSet(context.Background(), ws, newTestSession())
116
117 lookup := func(key string) (Action, bool) {
118 binding, ok := ParseKeyString(key)
119 require.True(t, ok, key)
120 action, found := set.Global[binding]
121 return action, found
122 }
123
124 action, ok := lookup("ctrl+l")
125 assert.True(t, ok, "untouched builtin action keeps its default key")
126 assert.Equal(t, ActionOpenOmnibox, action)
127
128 _, ok = lookup("ctrl+r")
129 assert.False(t, ok, "empty keys disable a builtin shortcut")
130
131 _, ok = lookup("ctrl+q")
132 assert.False(t, ok, "rebound builtin drops its default key")
133 action, _ = lookup("ctrl+shift+q")
134 assert.Equal(t, ActionQuit, action)
135}
136
137func TestParseKeyString_PrintableSymbols(t *testing.T) {
138 for _, key := range []string{"%", "\"", "&", ",", "!"} {
139 binding, ok := ParseKeyString(key)
140 require.Truef(t, ok, "%q should parse", key)
141 assert.Equal(t, uint(key[0]), binding.Keyval)
142 assert.Equal(t, ModNone, binding.Modifiers)
143 }
144}
145
146type keyEvent struct {
147 keyval uint
148 state gdk.ModifierType
149}
150
151// TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers
152// them: modifier and dead-key presses arrive before the character.
153func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) {
154 shift := gdk.ShiftMaskValue
155 tests := []struct {
156 name string
157 events []keyEvent
158 wantAction []Action
159 wantMode Mode
160 lastResult bool
161 }{
162 {
163 name: "shift then percent splits right",
164 events: []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}},
165 wantAction: []Action{ActionSplitRight},
166 wantMode: ModeNormal,
167 lastResult: true,
168 },
169 {
170 name: "shift then quote splits down",
171 events: []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}},
172 wantAction: []Action{ActionSplitDown},
173 wantMode: ModeNormal,
174 lastResult: true,
175 },
176 {
177 name: "dead diaeresis splits down on international layouts",
178 events: []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}},
179 wantAction: []Action{ActionSplitDown},
180 wantMode: ModeNormal,
181 lastResult: true,
182 },
183 {
184 name: "altgr alone keeps the prefix waiting",
185 events: []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}},
186 wantMode: ModePrefix,
187 lastResult: true,
188 },
189 {
190 name: "unbound modified key cancels and passes through",
191 events: []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}},
192 wantMode: ModeNormal,
193 lastResult: false,
194 },
195 }
196
197 for _, tt := range tests {
198 t.Run(tt.name, func(t *testing.T) {
199 h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
200 got := recordActions(h)
201 require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
202 h.handleKeyRelease(uint(gdk.KEY_space))
203
204 var result bool
205 for _, ev := range tt.events {
206 result = h.handleKeyPress(ev.keyval, 0, ev.state)
207 }
208 assert.Equal(t, tt.lastResult, result)
209 assert.Equal(t, tt.wantMode, h.Mode())
210 assert.Equal(t, tt.wantAction, *got)
211 })
212 }
213}
214
215func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) {
216 ws := newTestWorkspace()
217 ws.Shortcuts.Actions = map[string]entity.ActionBinding{
218 "toggle-floating-pane": {Keys: []string{"ctrl+l"}},
219 "open-omnibox": {Keys: []string{"ctrl+l"}},
220 }
221 for range 20 {
222 set := NewShortcutSet(context.Background(), ws, newTestSession())
223 binding, _ := ParseKeyString("ctrl+l")
224 require.Equal(t, ActionOpenOmnibox, set.Global[binding])
225 }
226}