main 5041353

keyboard_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close