1package input
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/puregotk/v4/gdk"
10 "github.com/bnema/puregotk/v4/glib"
11 "github.com/bnema/puregotk/v4/gtk"
12 "github.com/stretchr/testify/assert"
13)
14
15// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
16// Used by tests that construct a KeyboardHandler.
17func newTestWorkspace() *entity.WorkspaceConfig {
18 return &entity.WorkspaceConfig{
19 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
20 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
21 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
22 }
23}
24
25// newTestSession creates a minimal SessionConfig with activation shortcuts set.
26func newTestSession() *entity.SessionConfig {
27 return &entity.SessionConfig{
28 SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
29 }
30}
31
32type fakeModalTimer struct{}
33
34func (*fakeModalTimer) Stop() bool { return true }
35
36// stubAccentHandler is a test stub for the AccentHandler interface.
37type stubAccentHandler struct {
38 onKeyPressedResult bool
39}
40
41func (s *stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool {
42 return s.onKeyPressedResult
43}
44func (s *stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {}
45func (s *stubAccentHandler) IsPickerVisible() bool { return false }
46func (s *stubAccentHandler) Cancel(_ context.Context) {}
47
48func TestIsShortcutModified(t *testing.T) {
49 tests := []struct {
50 name string
51 mods Modifier
52 expected bool
53 }{
54 {"no modifier", ModNone, false},
55 {"shift only", ModShift, false},
56 {"ctrl", ModCtrl, true},
57 {"alt", ModAlt, true},
58 {"ctrl+shift", ModCtrl | ModShift, true},
59 {"alt+shift", ModAlt | ModShift, true},
60 {"ctrl+alt", ModCtrl | ModAlt, true},
61 }
62
63 for _, tt := range tests {
64 t.Run(tt.name, func(t *testing.T) {
65 assert.Equal(t, tt.expected, IsShortcutModified(tt.mods))
66 })
67 }
68}
69
70func TestIsTextInputKey(t *testing.T) {
71 tests := []struct {
72 name string
73 keyval uint
74 expected bool
75 }{
76 // Printable characters
77 {"lowercase a", uint('a'), true},
78 {"lowercase z", uint('z'), true},
79 {"uppercase A", uint('A'), true},
80 {"digit 1", uint('1'), true},
81 {"space", 0x020, true},
82 {"period", uint('.'), true},
83 {"slash", uint('/'), true},
84 {"apostrophe", 0x027, true},
85 {"grave accent", 0x060, true},
86 {"tilde", 0x07e, true},
87 {"quotedbl", 0x022, true},
88 {"caret", 0x05e, true},
89
90 // Dead keys (used by US International layout)
91 {"dead acute", 0xfe51, true},
92 {"dead grave", 0xfe50, true},
93 {"dead circumflex", 0xfe52, true},
94 {"dead tilde", 0xfe53, true},
95 {"dead diaeresis", 0xfe57, true},
96
97 // Dead key upper-bound boundary cases
98 {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true},
99 {"0xfe8d - one past highest dead key", 0xfe8d, false},
100
101 // Non-text keys (should NOT be treated as text)
102 {"escape", 0xff1b, false},
103 {"return", 0xff0d, false},
104 {"tab", 0xff09, false},
105 {"F1", 0xffbe, false},
106 {"F12", 0xffc9, false},
107 {"left arrow", 0xff51, false},
108 {"right arrow", 0xff53, false},
109 {"backspace", 0xff08, false},
110 {"delete", 0xffff, false},
111 {"home", 0xff50, false},
112 {"end", 0xff57, false},
113 }
114
115 for _, tt := range tests {
116 t.Run(tt.name, func(t *testing.T) {
117 assert.Equal(t, tt.expected, IsTextInputKey(tt.keyval), "keyval 0x%x", tt.keyval)
118 })
119 }
120}
121
122func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
123 ctx := context.Background()
124
125 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
126
127 // Route all non-shortcut keys to widget (simulates WebView text input)
128 h.SetRouteKey(func(kc KeyContext) KeyRoute {
129 return RoutePassToWidget
130 })
131
132 tests := []struct {
133 name string
134 keyval uint
135 state gdk.ModifierType
136 }{
137 {"plain 'e'", uint('e'), 0},
138 {"plain 'a'", uint('a'), 0},
139 {"shift 'E'", uint('E'), gdk.ShiftMaskValue},
140 {"space", 0x020, 0},
141 {"digit 1", uint('1'), 0},
142 }
143
144 for _, tt := range tests {
145 t.Run(tt.name, func(t *testing.T) {
146 result := h.handleKeyPress(tt.keyval, 0, tt.state)
147 assert.False(t, result, "RoutePassToWidget should let key propagate (return false)")
148 })
149 }
150}
151
152func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
153 ctx := context.Background()
154
155 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
156
157 // Set a stub accent handler that always returns false (simulates "no accents for this key")
158 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
159
160 // Route to accent detection
161 h.SetRouteKey(func(kc KeyContext) KeyRoute {
162 return RouteAccentDetection
163 })
164
165 // 'x' has no accents -> accent handler returns false -> key falls through
166 result := h.handleKeyPress(uint('x'), 0, 0)
167 assert.False(t, result, "'x' has no accents, should pass through")
168}
169
170func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
171 ctx := context.Background()
172
173 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
174
175 h.SetOnAction(func(ctx context.Context, action Action) error {
176 return nil
177 })
178
179 // Route text keys to widget, shortcuts to handler
180 h.SetRouteKey(func(kc KeyContext) KeyRoute {
181 if IsShortcutModified(kc.Modifiers) {
182 return RouteHandleShortcuts
183 }
184 return RoutePassToWidget
185 })
186
187 // Ctrl+L = open omnibox (registered global shortcut)
188 result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue)
189 assert.True(t, result, "Ctrl+L should be consumed by shortcut handler")
190}
191
192func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
193 ctx := context.Background()
194
195 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
196 h.EnterTabMode()
197
198 // Even if routeKey says pass to widget, modal mode ignores it
199 h.SetRouteKey(func(kc KeyContext) KeyRoute {
200 return RoutePassToWidget
201 })
202
203 // In modal mode, unknown keys are consumed (return true)
204 result := h.handleKeyPress(uint('z'), 0, 0)
205 assert.True(t, result, "modal mode should consume unknown keys regardless of route")
206}
207
208func TestKeyboardHandler_SetRouteKey(t *testing.T) {
209 ctx := context.Background()
210 h := NewKeyboardHandler(ctx, nil, nil)
211
212 // Default: no callback set
213 h.mu.RLock()
214 fn := h.routeKey
215 h.mu.RUnlock()
216 assert.Nil(t, fn, "routeKey should be nil by default")
217
218 // Set callback
219 h.SetRouteKey(func(kc KeyContext) KeyRoute {
220 return RoutePassToWidget
221 })
222
223 h.mu.RLock()
224 fn = h.routeKey
225 h.mu.RUnlock()
226 assert.NotNil(t, fn, "routeKey should be set")
227 assert.Equal(t, RoutePassToWidget, fn(KeyContext{}))
228}
229
230func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
231 ctx := context.Background()
232
233 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
234
235 // No routeKey set -- should default to shortcut handling
236 // 'z' is not a registered shortcut in ModeNormal, so returns false
237 result := h.handleKeyPress(uint('z'), 0, 0)
238 assert.False(t, result, "unregistered key in ModeNormal returns false")
239}
240
241func TestKeyboardHandlerDetachForDestroyClearsRetainedCallbacksAndState(t *testing.T) {
242 h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
243 h.keyPressedCb = func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool { return true }
244 h.keyReleasedCb = func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) {}
245 h.keyPressedHandlerID = 1
246 h.keyReleasedHandlerID = 2
247 h.activePressedActions = map[Action]uint{ActionReload: 1}
248
249 h.DetachForDestroy()
250
251 assert.Nil(t, h.controller)
252 assert.Nil(t, h.window)
253 assert.Nil(t, h.keyPressedCb)
254 assert.Nil(t, h.keyReleasedCb)
255 assert.Zero(t, h.keyPressedHandlerID)
256 assert.Zero(t, h.keyReleasedHandlerID)
257 assert.Nil(t, h.activePressedActions)
258}
259
260func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
261 h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
262 calls := 0
263 h.SetOnAction(func(_ context.Context, action Action) error {
264 calls++
265 if action != ActionSwitchTabIndex2 {
266 t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
267 }
268 return nil
269 })
270
271 const fallbackKeycode = 11
272 if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
273 t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
274 }
275
276 if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
277 t.Fatal("first tab-switch key press should be consumed")
278 }
279 if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
280 t.Fatal("repeated held tab-switch key press should still be consumed")
281 }
282 if calls != 1 {
283 t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
284 }
285
286 h.handleKeyRelease(uint('x'))
287 if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
288 t.Fatal("tab-switch key press after release should be consumed")
289 }
290 if calls != 2 {
291 t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
292 }
293}
294
295func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
296 h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
297 const resizeKeycode = 27
298 mods := gdk.ControlMaskValue | gdk.AltMaskValue
299
300 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
301 t.Fatal("first resize-mode activation should be consumed")
302 }
303 if got := h.Mode(); got != ModeResize {
304 t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
305 }
306
307 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
308 t.Fatal("repeated held resize-mode activation should be consumed")
309 }
310 if got := h.Mode(); got != ModeResize {
311 t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
312 }
313
314 h.handleKeyRelease(uint('x'))
315 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
316 t.Fatal("resize-mode activation after unrelated release should still be consumed")
317 }
318 if got := h.Mode(); got != ModeResize {
319 t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
320 }
321
322 h.handleKeyRelease(uint('r'))
323 if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
324 t.Fatal("resize-mode activation after release should be consumed")
325 }
326 if got := h.Mode(); got != ModeNormal {
327 t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
328 }
329}
330
331func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
332 workspace := newTestWorkspace()
333 workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
334 "toggle_floating_pane": {Keys: []string{"alt+f"}},
335 }
336 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
337 calls := 0
338 h.SetOnAction(func(_ context.Context, action Action) error {
339 calls++
340 if action != ActionToggleFloatingPane {
341 t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
342 }
343 return nil
344 })
345
346 if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
347 t.Fatal("first floating pane toggle should be consumed")
348 }
349 h.handleKeyRelease(uint('x'))
350 if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
351 t.Fatal("held floating pane toggle repeat should still be consumed")
352 }
353 if calls != 1 {
354 t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
355 }
356
357 h.handleKeyRelease(uint('f'))
358 if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
359 t.Fatal("floating pane toggle after release should be consumed")
360 }
361 if calls != 2 {
362 t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
363 }
364}
365
366func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
367 if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
368 t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
369 }
370}
371
372func TestHandleKeyPress_EnterVimMode(t *testing.T) {
373 ctx := context.Background()
374 workspace := newTestWorkspace()
375 workspace.VimMode = entity.VimModeConfig{
376 ActivationShortcut: "ctrl+y",
377 }
378
379 h := NewKeyboardHandler(ctx, workspace, newTestSession())
380
381 h.SetOnAction(func(ctx context.Context, action Action) error {
382 return nil
383 })
384
385 // Ctrl+Y should enter vim mode
386 result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
387 if !result {
388 t.Fatal("Ctrl+Y should be consumed")
389 }
390 if h.Mode() != ModeVim {
391 t.Fatalf("mode = %v, want ModeVim", h.Mode())
392 }
393}
394
395func TestHandleKeyPress_VimModeStaysActiveAfterScroll(t *testing.T) {
396 ctx := context.Background()
397 workspace := newTestWorkspace()
398 workspace.VimMode = entity.VimModeConfig{
399 ActivationShortcut: "ctrl+y",
400 Actions: map[string]entity.ActionBinding{
401 "vim-scroll-down": {Keys: []string{"j"}},
402 "vim-scroll-up": {Keys: []string{"k"}},
403 "cancel": {Keys: []string{"escape"}},
404 },
405 }
406
407 h := NewKeyboardHandler(ctx, workspace, newTestSession())
408 actionCalls := 0
409 h.SetOnAction(func(ctx context.Context, action Action) error {
410 actionCalls++
411 return nil
412 })
413
414 // Enter vim mode
415 result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
416 if !result || h.Mode() != ModeVim {
417 t.Fatal("failed to enter vim mode")
418 }
419
420 var repeatTick glib.SourceFunc
421 repeatNow := time.Unix(2_000, 0)
422 h.vimScrollNow = func() time.Time { return repeatNow }
423 h.vimScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint {
424 repeatTick = *cb
425 return 1
426 }
427 h.vimScrollRepeatRemove = func(uint) bool { return true }
428
429 // Scroll actions stay in vim mode; the first press arms the smooth
430 // repeater and later physical auto-repeat events are ignored.
431 for i := 0; i < 2; i++ {
432 scrolled := h.handleKeyPress(uint('j'), 0, 0)
433 if !scrolled {
434 t.Fatalf("scroll down iteration %d: key not consumed", i)
435 }
436 if h.Mode() != ModeVim {
437 t.Fatalf("scroll down iteration %d: mode = %v, want ModeVim", i, h.Mode())
438 }
439 }
440 if repeatTick == nil {
441 t.Fatal("expected held vim scroll to register smooth repeater")
442 }
443 repeatNow = repeatNow.Add(vimScrollHoldDelay)
444 for i := 0; i < 3; i++ {
445 if !repeatTick(0) {
446 t.Fatalf("repeat tick %d stopped unexpectedly", i)
447 }
448 if h.Mode() != ModeVim {
449 t.Fatalf("repeat tick %d: mode = %v, want ModeVim", i, h.Mode())
450 }
451 }
452 h.handleKeyRelease(uint('j'))
453
454 // Scroll up also stays in vim mode
455 result = h.handleKeyPress(uint('k'), 0, 0)
456 if !result || h.Mode() != ModeVim {
457 t.Fatal("scroll up should stay in vim mode")
458 }
459
460 // Ensure scroll actions were dispatched
461 if actionCalls != 5 {
462 t.Fatalf("action calls = %d, want 5 (4 down + 1 up)", actionCalls)
463 }
464}
465
466func TestHandleKeyPress_VimModeTimeoutReset(t *testing.T) {
467 ctx := context.Background()
468 workspace := newTestWorkspace()
469 workspace.VimMode = entity.VimModeConfig{
470 ActivationShortcut: "ctrl+y",
471 TimeoutMilliseconds: 100,
472 Actions: map[string]entity.ActionBinding{
473 "vim-scroll-down": {Keys: []string{"j"}},
474 "cancel": {Keys: []string{"escape"}},
475 },
476 }
477
478 h := NewKeyboardHandler(ctx, workspace, newTestSession())
479 timerStarts := 0
480 h.modal.afterFunc = func(d time.Duration, fn func()) modalTimer {
481 timerStarts++
482 return &fakeModalTimer{}
483 }
484 h.SetOnAction(func(ctx context.Context, action Action) error {
485 return nil
486 })
487
488 // Enter vim mode with timeout.
489 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
490 if h.Mode() != ModeVim {
491 t.Fatal("failed to enter vim mode")
492 }
493 if timerStarts != 1 {
494 t.Fatalf("timer starts after entering vim mode = %d, want 1", timerStarts)
495 }
496
497 // Scroll action should reset the timeout by starting a replacement timer.
498 h.handleKeyPress(uint('j'), 0, 0)
499
500 if h.Mode() != ModeVim {
501 t.Fatalf("mode after scroll = %v, want ModeVim", h.Mode())
502 }
503 if timerStarts != 2 {
504 t.Fatalf("timer starts after scroll reset = %d, want 2", timerStarts)
505 }
506}
507
508func TestVimScrollRepeatTick_RefreshesConfiguredVimModeTimeout(t *testing.T) {
509 ctx := context.Background()
510 workspace := newTestWorkspace()
511 workspace.VimMode = entity.VimModeConfig{
512 ActivationShortcut: "ctrl+y",
513 TimeoutMilliseconds: 100,
514 Actions: map[string]entity.ActionBinding{
515 "vim-scroll-down": {Keys: []string{"j"}},
516 },
517 }
518
519 h := NewKeyboardHandler(ctx, workspace, newTestSession())
520 timerStarts := 0
521 h.modal.afterFunc = func(time.Duration, func()) modalTimer {
522 timerStarts++
523 return &fakeModalTimer{}
524 }
525 var continuous int
526 h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
527 if phase == VimScrollContinuous {
528 continuous++
529 }
530 return nil
531 })
532
533 var repeatTick glib.SourceFunc
534 repeatNow := time.Unix(3_000, 0)
535 h.vimScrollNow = func() time.Time { return repeatNow }
536 h.vimScrollRepeatAdd = func(_ uint, cb *glib.SourceFunc) uint {
537 repeatTick = *cb
538 return 1
539 }
540 h.vimScrollRepeatRemove = func(uint) bool { return true }
541
542 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
543 if h.Mode() != ModeVim {
544 t.Fatal("failed to enter vim mode")
545 }
546 enterStarts := timerStarts
547 h.handleKeyPress(uint('j'), 0, 0)
548 if repeatTick == nil {
549 t.Fatal("expected repeater registration")
550 }
551 afterTap := timerStarts
552 if afterTap <= enterStarts {
553 t.Fatalf("tap should refresh timeout: enter=%d afterTap=%d", enterStarts, afterTap)
554 }
555
556 repeatNow = repeatNow.Add(vimScrollHoldDelay)
557 if !repeatTick(0) {
558 t.Fatal("continuous tick stopped unexpectedly")
559 }
560 if continuous != 1 {
561 t.Fatalf("continuous dispatches=%d, want 1", continuous)
562 }
563 if timerStarts <= afterTap {
564 t.Fatalf("continuous tick must refresh vim-mode timeout: afterTap=%d afterTick=%d", afterTap, timerStarts)
565 }
566 if h.Mode() != ModeVim {
567 t.Fatalf("mode after continuous tick = %v, want ModeVim", h.Mode())
568 }
569}
570
571func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) {
572 ctx := context.Background()
573 workspace := newTestWorkspace()
574 workspace.VimMode = entity.VimModeConfig{
575 ActivationShortcut: "ctrl+y",
576 Actions: map[string]entity.ActionBinding{
577 "vim-scroll-down": {Keys: []string{"j"}},
578 "cancel": {Keys: []string{"escape"}},
579 },
580 }
581
582 h := NewKeyboardHandler(ctx, workspace, newTestSession())
583 var confirmCalls int
584 h.SetOnSequenceAction(func(action string, _ int) {
585 if action == "confirm" {
586 confirmCalls++
587 }
588 })
589
590 // Enter vim mode
591 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
592 if h.Mode() != ModeVim {
593 t.Fatal("failed to enter vim mode")
594 }
595
596 // Escape should exit vim mode
597 result := h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)
598 if !result {
599 t.Fatal("Escape should be consumed")
600 }
601 if h.Mode() != ModeNormal {
602 t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())
603 }
604 if confirmCalls != 0 {
605 t.Fatalf("confirm calls after Escape = %d, want 0", confirmCalls)
606 }
607}
608
609func TestHandleKeyPress_VimModeEnterConfirmsAndExits(t *testing.T) {
610 for _, tt := range []struct {
611 name string
612 keyval uint
613 }{
614 {name: "return", keyval: uint(gdk.KEY_Return)},
615 {name: "keypad enter", keyval: uint(gdk.KEY_KP_Enter)},
616 } {
617 t.Run(tt.name, func(t *testing.T) {
618 ctx := context.Background()
619 workspace := newTestWorkspace()
620 workspace.VimMode = entity.VimModeConfig{
621 ActivationShortcut: "ctrl+y",
622 Actions: map[string]entity.ActionBinding{
623 "confirm": {Keys: []string{"enter"}},
624 },
625 }
626
627 h := NewKeyboardHandler(ctx, workspace, newTestSession())
628 var confirmed bool
629 h.SetOnSequenceAction(func(action string, count int) {
630 confirmed = action == "confirm" && count == 0
631 })
632 enterVimMode(t, h)
633
634 if !h.handleKeyPress(tt.keyval, 0, 0) {
635 t.Fatal("Enter should be consumed")
636 }
637 if h.Mode() != ModeNormal {
638 t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
639 }
640 if !confirmed {
641 t.Fatal("Enter should dispatch Vim confirm before exiting")
642 }
643 })
644 }
645}
646
647func TestHandleKeyPress_VimModeConfiguredConfirmActivatesAndExits(t *testing.T) {
648 ctx := context.Background()
649 workspace := newTestWorkspace()
650 workspace.VimMode = entity.VimModeConfig{
651 ActivationShortcut: "ctrl+y",
652 Actions: map[string]entity.ActionBinding{
653 "confirm": {Keys: []string{"x"}},
654 },
655 }
656
657 h := NewKeyboardHandler(ctx, workspace, newTestSession())
658 var confirmed bool
659 h.SetOnSequenceAction(func(action string, count int) {
660 confirmed = action == "confirm" && count == 0
661 })
662 enterVimMode(t, h)
663
664 if !h.handleKeyPress(uint('x'), 0, 0) {
665 t.Fatal("configured confirm should be consumed")
666 }
667 if !confirmed {
668 t.Fatal("configured confirm should dispatch Vim confirm")
669 }
670 if h.Mode() != ModeNormal {
671 t.Fatalf("mode after configured confirm = %v, want ModeNormal", h.Mode())
672 }
673}
674
675func TestHandleKeyPress_VimModeActivationTogglesOff(t *testing.T) {
676 ctx := context.Background()
677 workspace := newTestWorkspace()
678 workspace.VimMode = entity.VimModeConfig{
679 ActivationShortcut: "ctrl+y",
680 }
681
682 h := NewKeyboardHandler(ctx, workspace, newTestSession())
683
684 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
685 t.Fatal("first Ctrl+Y should be consumed")
686 }
687 if h.Mode() != ModeVim {
688 t.Fatalf("mode after first Ctrl+Y = %v, want ModeVim", h.Mode())
689 }
690
691 h.handleKeyRelease(uint('y'))
692 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
693 t.Fatal("second Ctrl+Y should be consumed")
694 }
695 if h.Mode() != ModeNormal {
696 t.Fatalf("mode after second Ctrl+Y = %v, want ModeNormal", h.Mode())
697 }
698}
699
700func TestHandleKeyPress_VimModeEscapeExitsWithoutCancelBinding(t *testing.T) {
701 ctx := context.Background()
702 workspace := newTestWorkspace()
703 workspace.VimMode = entity.VimModeConfig{
704 ActivationShortcut: "ctrl+y",
705 Actions: map[string]entity.ActionBinding{
706 "vim-scroll-down": {Keys: []string{"j"}},
707 },
708 }
709
710 h := NewKeyboardHandler(ctx, workspace, newTestSession())
711 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
712 t.Fatal("Ctrl+Y should be consumed")
713 }
714 if h.Mode() != ModeVim {
715 t.Fatal("failed to enter vim mode")
716 }
717
718 if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
719 t.Fatal("Escape should still be consumed without explicit cancel binding")
720 }
721 if h.Mode() != ModeNormal {
722 t.Fatalf("mode after Escape fallback = %v, want ModeNormal", h.Mode())
723 }
724}
725
726func TestHandleKeyPress_VimModeActivationNotInPassThrough(t *testing.T) {
727 ctx := context.Background()
728 workspace := newTestWorkspace()
729 workspace.VimMode = entity.VimModeConfig{
730 ActivationShortcut: "ctrl+y",
731 }
732
733 h := NewKeyboardHandler(ctx, workspace, newTestSession())
734
735 // Route ALL keys to widget (editable pass-through context)
736 h.SetRouteKey(func(kc KeyContext) KeyRoute {
737 return RoutePassToWidget
738 })
739
740 // Ctrl+Y should NOT activate vim mode because route says pass to widget
741 result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
742 if result {
743 t.Fatal("Ctrl+Y should NOT be consumed when routed to widget")
744 }
745 if h.Mode() != ModeNormal {
746 t.Fatalf("mode = %v, want ModeNormal (should not enter vim mode)", h.Mode())
747 }
748}
749
750func TestHandleKeyPress_VimModeActivationPassesThroughWhenBlocked(t *testing.T) {
751 ctx := context.Background()
752 workspace := newTestWorkspace()
753 workspace.VimMode = entity.VimModeConfig{
754 ActivationShortcut: "ctrl+y",
755 }
756
757 h := NewKeyboardHandler(ctx, workspace, newTestSession())
758 h.SetVimModeActivationPassthrough(func() bool { return true })
759
760 result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
761 if result {
762 t.Fatal("Ctrl+Y should pass through when vim mode activation is blocked")
763 }
764 if h.Mode() != ModeNormal {
765 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
766 }
767}
768
769func TestDispatchAction_VimModeActivationPassesThroughWhenBlocked(t *testing.T) {
770 ctx := context.Background()
771 workspace := newTestWorkspace()
772 workspace.VimMode = entity.VimModeConfig{
773 ActivationShortcut: "ctrl+y",
774 }
775
776 h := NewKeyboardHandler(ctx, workspace, newTestSession())
777 h.SetVimModeActivationPassthrough(func() bool { return true })
778
779 consumed := h.DispatchAction(ActionEnterVimMode)
780 if consumed {
781 t.Fatal("DispatchAction should not consume blocked vim mode activation")
782 }
783 if h.Mode() != ModeNormal {
784 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
785 }
786}
787
788func TestHandleKeyPress_VimModeFastScrollLookup(t *testing.T) {
789 tests := []struct {
790 name string
791 configAction string
792 key uint
793 expected Action
794 }{
795 {
796 name: "scroll down fast",
797 configAction: "vim-scroll-down-fast",
798 key: uint('d'),
799 expected: ActionVimScrollDownFast,
800 },
801 {
802 name: "scroll up fast",
803 configAction: "vim-scroll-up-fast",
804 key: uint('u'),
805 expected: ActionVimScrollUpFast,
806 },
807 }
808
809 for _, tt := range tests {
810 t.Run(tt.name, func(t *testing.T) {
811 ctx := context.Background()
812 workspace := newTestWorkspace()
813 workspace.VimMode = entity.VimModeConfig{
814 ActivationShortcut: "ctrl+y",
815 Actions: map[string]entity.ActionBinding{
816 tt.configAction: {Keys: []string{"ctrl+" + string(rune(tt.key))}},
817 },
818 }
819
820 h := NewKeyboardHandler(ctx, workspace, newTestSession())
821 actionCalls := 0
822 var lastAction Action
823 h.SetOnAction(func(ctx context.Context, action Action) error {
824 actionCalls++
825 lastAction = action
826 return nil
827 })
828
829 // Enter vim mode.
830 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
831 if h.Mode() != ModeVim {
832 t.Fatal("failed to enter vim mode")
833 }
834
835 // Fast-scroll shortcut in vim mode should dispatch the expected action.
836 result := h.handleKeyPress(tt.key, 0, gdk.ControlMaskValue)
837 if !result {
838 t.Fatal("fast scroll shortcut in vim mode should be consumed")
839 }
840 if actionCalls != 1 || lastAction != tt.expected {
841 t.Fatalf("expected %s, got action=%s calls=%d", tt.expected, lastAction, actionCalls)
842 }
843 if h.Mode() != ModeVim {
844 t.Fatal("should stay in vim mode after scroll action")
845 }
846 })
847 }
848}
849
850func TestHandleKeyPress_VimModeNoAutoExitForScrollActions(t *testing.T) {
851 ctx := context.Background()
852 workspace := newTestWorkspace()
853 workspace.VimMode = entity.VimModeConfig{
854 ActivationShortcut: "ctrl+y",
855 Actions: map[string]entity.ActionBinding{
856 "vim-scroll-left": {Keys: []string{"h"}},
857 "vim-scroll-right": {Keys: []string{"l"}},
858 },
859 }
860
861 h := NewKeyboardHandler(ctx, workspace, newTestSession())
862 actionCalls := 0
863 h.SetOnAction(func(ctx context.Context, action Action) error {
864 actionCalls++
865 return nil
866 })
867
868 // Enter vim mode
869 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
870 if h.Mode() != ModeVim {
871 t.Fatal("failed to enter vim mode")
872 }
873
874 // Scroll left should not auto-exit
875 h.handleKeyPress(uint('h'), 0, 0)
876 if h.Mode() != ModeVim {
877 t.Fatal("scroll left should not exit vim mode")
878 }
879
880 // Scroll right should not auto-exit
881 h.handleKeyPress(uint('l'), 0, 0)
882 if h.Mode() != ModeVim {
883 t.Fatal("scroll right should not exit vim mode")
884 }
885
886 if actionCalls != 2 {
887 t.Fatalf("action calls = %d, want 2", actionCalls)
888 }
889}
890
891func TestHandleKeyPress_VimModeArrowKeysPassThroughNatively(t *testing.T) {
892 ctx := context.Background()
893 workspace := newTestWorkspace()
894 workspace.VimMode = entity.VimModeConfig{
895 ActivationShortcut: "ctrl+y",
896 Actions: map[string]entity.ActionBinding{
897 "vim-scroll-down": {Keys: []string{"j"}},
898 },
899 }
900
901 h := NewKeyboardHandler(ctx, workspace, newTestSession())
902 actionCalls := 0
903 h.SetOnAction(func(ctx context.Context, action Action) error {
904 actionCalls++
905 return nil
906 })
907
908 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
909 if h.Mode() != ModeVim {
910 t.Fatal("failed to enter vim mode")
911 }
912
913 for _, keyval := range []uint{uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down)} {
914 consumed := h.handleKeyPress(keyval, 0, 0)
915 if consumed {
916 t.Fatalf("arrow key %d should pass through to native page handling in vim mode", keyval)
917 }
918 if h.Mode() != ModeVim {
919 t.Fatalf("arrow key %d should keep vim mode active", keyval)
920 }
921 }
922
923 if actionCalls != 0 {
924 t.Fatalf("arrow key passthrough should not dispatch vim-mode action, got %d calls", actionCalls)
925 }
926}
927
928func TestHandleKeyPress_VimModeTabKeysPassThroughNatively(t *testing.T) {
929 ctx := context.Background()
930 workspace := newTestWorkspace()
931 workspace.VimMode = entity.VimModeConfig{ActivationShortcut: "ctrl+y"}
932
933 h := NewKeyboardHandler(ctx, workspace, newTestSession())
934 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
935 if h.Mode() != ModeVim {
936 t.Fatal("failed to enter vim mode")
937 }
938
939 for _, test := range []struct {
940 name string
941 keyval uint
942 modifiers gdk.ModifierType
943 }{
944 {name: "tab", keyval: uint(gdk.KEY_Tab)},
945 {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue},
946 {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue},
947 } {
948 t.Run(test.name, func(t *testing.T) {
949 consumed := h.handleKeyPress(test.keyval, 0, test.modifiers)
950 if consumed {
951 t.Fatal("tab key should pass through to native page focus navigation in vim mode")
952 }
953 if h.Mode() != ModeVim {
954 t.Fatal("tab key should keep vim mode active")
955 }
956 })
957 }
958}
959
960func TestHandleKeyPress_NormalModeTabKeysPassThroughNatively(t *testing.T) {
961 ctx := context.Background()
962 workspace := newTestWorkspace()
963
964 h := NewKeyboardHandler(ctx, workspace, newTestSession())
965 for _, test := range []struct {
966 name string
967 keyval uint
968 modifiers gdk.ModifierType
969 }{
970 {name: "tab", keyval: uint(gdk.KEY_Tab)},
971 {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue},
972 {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue},
973 } {
974 t.Run(test.name, func(t *testing.T) {
975 consumed := h.handleKeyPress(test.keyval, 0, test.modifiers)
976 if consumed {
977 t.Fatal("tab key should pass through to native page focus navigation in normal mode")
978 }
979 if h.Mode() != ModeNormal {
980 t.Fatal("tab key should keep normal mode active")
981 }
982 })
983 }
984}
985
986func TestHandleKeyPress_PageFocusNavigationHandlerConsumesReverseTab(t *testing.T) {
987 ctx := context.Background()
988 workspace := newTestWorkspace()
989 h := NewKeyboardHandler(ctx, workspace, newTestSession())
990 var gotBackward bool
991 h.SetOnPageFocusNavigation(func(_ context.Context, backward bool) bool {
992 gotBackward = backward
993 return true
994 })
995
996 if !h.handleKeyPress(uint(gdk.KEY_ISO_Left_Tab), 0, gdk.ShiftMaskValue) {
997 t.Fatal("handled page focus navigation should consume reverse tab")
998 }
999 if !gotBackward {
1000 t.Fatal("reverse tab should call page focus navigation backward")
1001 }
1002}
1003
1004func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) {
1005 ctx := context.Background()
1006 workspace := newTestWorkspace()
1007 workspace.VimMode = entity.VimModeConfig{
1008 ActivationShortcut: "ctrl+y",
1009 Actions: map[string]entity.ActionBinding{
1010 "vim-scroll-down": {Keys: []string{"j"}},
1011 },
1012 }
1013
1014 h := NewKeyboardHandler(ctx, workspace, newTestSession())
1015 actionCalls := 0
1016 var lastAction Action
1017 h.SetOnAction(func(ctx context.Context, action Action) error {
1018 actionCalls++
1019 lastAction = action
1020 return nil
1021 })
1022
1023 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
1024 t.Fatal("Ctrl+Y should be consumed")
1025 }
1026 if h.Mode() != ModeVim {
1027 t.Fatal("failed to enter vim mode")
1028 }
1029
1030 if !h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) {
1031 t.Fatal("Ctrl+L should be consumed inside vim mode")
1032 }
1033 if actionCalls != 0 {
1034 t.Fatalf("Ctrl+L should not dispatch a global action in vim mode, got %d calls (%s)", actionCalls, lastAction)
1035 }
1036 if h.Mode() != ModeVim {
1037 t.Fatalf("mode after blocked Ctrl+L = %v, want ModeVim", h.Mode())
1038 }
1039}
1040
1041func TestVimScrollRepeat_FirstKeyDownDispatchesTapAndArmsHold(t *testing.T) {
1042 h, now, tick := newVimScrollRepeatTestHandler(t)
1043 actionCalls := 0
1044 h.SetOnAction(func(_ context.Context, action Action) error {
1045 actionCalls++
1046 if action != ActionVimScrollDown {
1047 t.Fatalf("action = %s, want %s", action, ActionVimScrollDown)
1048 }
1049 return nil
1050 })
1051
1052 if !h.handleKeyPress(uint('j'), 0, 0) {
1053 t.Fatal("first J press should be consumed")
1054 }
1055 if actionCalls != 1 {
1056 t.Fatalf("tap dispatches = %d, want 1", actionCalls)
1057 }
1058 if *tick == nil {
1059 t.Fatal("first keydown must arm the hold source without an OS repeat")
1060 }
1061
1062 *now = now.Add(vimScrollHoldDelay - time.Millisecond)
1063 if !(*tick)(0) {
1064 t.Fatal("hold source should remain armed before the delay")
1065 }
1066 if actionCalls != 1 {
1067 t.Fatalf("dispatches before hold delay = %d, want 1 tap", actionCalls)
1068 }
1069}
1070
1071func TestVimScrollRepeat_HoldStartsWithoutOSRepeat(t *testing.T) {
1072 h, now, tick := newVimScrollRepeatTestHandler(t)
1073 continuous := 0
1074 h.SetOnAction(func(context.Context, Action) error { return nil })
1075 h.SetOnVimScrollLifecycle(func(_ context.Context, action Action, phase VimScrollPhase) error {
1076 if action != ActionVimScrollDown || phase != VimScrollContinuous {
1077 t.Fatalf("lifecycle = (%s, %v), want (%s, continuous)", action, phase, ActionVimScrollDown)
1078 }
1079 continuous++
1080 return nil
1081 })
1082
1083 h.handleKeyPress(uint('j'), 0, 0)
1084 *now = now.Add(vimScrollHoldDelay)
1085 if !(*tick)(0) {
1086 t.Fatal("hold source stopped at the delay")
1087 }
1088 if continuous != 1 {
1089 t.Fatalf("continuous dispatches = %d, want 1 without a second keydown", continuous)
1090 }
1091}
1092
1093func TestVimScrollRepeat_FixedCadenceIgnoresDisplayAndOSRepeatRates(t *testing.T) {
1094 const simulatedHold = time.Second
1095 simulate := func(t *testing.T, externalHz int) int {
1096 t.Helper()
1097 h, now, tick := newVimScrollRepeatTestHandler(t)
1098 tapDistance := 0
1099 continuousDistance := 0
1100 h.SetOnAction(func(context.Context, Action) error {
1101 tapDistance += 80
1102 return nil
1103 })
1104 h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
1105 if phase == VimScrollContinuous {
1106 continuousDistance += 80
1107 }
1108 return nil
1109 })
1110 h.handleKeyPress(uint('j'), 0, 0)
1111
1112 start := *now
1113 nextExternal := time.Second / time.Duration(externalHz)
1114 for elapsed := vimScrollCadence; elapsed <= simulatedHold; elapsed += vimScrollCadence {
1115 *now = start.Add(elapsed)
1116 for elapsed >= nextExternal {
1117 h.handleKeyPress(uint('j'), 0, 0) // ignored OS auto-repeat
1118 nextExternal += time.Second / time.Duration(externalHz)
1119 }
1120 if !(*tick)(0) {
1121 t.Fatalf("source stopped at %s", elapsed)
1122 }
1123 }
1124 return tapDistance + continuousDistance
1125 }
1126
1127 distance60 := simulate(t, 60)
1128 distance144 := simulate(t, 144)
1129 t.Logf("simulated one-second distances: 60 Hz=%d px, 144 Hz=%d px; tolerance=80 px", distance60, distance144)
1130 // A fixed 16 ms source makes the results exact in this deterministic model;
1131 // the documented runtime tolerance is one 80 px cadence step at timer edges.
1132 if diff := distance60 - distance144; diff < -80 || diff > 80 {
1133 t.Fatalf("60 Hz distance=%d, 144 Hz distance=%d; tolerance=80 px", distance60, distance144)
1134 }
1135}
1136
1137func TestVimScrollRepeat_ReleaseAndEscapeStopImmediately(t *testing.T) {
1138 for _, tc := range []struct {
1139 name string
1140 stop func(*KeyboardHandler)
1141 }{
1142 {name: "release", stop: func(h *KeyboardHandler) { h.handleKeyRelease(uint('j')) }},
1143 {name: "escape", stop: func(h *KeyboardHandler) { h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) }},
1144 } {
1145 t.Run(tc.name, func(t *testing.T) {
1146 h, now, tick := newVimScrollRepeatTestHandler(t)
1147 removed := 0
1148 stops := 0
1149 h.vimScrollRepeatRemove = func(uint) bool { removed++; return true }
1150 h.SetOnAction(func(context.Context, Action) error { return nil })
1151 h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
1152 if phase == VimScrollStop {
1153 stops++
1154 }
1155 return nil
1156 })
1157 h.handleKeyPress(uint('j'), 0, 0)
1158 *now = now.Add(vimScrollHoldDelay)
1159 (*tick)(0)
1160
1161 tc.stop(h)
1162 if removed != 1 || stops != 1 {
1163 t.Fatalf("removed=%d stops=%d, want 1/1", removed, stops)
1164 }
1165 if (*tick)(0) {
1166 t.Fatal("retained callback must stop immediately")
1167 }
1168 })
1169 }
1170}
1171
1172func newVimScrollRepeatTestHandler(t *testing.T) (*KeyboardHandler, *time.Time, *glib.SourceFunc) {
1173 t.Helper()
1174 workspace := newTestWorkspace()
1175 workspace.VimMode = entity.VimModeConfig{
1176 Actions: map[string]entity.ActionBinding{"vim-scroll-down": {Keys: []string{"j"}}},
1177 }
1178 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
1179 h.EnterVimMode()
1180 now := time.Unix(1_000, 0)
1181 var tick glib.SourceFunc
1182 h.vimScrollNow = func() time.Time { return now }
1183 h.vimScrollRepeatAdd = func(intervalMS uint, cb *glib.SourceFunc) uint {
1184 if intervalMS != uint(vimScrollCadence/time.Millisecond) {
1185 t.Fatalf("cadence = %dms, want %dms", intervalMS, vimScrollCadence/time.Millisecond)
1186 }
1187 tick = *cb
1188 return 99
1189 }
1190 h.vimScrollRepeatRemove = func(uint) bool { return true }
1191 return h, &now, &tick
1192}