v0.30.0 eb3068f

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

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