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