v0.29.0 21b3150

keyboard_test.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"testing"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/bnema/puregotk/v4/gdk"
9	"github.com/stretchr/testify/assert"
10)
11
12// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
13// Used by tests that construct a KeyboardHandler.
14func newTestWorkspace() *entity.WorkspaceConfig {
15	return &entity.WorkspaceConfig{
16		TabMode:    entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
17		PaneMode:   entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
18		ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
19	}
20}
21
22// newTestSession creates a minimal SessionConfig with activation shortcuts set.
23func newTestSession() *entity.SessionConfig {
24	return &entity.SessionConfig{
25		SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
26	}
27}
28
29// stubAccentHandler is a test stub for the AccentHandler interface.
30type stubAccentHandler struct {
31	onKeyPressedResult bool
32}
33
34func (s *stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool {
35	return s.onKeyPressedResult
36}
37func (s *stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {}
38func (s *stubAccentHandler) IsPickerVisible() bool                   { return false }
39func (s *stubAccentHandler) Cancel(_ context.Context)                {}
40
41func TestIsShortcutModified(t *testing.T) {
42	tests := []struct {
43		name     string
44		mods     Modifier
45		expected bool
46	}{
47		{"no modifier", ModNone, false},
48		{"shift only", ModShift, false},
49		{"ctrl", ModCtrl, true},
50		{"alt", ModAlt, true},
51		{"ctrl+shift", ModCtrl | ModShift, true},
52		{"alt+shift", ModAlt | ModShift, true},
53		{"ctrl+alt", ModCtrl | ModAlt, true},
54	}
55
56	for _, tt := range tests {
57		t.Run(tt.name, func(t *testing.T) {
58			assert.Equal(t, tt.expected, IsShortcutModified(tt.mods))
59		})
60	}
61}
62
63func TestIsTextInputKey(t *testing.T) {
64	tests := []struct {
65		name     string
66		keyval   uint
67		expected bool
68	}{
69		// Printable characters
70		{"lowercase a", uint('a'), true},
71		{"lowercase z", uint('z'), true},
72		{"uppercase A", uint('A'), true},
73		{"digit 1", uint('1'), true},
74		{"space", 0x020, true},
75		{"period", uint('.'), true},
76		{"slash", uint('/'), true},
77		{"apostrophe", 0x027, true},
78		{"grave accent", 0x060, true},
79		{"tilde", 0x07e, true},
80		{"quotedbl", 0x022, true},
81		{"caret", 0x05e, true},
82
83		// Dead keys (used by US International layout)
84		{"dead acute", 0xfe51, true},
85		{"dead grave", 0xfe50, true},
86		{"dead circumflex", 0xfe52, true},
87		{"dead tilde", 0xfe53, true},
88		{"dead diaeresis", 0xfe57, true},
89
90		// Dead key upper-bound boundary cases
91		{"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true},
92		{"0xfe8d - one past highest dead key", 0xfe8d, false},
93
94		// Non-text keys (should NOT be treated as text)
95		{"escape", 0xff1b, false},
96		{"return", 0xff0d, false},
97		{"tab", 0xff09, false},
98		{"F1", 0xffbe, false},
99		{"F12", 0xffc9, false},
100		{"left arrow", 0xff51, false},
101		{"right arrow", 0xff53, false},
102		{"backspace", 0xff08, false},
103		{"delete", 0xffff, false},
104		{"home", 0xff50, false},
105		{"end", 0xff57, false},
106	}
107
108	for _, tt := range tests {
109		t.Run(tt.name, func(t *testing.T) {
110			assert.Equal(t, tt.expected, IsTextInputKey(tt.keyval), "keyval 0x%x", tt.keyval)
111		})
112	}
113}
114
115func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
116	ctx := context.Background()
117
118	h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
119
120	// Route all non-shortcut keys to widget (simulates WebView text input)
121	h.SetRouteKey(func(kc KeyContext) KeyRoute {
122		return RoutePassToWidget
123	})
124
125	tests := []struct {
126		name   string
127		keyval uint
128		state  gdk.ModifierType
129	}{
130		{"plain 'e'", uint('e'), 0},
131		{"plain 'a'", uint('a'), 0},
132		{"shift 'E'", uint('E'), gdk.ShiftMaskValue},
133		{"space", 0x020, 0},
134		{"digit 1", uint('1'), 0},
135	}
136
137	for _, tt := range tests {
138		t.Run(tt.name, func(t *testing.T) {
139			result := h.handleKeyPress(tt.keyval, 0, tt.state)
140			assert.False(t, result, "RoutePassToWidget should let key propagate (return false)")
141		})
142	}
143}
144
145func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
146	ctx := context.Background()
147
148	h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
149
150	// Set a stub accent handler that always returns false (simulates "no accents for this key")
151	h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
152
153	// Route to accent detection
154	h.SetRouteKey(func(kc KeyContext) KeyRoute {
155		return RouteAccentDetection
156	})
157
158	// 'x' has no accents -> accent handler returns false -> key falls through
159	result := h.handleKeyPress(uint('x'), 0, 0)
160	assert.False(t, result, "'x' has no accents, should pass through")
161}
162
163func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
164	ctx := context.Background()
165
166	h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
167
168	h.SetOnAction(func(ctx context.Context, action Action) error {
169		return nil
170	})
171
172	// Route text keys to widget, shortcuts to handler
173	h.SetRouteKey(func(kc KeyContext) KeyRoute {
174		if IsShortcutModified(kc.Modifiers) {
175			return RouteHandleShortcuts
176		}
177		return RoutePassToWidget
178	})
179
180	// Ctrl+L = open omnibox (registered global shortcut)
181	result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue)
182	assert.True(t, result, "Ctrl+L should be consumed by shortcut handler")
183}
184
185func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
186	ctx := context.Background()
187
188	h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
189	h.EnterTabMode()
190
191	// Even if routeKey says pass to widget, modal mode ignores it
192	h.SetRouteKey(func(kc KeyContext) KeyRoute {
193		return RoutePassToWidget
194	})
195
196	// In modal mode, unknown keys are consumed (return true)
197	result := h.handleKeyPress(uint('z'), 0, 0)
198	assert.True(t, result, "modal mode should consume unknown keys regardless of route")
199}
200
201func TestKeyboardHandler_SetRouteKey(t *testing.T) {
202	ctx := context.Background()
203	h := NewKeyboardHandler(ctx, nil, nil)
204
205	// Default: no callback set
206	h.mu.RLock()
207	fn := h.routeKey
208	h.mu.RUnlock()
209	assert.Nil(t, fn, "routeKey should be nil by default")
210
211	// Set callback
212	h.SetRouteKey(func(kc KeyContext) KeyRoute {
213		return RoutePassToWidget
214	})
215
216	h.mu.RLock()
217	fn = h.routeKey
218	h.mu.RUnlock()
219	assert.NotNil(t, fn, "routeKey should be set")
220	assert.Equal(t, RoutePassToWidget, fn(KeyContext{}))
221}
222
223func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
224	ctx := context.Background()
225
226	h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
227
228	// No routeKey set -- should default to shortcut handling
229	// 'z' is not a registered shortcut in ModeNormal, so returns false
230	result := h.handleKeyPress(uint('z'), 0, 0)
231	assert.False(t, result, "unregistered key in ModeNormal returns false")
232}
233
234func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
235	h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
236	calls := 0
237	h.SetOnAction(func(_ context.Context, action Action) error {
238		calls++
239		if action != ActionSwitchTabIndex2 {
240			t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
241		}
242		return nil
243	})
244
245	const fallbackKeycode = 11
246	if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
247		t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
248	}
249
250	if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
251		t.Fatal("first tab-switch key press should be consumed")
252	}
253	if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
254		t.Fatal("repeated held tab-switch key press should still be consumed")
255	}
256	if calls != 1 {
257		t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
258	}
259
260	h.handleKeyRelease(uint('x'))
261	if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
262		t.Fatal("tab-switch key press after release should be consumed")
263	}
264	if calls != 2 {
265		t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
266	}
267}
268
269func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
270	h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
271	const resizeKeycode = 27
272	mods := gdk.ControlMaskValue | gdk.AltMaskValue
273
274	if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
275		t.Fatal("first resize-mode activation should be consumed")
276	}
277	if got := h.Mode(); got != ModeResize {
278		t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
279	}
280
281	if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
282		t.Fatal("repeated held resize-mode activation should be consumed")
283	}
284	if got := h.Mode(); got != ModeResize {
285		t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
286	}
287
288	h.handleKeyRelease(uint('x'))
289	if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
290		t.Fatal("resize-mode activation after unrelated release should still be consumed")
291	}
292	if got := h.Mode(); got != ModeResize {
293		t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
294	}
295
296	h.handleKeyRelease(uint('r'))
297	if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
298		t.Fatal("resize-mode activation after release should be consumed")
299	}
300	if got := h.Mode(); got != ModeNormal {
301		t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
302	}
303}
304
305func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
306	workspace := newTestWorkspace()
307	workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
308		"toggle_floating_pane": {Keys: []string{"alt+f"}},
309	}
310	h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
311	calls := 0
312	h.SetOnAction(func(_ context.Context, action Action) error {
313		calls++
314		if action != ActionToggleFloatingPane {
315			t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
316		}
317		return nil
318	})
319
320	if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
321		t.Fatal("first floating pane toggle should be consumed")
322	}
323	h.handleKeyRelease(uint('x'))
324	if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
325		t.Fatal("held floating pane toggle repeat should still be consumed")
326	}
327	if calls != 1 {
328		t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
329	}
330
331	h.handleKeyRelease(uint('f'))
332	if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
333		t.Fatal("floating pane toggle after release should be consumed")
334	}
335	if calls != 2 {
336		t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
337	}
338}
339
340func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
341	if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
342		t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
343	}
344}

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