detached 5041353

prefix_mode_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	"github.com/stretchr/testify/require"
11)
12
13func newTmuxWorkspace() *entity.WorkspaceConfig {
14	ws := newTestWorkspace()
15	ws.Keymap = entity.KeymapTmux
16	ws.VimMode.ActivationShortcut = "ctrl+y"
17	ws.PrefixMode = entity.PrefixModeConfig{
18		ActivationShortcut: "ctrl+space",
19		Actions: map[string]entity.ActionBinding{
20			"split-right":       {Keys: []string{"%"}},
21			"split-down":        {Keys: []string{"\""}},
22			"new-tab":           {Keys: []string{"c"}},
23			"next-tab":          {Keys: []string{"n"}},
24			"enter-resize-mode": {Keys: []string{"r"}},
25			"cancel":            {Keys: []string{"escape"}},
26		},
27	}
28	return ws
29}
30
31func recordActions(h *KeyboardHandler) *[]Action {
32	var got []Action
33	h.SetOnAction(func(_ context.Context, action Action) error {
34		got = append(got, action)
35		return nil
36	})
37	return &got
38}
39
40func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) {
41	set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession())
42
43	prefix, _ := ParseKeyString("ctrl+space")
44	assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix])
45	for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} {
46		binding, ok := ParseKeyString(key)
47		require.True(t, ok)
48		_, bound := set.Global[binding]
49		assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key)
50	}
51	vim, _ := ParseKeyString("ctrl+y")
52	assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation")
53}
54
55func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) {
56	ws := newTmuxWorkspace()
57	ws.Keymap = entity.KeymapZellij
58	set := NewShortcutSet(context.Background(), ws, newTestSession())
59
60	prefix, _ := ParseKeyString("ctrl+space")
61	_, bound := set.Global[prefix]
62	assert.False(t, bound)
63	tab, _ := ParseKeyString("ctrl+t")
64	assert.Equal(t, ActionEnterTabMode, set.Global[tab])
65}
66
67func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) {
68	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
69	got := recordActions(h)
70
71	require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
72	require.Equal(t, ModePrefix, h.Mode())
73
74	// '%' is typed with Shift on most layouts; the binding omits Shift.
75	require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue))
76	assert.Equal(t, []Action{ActionSplitRight}, *got)
77	assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot")
78
79	// A plain key after the prefix exited must not dispatch again.
80	assert.False(t, h.handleKeyPress(uint('n'), 0, 0))
81	assert.Len(t, *got, 1)
82}
83
84func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) {
85	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
86	got := recordActions(h)
87
88	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
89	assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed")
90	assert.Equal(t, ModeNormal, h.Mode())
91	assert.Empty(t, *got)
92}
93
94func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) {
95	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
96
97	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
98	h.handleKeyPress(uint('r'), 0, 0)
99	assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode")
100}
101
102func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
103	h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
104
105	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
106	h.handleKeyRelease(uint(gdk.KEY_space))
107	h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)
108	assert.Equal(t, ModeNormal, h.Mode())
109}
110
111func TestShortcutSet_BuiltinShortcutsAndOverrides(t *testing.T) {
112	ws := newTestWorkspace()
113	ws.Shortcuts.Actions["reload"] = entity.ActionBinding{Keys: []string{}}             // disabled by user
114	ws.Shortcuts.Actions["quit"] = entity.ActionBinding{Keys: []string{"ctrl+shift+q"}} // rebound by user
115	set := NewShortcutSet(context.Background(), ws, newTestSession())
116
117	lookup := func(key string) (Action, bool) {
118		binding, ok := ParseKeyString(key)
119		require.True(t, ok, key)
120		action, found := set.Global[binding]
121		return action, found
122	}
123
124	action, ok := lookup("ctrl+l")
125	assert.True(t, ok, "untouched builtin action keeps its default key")
126	assert.Equal(t, ActionOpenOmnibox, action)
127
128	_, ok = lookup("ctrl+r")
129	assert.False(t, ok, "empty keys disable a builtin shortcut")
130
131	_, ok = lookup("ctrl+q")
132	assert.False(t, ok, "rebound builtin drops its default key")
133	action, _ = lookup("ctrl+shift+q")
134	assert.Equal(t, ActionQuit, action)
135}
136
137func TestParseKeyString_PrintableSymbols(t *testing.T) {
138	for _, key := range []string{"%", "\"", "&", ",", "!"} {
139		binding, ok := ParseKeyString(key)
140		require.Truef(t, ok, "%q should parse", key)
141		assert.Equal(t, uint(key[0]), binding.Keyval)
142		assert.Equal(t, ModNone, binding.Modifiers)
143	}
144}
145
146type keyEvent struct {
147	keyval uint
148	state  gdk.ModifierType
149}
150
151// TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers
152// them: modifier and dead-key presses arrive before the character.
153func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) {
154	shift := gdk.ShiftMaskValue
155	tests := []struct {
156		name       string
157		events     []keyEvent
158		wantAction []Action
159		wantMode   Mode
160		lastResult bool
161	}{
162		{
163			name:       "shift then percent splits right",
164			events:     []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}},
165			wantAction: []Action{ActionSplitRight},
166			wantMode:   ModeNormal,
167			lastResult: true,
168		},
169		{
170			name:       "shift then quote splits down",
171			events:     []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}},
172			wantAction: []Action{ActionSplitDown},
173			wantMode:   ModeNormal,
174			lastResult: true,
175		},
176		{
177			name:       "dead diaeresis splits down on international layouts",
178			events:     []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}},
179			wantAction: []Action{ActionSplitDown},
180			wantMode:   ModeNormal,
181			lastResult: true,
182		},
183		{
184			name:       "altgr alone keeps the prefix waiting",
185			events:     []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}},
186			wantMode:   ModePrefix,
187			lastResult: true,
188		},
189		{
190			name:       "unbound modified key cancels and passes through",
191			events:     []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}},
192			wantMode:   ModeNormal,
193			lastResult: false,
194		},
195	}
196
197	for _, tt := range tests {
198		t.Run(tt.name, func(t *testing.T) {
199			h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession())
200			got := recordActions(h)
201			require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue))
202			h.handleKeyRelease(uint(gdk.KEY_space))
203
204			var result bool
205			for _, ev := range tt.events {
206				result = h.handleKeyPress(ev.keyval, 0, ev.state)
207			}
208			assert.Equal(t, tt.lastResult, result)
209			assert.Equal(t, tt.wantMode, h.Mode())
210			assert.Equal(t, tt.wantAction, *got)
211		})
212	}
213}
214
215func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) {
216	ws := newTestWorkspace()
217	ws.Shortcuts.Actions = map[string]entity.ActionBinding{
218		"toggle-floating-pane": {Keys: []string{"ctrl+l"}},
219		"open-omnibox":         {Keys: []string{"ctrl+l"}},
220	}
221	for range 20 {
222		set := NewShortcutSet(context.Background(), ws, newTestSession())
223		binding, _ := ParseKeyString("ctrl+l")
224		require.Equal(t, ActionOpenOmnibox, set.Global[binding])
225	}
226}

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