v0.32.0 0617278

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

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