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/bnema/puregotk/v4/gtk"
10 "github.com/stretchr/testify/assert"
11)
12
13// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
14// Used by tests that construct a KeyboardHandler.
15func newTestWorkspace() *entity.WorkspaceConfig {
16 return &entity.WorkspaceConfig{
17 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
18 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
19 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
20 }
21}
22
23// newTestSession creates a minimal SessionConfig with activation shortcuts set.
24func newTestSession() *entity.SessionConfig {
25 return &entity.SessionConfig{
26 SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
27 }
28}
29
30// stubAccentHandler is a test stub for the AccentHandler interface.
31type stubAccentHandler struct {
32 onKeyPressedResult bool
33}
34
35func (s *stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool {
36 return s.onKeyPressedResult
37}
38func (s *stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {}
39func (s *stubAccentHandler) IsPickerVisible() bool { return false }
40func (s *stubAccentHandler) Cancel(_ context.Context) {}
41
42func TestIsShortcutModified(t *testing.T) {
43 tests := []struct {
44 name string
45 mods Modifier
46 expected bool
47 }{
48 {"no modifier", ModNone, false},
49 {"shift only", ModShift, false},
50 {"ctrl", ModCtrl, true},
51 {"alt", ModAlt, true},
52 {"ctrl+shift", ModCtrl | ModShift, true},
53 {"alt+shift", ModAlt | ModShift, true},
54 {"ctrl+alt", ModCtrl | ModAlt, true},
55 }
56
57 for _, tt := range tests {
58 t.Run(tt.name, func(t *testing.T) {
59 assert.Equal(t, tt.expected, IsShortcutModified(tt.mods))
60 })
61 }
62}
63
64func TestIsTextInputKey(t *testing.T) {
65 tests := []struct {
66 name string
67 keyval uint
68 expected bool
69 }{
70 // Printable characters
71 {"lowercase a", uint('a'), true},
72 {"lowercase z", uint('z'), true},
73 {"uppercase A", uint('A'), true},
74 {"digit 1", uint('1'), true},
75 {"space", 0x020, true},
76 {"period", uint('.'), true},
77 {"slash", uint('/'), true},
78 {"apostrophe", 0x027, true},
79 {"grave accent", 0x060, true},
80 {"tilde", 0x07e, true},
81 {"quotedbl", 0x022, true},
82 {"caret", 0x05e, true},
83
84 // Dead keys (used by US International layout)
85 {"dead acute", 0xfe51, true},
86 {"dead grave", 0xfe50, true},
87 {"dead circumflex", 0xfe52, true},
88 {"dead tilde", 0xfe53, true},
89 {"dead diaeresis", 0xfe57, true},
90
91 // Dead key upper-bound boundary cases
92 {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true},
93 {"0xfe8d - one past highest dead key", 0xfe8d, false},
94
95 // Non-text keys (should NOT be treated as text)
96 {"escape", 0xff1b, false},
97 {"return", 0xff0d, false},
98 {"tab", 0xff09, false},
99 {"F1", 0xffbe, false},
100 {"F12", 0xffc9, false},
101 {"left arrow", 0xff51, false},
102 {"right arrow", 0xff53, false},
103 {"backspace", 0xff08, false},
104 {"delete", 0xffff, false},
105 {"home", 0xff50, false},
106 {"end", 0xff57, false},
107 }
108
109 for _, tt := range tests {
110 t.Run(tt.name, func(t *testing.T) {
111 assert.Equal(t, tt.expected, IsTextInputKey(tt.keyval), "keyval 0x%x", tt.keyval)
112 })
113 }
114}
115
116func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
117 ctx := context.Background()
118
119 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
120
121 // Route all non-shortcut keys to widget (simulates WebView text input)
122 h.SetRouteKey(func(kc KeyContext) KeyRoute {
123 return RoutePassToWidget
124 })
125
126 tests := []struct {
127 name string
128 keyval uint
129 state gdk.ModifierType
130 }{
131 {"plain 'e'", uint('e'), 0},
132 {"plain 'a'", uint('a'), 0},
133 {"shift 'E'", uint('E'), gdk.ShiftMaskValue},
134 {"space", 0x020, 0},
135 {"digit 1", uint('1'), 0},
136 }
137
138 for _, tt := range tests {
139 t.Run(tt.name, func(t *testing.T) {
140 result := h.handleKeyPress(tt.keyval, 0, tt.state)
141 assert.False(t, result, "RoutePassToWidget should let key propagate (return false)")
142 })
143 }
144}
145
146func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
147 ctx := context.Background()
148
149 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
150
151 // Set a stub accent handler that always returns false (simulates "no accents for this key")
152 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
153
154 // Route to accent detection
155 h.SetRouteKey(func(kc KeyContext) KeyRoute {
156 return RouteAccentDetection
157 })
158
159 // 'x' has no accents -> accent handler returns false -> key falls through
160 result := h.handleKeyPress(uint('x'), 0, 0)
161 assert.False(t, result, "'x' has no accents, should pass through")
162}
163
164func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
165 ctx := context.Background()
166
167 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
168
169 h.SetOnAction(func(ctx context.Context, action Action) error {
170 return nil
171 })
172
173 // Route text keys to widget, shortcuts to handler
174 h.SetRouteKey(func(kc KeyContext) KeyRoute {
175 if IsShortcutModified(kc.Modifiers) {
176 return RouteHandleShortcuts
177 }
178 return RoutePassToWidget
179 })
180
181 // Ctrl+L = open omnibox (registered global shortcut)
182 result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue)
183 assert.True(t, result, "Ctrl+L should be consumed by shortcut handler")
184}
185
186func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
187 ctx := context.Background()
188
189 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
190 h.EnterTabMode()
191
192 // Even if routeKey says pass to widget, modal mode ignores it
193 h.SetRouteKey(func(kc KeyContext) KeyRoute {
194 return RoutePassToWidget
195 })
196
197 // In modal mode, unknown keys are consumed (return true)
198 result := h.handleKeyPress(uint('z'), 0, 0)
199 assert.True(t, result, "modal mode should consume unknown keys regardless of route")
200}
201
202func TestKeyboardHandler_SetRouteKey(t *testing.T) {
203 ctx := context.Background()
204 h := NewKeyboardHandler(ctx, nil, nil)
205
206 // Default: no callback set
207 h.mu.RLock()
208 fn := h.routeKey
209 h.mu.RUnlock()
210 assert.Nil(t, fn, "routeKey should be nil by default")
211
212 // Set callback
213 h.SetRouteKey(func(kc KeyContext) KeyRoute {
214 return RoutePassToWidget
215 })
216
217 h.mu.RLock()
218 fn = h.routeKey
219 h.mu.RUnlock()
220 assert.NotNil(t, fn, "routeKey should be set")
221 assert.Equal(t, RoutePassToWidget, fn(KeyContext{}))
222}
223
224func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
225 ctx := context.Background()
226
227 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
228
229 // No routeKey set -- should default to shortcut handling
230 // 'z' is not a registered shortcut in ModeNormal, so returns false
231 result := h.handleKeyPress(uint('z'), 0, 0)
232 assert.False(t, result, "unregistered key in ModeNormal returns false")
233}
234
235func TestKeyboardHandlerDetachForDestroyClearsRetainedCallbacksAndState(t *testing.T) {
236 h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
237 h.keyPressedCb = func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool { return true }
238 h.keyReleasedCb = func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) {}
239 h.keyPressedHandlerID = 1
240 h.keyReleasedHandlerID = 2
241 h.activePressedActions = map[Action]uint{ActionReload: 1}
242
243 h.DetachForDestroy()
244
245 assert.Nil(t, h.controller)
246 assert.Nil(t, h.window)
247 assert.Nil(t, h.keyPressedCb)
248 assert.Nil(t, h.keyReleasedCb)
249 assert.Zero(t, h.keyPressedHandlerID)
250 assert.Zero(t, h.keyReleasedHandlerID)
251 assert.Nil(t, h.activePressedActions)
252}
253
254func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
255 h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
256 calls := 0
257 h.SetOnAction(func(_ context.Context, action Action) error {
258 calls++
259 if action != ActionSwitchTabIndex2 {
260 t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
261 }
262 return nil
263 })
264
265 const fallbackKeycode = 11
266 if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
267 t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
268 }
269
270 if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
271 t.Fatal("first tab-switch key press should be consumed")
272 }
273 if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
274 t.Fatal("repeated held tab-switch key press should still be consumed")
275 }
276 if calls != 1 {
277 t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
278 }
279
280 h.handleKeyRelease(uint('x'))
281 if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
282 t.Fatal("tab-switch key press after release should be consumed")
283 }
284 if calls != 2 {
285 t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
286 }
287}
288
289func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
290 h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
291 const resizeKeycode = 27
292 mods := gdk.ControlMaskValue | gdk.AltMaskValue
293
294 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
295 t.Fatal("first resize-mode activation should be consumed")
296 }
297 if got := h.Mode(); got != ModeResize {
298 t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
299 }
300
301 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
302 t.Fatal("repeated held resize-mode activation should be consumed")
303 }
304 if got := h.Mode(); got != ModeResize {
305 t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
306 }
307
308 h.handleKeyRelease(uint('x'))
309 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
310 t.Fatal("resize-mode activation after unrelated release should still be consumed")
311 }
312 if got := h.Mode(); got != ModeResize {
313 t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
314 }
315
316 h.handleKeyRelease(uint('r'))
317 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
318 t.Fatal("resize-mode activation after release should be consumed")
319 }
320 if got := h.Mode(); got != ModeNormal {
321 t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
322 }
323}
324
325func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
326 workspace := newTestWorkspace()
327 workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
328 "toggle_floating_pane": {Keys: []string{"alt+f"}},
329 }
330 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
331 calls := 0
332 h.SetOnAction(func(_ context.Context, action Action) error {
333 calls++
334 if action != ActionToggleFloatingPane {
335 t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
336 }
337 return nil
338 })
339
340 if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
341 t.Fatal("first floating pane toggle should be consumed")
342 }
343 h.handleKeyRelease(uint('x'))
344 if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
345 t.Fatal("held floating pane toggle repeat should still be consumed")
346 }
347 if calls != 1 {
348 t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
349 }
350
351 h.handleKeyRelease(uint('f'))
352 if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
353 t.Fatal("floating pane toggle after release should be consumed")
354 }
355 if calls != 2 {
356 t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
357 }
358}
359
360func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
361 if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
362 t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
363 }
364}