v0.31.0 bf0756b

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	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}

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