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 h.SetOnAction(func(ctx context.Context, action Action) error {
584 return nil
585 })
586
587 // Enter vim mode
588 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
589 if h.Mode() != ModeVim {
590 t.Fatal("failed to enter vim mode")
591 }
592
593 // Escape should exit vim mode
594 result := h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0)
595 if !result {
596 t.Fatal("Escape should be consumed")
597 }
598 if h.Mode() != ModeNormal {
599 t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())
600 }
601}
602
603func TestHandleKeyPress_VimModeEnterExits(t *testing.T) {
604 ctx := context.Background()
605 workspace := newTestWorkspace()
606 workspace.VimMode = entity.VimModeConfig{
607 ActivationShortcut: "ctrl+y",
608 Actions: map[string]entity.ActionBinding{
609 "confirm": {Keys: []string{"enter"}},
610 },
611 }
612
613 h := NewKeyboardHandler(ctx, workspace, newTestSession())
614 h.SetOnAction(func(ctx context.Context, action Action) error {
615 return nil
616 })
617
618 // Enter vim mode
619 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
620 if h.Mode() != ModeVim {
621 t.Fatal("failed to enter vim mode")
622 }
623
624 // Enter should exit vim mode
625 result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)
626 if !result {
627 t.Fatal("Enter should be consumed")
628 }
629 if h.Mode() != ModeNormal {
630 t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
631 }
632}
633
634func TestHandleKeyPress_VimModeActivationTogglesOff(t *testing.T) {
635 ctx := context.Background()
636 workspace := newTestWorkspace()
637 workspace.VimMode = entity.VimModeConfig{
638 ActivationShortcut: "ctrl+y",
639 }
640
641 h := NewKeyboardHandler(ctx, workspace, newTestSession())
642
643 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
644 t.Fatal("first Ctrl+Y should be consumed")
645 }
646 if h.Mode() != ModeVim {
647 t.Fatalf("mode after first Ctrl+Y = %v, want ModeVim", h.Mode())
648 }
649
650 h.handleKeyRelease(uint('y'))
651 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
652 t.Fatal("second Ctrl+Y should be consumed")
653 }
654 if h.Mode() != ModeNormal {
655 t.Fatalf("mode after second Ctrl+Y = %v, want ModeNormal", h.Mode())
656 }
657}
658
659func TestHandleKeyPress_VimModeEscapeExitsWithoutCancelBinding(t *testing.T) {
660 ctx := context.Background()
661 workspace := newTestWorkspace()
662 workspace.VimMode = entity.VimModeConfig{
663 ActivationShortcut: "ctrl+y",
664 Actions: map[string]entity.ActionBinding{
665 "vim-scroll-down": {Keys: []string{"j"}},
666 },
667 }
668
669 h := NewKeyboardHandler(ctx, workspace, newTestSession())
670 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
671 t.Fatal("Ctrl+Y should be consumed")
672 }
673 if h.Mode() != ModeVim {
674 t.Fatal("failed to enter vim mode")
675 }
676
677 if !h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) {
678 t.Fatal("Escape should still be consumed without explicit cancel binding")
679 }
680 if h.Mode() != ModeNormal {
681 t.Fatalf("mode after Escape fallback = %v, want ModeNormal", h.Mode())
682 }
683}
684
685func TestHandleKeyPress_VimModeActivationNotInPassThrough(t *testing.T) {
686 ctx := context.Background()
687 workspace := newTestWorkspace()
688 workspace.VimMode = entity.VimModeConfig{
689 ActivationShortcut: "ctrl+y",
690 }
691
692 h := NewKeyboardHandler(ctx, workspace, newTestSession())
693
694 // Route ALL keys to widget (editable pass-through context)
695 h.SetRouteKey(func(kc KeyContext) KeyRoute {
696 return RoutePassToWidget
697 })
698
699 // Ctrl+Y should NOT activate vim mode because route says pass to widget
700 result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
701 if result {
702 t.Fatal("Ctrl+Y should NOT be consumed when routed to widget")
703 }
704 if h.Mode() != ModeNormal {
705 t.Fatalf("mode = %v, want ModeNormal (should not enter vim mode)", h.Mode())
706 }
707}
708
709func TestHandleKeyPress_VimModeActivationPassesThroughWhenBlocked(t *testing.T) {
710 ctx := context.Background()
711 workspace := newTestWorkspace()
712 workspace.VimMode = entity.VimModeConfig{
713 ActivationShortcut: "ctrl+y",
714 }
715
716 h := NewKeyboardHandler(ctx, workspace, newTestSession())
717 h.SetVimModeActivationPassthrough(func() bool { return true })
718
719 result := h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
720 if result {
721 t.Fatal("Ctrl+Y should pass through when vim mode activation is blocked")
722 }
723 if h.Mode() != ModeNormal {
724 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
725 }
726}
727
728func TestDispatchAction_VimModeActivationPassesThroughWhenBlocked(t *testing.T) {
729 ctx := context.Background()
730 workspace := newTestWorkspace()
731 workspace.VimMode = entity.VimModeConfig{
732 ActivationShortcut: "ctrl+y",
733 }
734
735 h := NewKeyboardHandler(ctx, workspace, newTestSession())
736 h.SetVimModeActivationPassthrough(func() bool { return true })
737
738 consumed := h.DispatchAction(ActionEnterVimMode)
739 if consumed {
740 t.Fatal("DispatchAction should not consume blocked vim mode activation")
741 }
742 if h.Mode() != ModeNormal {
743 t.Fatalf("mode = %v, want ModeNormal", h.Mode())
744 }
745}
746
747func TestHandleKeyPress_VimModeFastScrollLookup(t *testing.T) {
748 tests := []struct {
749 name string
750 configAction string
751 key uint
752 expected Action
753 }{
754 {
755 name: "scroll down fast",
756 configAction: "vim-scroll-down-fast",
757 key: uint('d'),
758 expected: ActionVimScrollDownFast,
759 },
760 {
761 name: "scroll up fast",
762 configAction: "vim-scroll-up-fast",
763 key: uint('u'),
764 expected: ActionVimScrollUpFast,
765 },
766 }
767
768 for _, tt := range tests {
769 t.Run(tt.name, func(t *testing.T) {
770 ctx := context.Background()
771 workspace := newTestWorkspace()
772 workspace.VimMode = entity.VimModeConfig{
773 ActivationShortcut: "ctrl+y",
774 Actions: map[string]entity.ActionBinding{
775 tt.configAction: {Keys: []string{"ctrl+" + string(rune(tt.key))}},
776 },
777 }
778
779 h := NewKeyboardHandler(ctx, workspace, newTestSession())
780 actionCalls := 0
781 var lastAction Action
782 h.SetOnAction(func(ctx context.Context, action Action) error {
783 actionCalls++
784 lastAction = action
785 return nil
786 })
787
788 // Enter vim mode.
789 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
790 if h.Mode() != ModeVim {
791 t.Fatal("failed to enter vim mode")
792 }
793
794 // Fast-scroll shortcut in vim mode should dispatch the expected action.
795 result := h.handleKeyPress(tt.key, 0, gdk.ControlMaskValue)
796 if !result {
797 t.Fatal("fast scroll shortcut in vim mode should be consumed")
798 }
799 if actionCalls != 1 || lastAction != tt.expected {
800 t.Fatalf("expected %s, got action=%s calls=%d", tt.expected, lastAction, actionCalls)
801 }
802 if h.Mode() != ModeVim {
803 t.Fatal("should stay in vim mode after scroll action")
804 }
805 })
806 }
807}
808
809func TestHandleKeyPress_VimModeNoAutoExitForScrollActions(t *testing.T) {
810 ctx := context.Background()
811 workspace := newTestWorkspace()
812 workspace.VimMode = entity.VimModeConfig{
813 ActivationShortcut: "ctrl+y",
814 Actions: map[string]entity.ActionBinding{
815 "vim-scroll-left": {Keys: []string{"h"}},
816 "vim-scroll-right": {Keys: []string{"l"}},
817 },
818 }
819
820 h := NewKeyboardHandler(ctx, workspace, newTestSession())
821 actionCalls := 0
822 h.SetOnAction(func(ctx context.Context, action Action) error {
823 actionCalls++
824 return nil
825 })
826
827 // Enter vim mode
828 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
829 if h.Mode() != ModeVim {
830 t.Fatal("failed to enter vim mode")
831 }
832
833 // Scroll left should not auto-exit
834 h.handleKeyPress(uint('h'), 0, 0)
835 if h.Mode() != ModeVim {
836 t.Fatal("scroll left should not exit vim mode")
837 }
838
839 // Scroll right should not auto-exit
840 h.handleKeyPress(uint('l'), 0, 0)
841 if h.Mode() != ModeVim {
842 t.Fatal("scroll right should not exit vim mode")
843 }
844
845 if actionCalls != 2 {
846 t.Fatalf("action calls = %d, want 2", actionCalls)
847 }
848}
849
850func TestHandleKeyPress_VimModeArrowKeysPassThroughNatively(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-down": {Keys: []string{"j"}},
857 },
858 }
859
860 h := NewKeyboardHandler(ctx, workspace, newTestSession())
861 actionCalls := 0
862 h.SetOnAction(func(ctx context.Context, action Action) error {
863 actionCalls++
864 return nil
865 })
866
867 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
868 if h.Mode() != ModeVim {
869 t.Fatal("failed to enter vim mode")
870 }
871
872 for _, keyval := range []uint{uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down)} {
873 consumed := h.handleKeyPress(keyval, 0, 0)
874 if consumed {
875 t.Fatalf("arrow key %d should pass through to native page handling in vim mode", keyval)
876 }
877 if h.Mode() != ModeVim {
878 t.Fatalf("arrow key %d should keep vim mode active", keyval)
879 }
880 }
881
882 if actionCalls != 0 {
883 t.Fatalf("arrow key passthrough should not dispatch vim-mode action, got %d calls", actionCalls)
884 }
885}
886
887func TestHandleKeyPress_VimModeTabKeysPassThroughNatively(t *testing.T) {
888 ctx := context.Background()
889 workspace := newTestWorkspace()
890 workspace.VimMode = entity.VimModeConfig{ActivationShortcut: "ctrl+y"}
891
892 h := NewKeyboardHandler(ctx, workspace, newTestSession())
893 h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
894 if h.Mode() != ModeVim {
895 t.Fatal("failed to enter vim mode")
896 }
897
898 for _, test := range []struct {
899 name string
900 keyval uint
901 modifiers gdk.ModifierType
902 }{
903 {name: "tab", keyval: uint(gdk.KEY_Tab)},
904 {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue},
905 {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue},
906 } {
907 t.Run(test.name, func(t *testing.T) {
908 consumed := h.handleKeyPress(test.keyval, 0, test.modifiers)
909 if consumed {
910 t.Fatal("tab key should pass through to native page focus navigation in vim mode")
911 }
912 if h.Mode() != ModeVim {
913 t.Fatal("tab key should keep vim mode active")
914 }
915 })
916 }
917}
918
919func TestHandleKeyPress_NormalModeTabKeysPassThroughNatively(t *testing.T) {
920 ctx := context.Background()
921 workspace := newTestWorkspace()
922
923 h := NewKeyboardHandler(ctx, workspace, newTestSession())
924 for _, test := range []struct {
925 name string
926 keyval uint
927 modifiers gdk.ModifierType
928 }{
929 {name: "tab", keyval: uint(gdk.KEY_Tab)},
930 {name: "shift tab", keyval: uint(gdk.KEY_Tab), modifiers: gdk.ShiftMaskValue},
931 {name: "iso left tab", keyval: uint(gdk.KEY_ISO_Left_Tab), modifiers: gdk.ShiftMaskValue},
932 } {
933 t.Run(test.name, func(t *testing.T) {
934 consumed := h.handleKeyPress(test.keyval, 0, test.modifiers)
935 if consumed {
936 t.Fatal("tab key should pass through to native page focus navigation in normal mode")
937 }
938 if h.Mode() != ModeNormal {
939 t.Fatal("tab key should keep normal mode active")
940 }
941 })
942 }
943}
944
945func TestHandleKeyPress_PageFocusNavigationHandlerConsumesReverseTab(t *testing.T) {
946 ctx := context.Background()
947 workspace := newTestWorkspace()
948 h := NewKeyboardHandler(ctx, workspace, newTestSession())
949 var gotBackward bool
950 h.SetOnPageFocusNavigation(func(_ context.Context, backward bool) bool {
951 gotBackward = backward
952 return true
953 })
954
955 if !h.handleKeyPress(uint(gdk.KEY_ISO_Left_Tab), 0, gdk.ShiftMaskValue) {
956 t.Fatal("handled page focus navigation should consume reverse tab")
957 }
958 if !gotBackward {
959 t.Fatal("reverse tab should call page focus navigation backward")
960 }
961}
962
963func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) {
964 ctx := context.Background()
965 workspace := newTestWorkspace()
966 workspace.VimMode = entity.VimModeConfig{
967 ActivationShortcut: "ctrl+y",
968 Actions: map[string]entity.ActionBinding{
969 "vim-scroll-down": {Keys: []string{"j"}},
970 },
971 }
972
973 h := NewKeyboardHandler(ctx, workspace, newTestSession())
974 actionCalls := 0
975 var lastAction Action
976 h.SetOnAction(func(ctx context.Context, action Action) error {
977 actionCalls++
978 lastAction = action
979 return nil
980 })
981
982 if !h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue) {
983 t.Fatal("Ctrl+Y should be consumed")
984 }
985 if h.Mode() != ModeVim {
986 t.Fatal("failed to enter vim mode")
987 }
988
989 if !h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) {
990 t.Fatal("Ctrl+L should be consumed inside vim mode")
991 }
992 if actionCalls != 0 {
993 t.Fatalf("Ctrl+L should not dispatch a global action in vim mode, got %d calls (%s)", actionCalls, lastAction)
994 }
995 if h.Mode() != ModeVim {
996 t.Fatalf("mode after blocked Ctrl+L = %v, want ModeVim", h.Mode())
997 }
998}
999
1000func TestVimScrollRepeat_FirstKeyDownDispatchesTapAndArmsHold(t *testing.T) {
1001 h, now, tick := newVimScrollRepeatTestHandler(t)
1002 actionCalls := 0
1003 h.SetOnAction(func(_ context.Context, action Action) error {
1004 actionCalls++
1005 if action != ActionVimScrollDown {
1006 t.Fatalf("action = %s, want %s", action, ActionVimScrollDown)
1007 }
1008 return nil
1009 })
1010
1011 if !h.handleKeyPress(uint('j'), 0, 0) {
1012 t.Fatal("first J press should be consumed")
1013 }
1014 if actionCalls != 1 {
1015 t.Fatalf("tap dispatches = %d, want 1", actionCalls)
1016 }
1017 if *tick == nil {
1018 t.Fatal("first keydown must arm the hold source without an OS repeat")
1019 }
1020
1021 *now = now.Add(vimScrollHoldDelay - time.Millisecond)
1022 if !(*tick)(0) {
1023 t.Fatal("hold source should remain armed before the delay")
1024 }
1025 if actionCalls != 1 {
1026 t.Fatalf("dispatches before hold delay = %d, want 1 tap", actionCalls)
1027 }
1028}
1029
1030func TestVimScrollRepeat_HoldStartsWithoutOSRepeat(t *testing.T) {
1031 h, now, tick := newVimScrollRepeatTestHandler(t)
1032 continuous := 0
1033 h.SetOnAction(func(context.Context, Action) error { return nil })
1034 h.SetOnVimScrollLifecycle(func(_ context.Context, action Action, phase VimScrollPhase) error {
1035 if action != ActionVimScrollDown || phase != VimScrollContinuous {
1036 t.Fatalf("lifecycle = (%s, %v), want (%s, continuous)", action, phase, ActionVimScrollDown)
1037 }
1038 continuous++
1039 return nil
1040 })
1041
1042 h.handleKeyPress(uint('j'), 0, 0)
1043 *now = now.Add(vimScrollHoldDelay)
1044 if !(*tick)(0) {
1045 t.Fatal("hold source stopped at the delay")
1046 }
1047 if continuous != 1 {
1048 t.Fatalf("continuous dispatches = %d, want 1 without a second keydown", continuous)
1049 }
1050}
1051
1052func TestVimScrollRepeat_FixedCadenceIgnoresDisplayAndOSRepeatRates(t *testing.T) {
1053 const simulatedHold = time.Second
1054 simulate := func(t *testing.T, externalHz int) int {
1055 t.Helper()
1056 h, now, tick := newVimScrollRepeatTestHandler(t)
1057 tapDistance := 0
1058 continuousDistance := 0
1059 h.SetOnAction(func(context.Context, Action) error {
1060 tapDistance += 80
1061 return nil
1062 })
1063 h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
1064 if phase == VimScrollContinuous {
1065 continuousDistance += 80
1066 }
1067 return nil
1068 })
1069 h.handleKeyPress(uint('j'), 0, 0)
1070
1071 start := *now
1072 nextExternal := time.Second / time.Duration(externalHz)
1073 for elapsed := vimScrollCadence; elapsed <= simulatedHold; elapsed += vimScrollCadence {
1074 *now = start.Add(elapsed)
1075 for elapsed >= nextExternal {
1076 h.handleKeyPress(uint('j'), 0, 0) // ignored OS auto-repeat
1077 nextExternal += time.Second / time.Duration(externalHz)
1078 }
1079 if !(*tick)(0) {
1080 t.Fatalf("source stopped at %s", elapsed)
1081 }
1082 }
1083 return tapDistance + continuousDistance
1084 }
1085
1086 distance60 := simulate(t, 60)
1087 distance144 := simulate(t, 144)
1088 t.Logf("simulated one-second distances: 60 Hz=%d px, 144 Hz=%d px; tolerance=80 px", distance60, distance144)
1089 // A fixed 16 ms source makes the results exact in this deterministic model;
1090 // the documented runtime tolerance is one 80 px cadence step at timer edges.
1091 if diff := distance60 - distance144; diff < -80 || diff > 80 {
1092 t.Fatalf("60 Hz distance=%d, 144 Hz distance=%d; tolerance=80 px", distance60, distance144)
1093 }
1094}
1095
1096func TestVimScrollRepeat_ReleaseAndEscapeStopImmediately(t *testing.T) {
1097 for _, tc := range []struct {
1098 name string
1099 stop func(*KeyboardHandler)
1100 }{
1101 {name: "release", stop: func(h *KeyboardHandler) { h.handleKeyRelease(uint('j')) }},
1102 {name: "escape", stop: func(h *KeyboardHandler) { h.handleKeyPress(uint(gdk.KEY_Escape), 0, 0) }},
1103 } {
1104 t.Run(tc.name, func(t *testing.T) {
1105 h, now, tick := newVimScrollRepeatTestHandler(t)
1106 removed := 0
1107 stops := 0
1108 h.vimScrollRepeatRemove = func(uint) bool { removed++; return true }
1109 h.SetOnAction(func(context.Context, Action) error { return nil })
1110 h.SetOnVimScrollLifecycle(func(_ context.Context, _ Action, phase VimScrollPhase) error {
1111 if phase == VimScrollStop {
1112 stops++
1113 }
1114 return nil
1115 })
1116 h.handleKeyPress(uint('j'), 0, 0)
1117 *now = now.Add(vimScrollHoldDelay)
1118 (*tick)(0)
1119
1120 tc.stop(h)
1121 if removed != 1 || stops != 1 {
1122 t.Fatalf("removed=%d stops=%d, want 1/1", removed, stops)
1123 }
1124 if (*tick)(0) {
1125 t.Fatal("retained callback must stop immediately")
1126 }
1127 })
1128 }
1129}
1130
1131func newVimScrollRepeatTestHandler(t *testing.T) (*KeyboardHandler, *time.Time, *glib.SourceFunc) {
1132 t.Helper()
1133 workspace := newTestWorkspace()
1134 workspace.VimMode = entity.VimModeConfig{
1135 Actions: map[string]entity.ActionBinding{"vim-scroll-down": {Keys: []string{"j"}}},
1136 }
1137 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
1138 h.EnterVimMode()
1139 now := time.Unix(1_000, 0)
1140 var tick glib.SourceFunc
1141 h.vimScrollNow = func() time.Time { return now }
1142 h.vimScrollRepeatAdd = func(intervalMS uint, cb *glib.SourceFunc) uint {
1143 if intervalMS != uint(vimScrollCadence/time.Millisecond) {
1144 t.Fatalf("cadence = %dms, want %dms", intervalMS, vimScrollCadence/time.Millisecond)
1145 }
1146 tick = *cb
1147 return 99
1148 }
1149 h.vimScrollRepeatRemove = func(uint) bool { return true }
1150 return h, &now, &tick
1151}