v0.30.0 eb3068f

global_shortcuts_test.go RawHistoryFolder

1package input
2
3import (
4	"testing"
5	"time"
6
7	"github.com/bnema/puregotk/v4/gdk"
8	"github.com/bnema/puregotk/v4/gio"
9	"github.com/bnema/puregotk/v4/gtk"
10)
11
12func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {
13	h := &GlobalShortcutHandler{
14		registered:   make(map[KeyBinding]Action),
15		shortcutRefs: make(map[string]globalShortcutRegistration),
16	}
17
18	for i := 0; i < 57; i++ {
19		binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: Modifier(gdk.AltMaskValue)}
20		action := ActionSwitchTabIndex1
21		shortcutID := encodeGlobalShortcutID(action, binding, h.generation)
22		h.registered[binding] = action
23		h.shortcutRefs[shortcutID] = globalShortcutRegistration{action: action, binding: binding, generation: h.generation}
24	}
25	if got := h.estimatedPuregoCallbackBudget(); got != 0 {
26		t.Fatalf("callback budget without retained GTK callbacks = %d, want 0", got)
27	}
28
29	h.globalActionCb = func(_ gio.SimpleAction, _ uintptr) {}
30	h.keyReleasedCb = func(_ gtk.EventControllerKey, _ uint, _ uint, _ gdk.ModifierType) {}
31	if got := h.estimatedPuregoCallbackBudget(); got != 2 {
32		t.Fatalf("callback budget with 57 shortcuts = %d, want 2", got)
33	}
34}
35
36func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
37	first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
38	second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
39	if first == second {
40		t.Fatal("global shortcut IDs should distinguish bindings for the same action")
41	}
42}
43
44func TestGlobalShortcutIDIncludesGeneration(t *testing.T) {
45	binding := KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}
46	first := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 1)
47	second := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 2)
48	if first == second {
49		t.Fatal("global shortcut IDs should distinguish registrations across reload generations")
50	}
51}
52
53func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
54	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
55	now := time.Unix(100, 0)
56
57	if h.suppressRepeatedShortcut(ActionZoomReset, now) {
58		t.Fatal("first zoom reset dispatch was suppressed")
59	}
60	if !h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow/2)) {
61		t.Fatal("repeated zoom reset dispatch inside suppression window was not suppressed")
62	}
63	if h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow)) {
64		t.Fatal("zoom reset dispatch at suppression boundary was suppressed")
65	}
66}
67
68func TestGlobalShortcutHandlerAllowsRepeatingFocusNavigationActions(t *testing.T) {
69	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
70	now := time.Unix(100, 0)
71
72	if h.suppressRepeatedShortcut(ActionFocusRight, now) {
73		t.Fatal("first focus-right dispatch was suppressed")
74	}
75	if h.suppressRepeatedShortcut(ActionFocusRight, now.Add(time.Millisecond)) {
76		t.Fatal("focus-right repeat was suppressed")
77	}
78}
79
80func TestGlobalShortcutHandlerSuppressesRepeatedBrowserNavigationActions(t *testing.T) {
81	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
82	now := time.Unix(100, 0)
83
84	if h.suppressRepeatedShortcut(ActionGoBack, now) {
85		t.Fatal("first go-back dispatch was suppressed")
86	}
87	if !h.suppressRepeatedShortcut(ActionGoBack, now.Add(time.Millisecond)) {
88		t.Fatal("repeated go-back dispatch was not suppressed")
89	}
90}
91
92func TestGlobalShortcutHandlerSuppressesRepeatedTabIndexSwitchActions(t *testing.T) {
93	actions := []Action{
94		ActionSwitchTabIndex1,
95		ActionSwitchTabIndex2,
96		ActionSwitchTabIndex3,
97		ActionSwitchTabIndex4,
98		ActionSwitchTabIndex5,
99		ActionSwitchTabIndex6,
100		ActionSwitchTabIndex7,
101		ActionSwitchTabIndex8,
102		ActionSwitchTabIndex9,
103		ActionSwitchTabIndex10,
104	}
105
106	for _, action := range actions {
107		t.Run(string(action), func(t *testing.T) {
108			h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
109			now := time.Unix(100, 0)
110
111			if h.suppressRepeatedShortcut(action, now) {
112				t.Fatalf("first %s dispatch was suppressed", action)
113			}
114			if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
115				t.Fatalf("repeated %s dispatch was not suppressed", action)
116			}
117		})
118	}
119}
120
121func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
122	h := &GlobalShortcutHandler{
123		registered:     make(map[KeyBinding]Action),
124		lastDispatchAt: make(map[Action]time.Time),
125	}
126
127	if h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
128		t.Fatal("one-shot shortcut was suppressed before detach")
129	}
130	h.Detach()
131	if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
132		t.Fatal("one-shot shortcut was not suppressed with detached handler state")
133	}
134}
135
136func TestGlobalShortcutHandlerDetachForDestroyClearsRetainedState(t *testing.T) {
137	h := &GlobalShortcutHandler{
138		registered:            make(map[KeyBinding]Action),
139		shortcutRefs:          make(map[string]globalShortcutRegistration),
140		lastDispatchAt:        make(map[Action]time.Time),
141		heldShortcuts:         make(map[globalShortcutHoldKey]struct{}),
142		globalActionCb:        func(_ gio.SimpleAction, _ uintptr) {},
143		globalActionHandlerID: 1,
144		keyReleasedCb:         func(_ gtk.EventControllerKey, _ uint, _ uint, _ gdk.ModifierType) {},
145		keyReleasedHandlerID:  2,
146	}
147
148	h.DetachForDestroy()
149
150	if h.controller != nil {
151		t.Fatal("controller should be cleared during destroy detach")
152	}
153	if h.releaseController != nil {
154		t.Fatal("releaseController should be cleared during destroy detach")
155	}
156	if h.shortcutAction != nil {
157		t.Fatal("shortcutAction should be cleared during destroy detach")
158	}
159	if h.globalAction != nil {
160		t.Fatal("globalAction should be cleared during destroy detach")
161	}
162	if h.globalActionCb != nil {
163		t.Fatal("globalActionCb should be cleared during destroy detach")
164	}
165	if h.keyReleasedCb != nil {
166		t.Fatal("keyReleasedCb should be cleared during destroy detach")
167	}
168	if h.globalActionHandlerID != 0 {
169		t.Fatal("globalActionHandlerID should be reset during destroy detach")
170	}
171	if h.keyReleasedHandlerID != 0 {
172		t.Fatal("keyReleasedHandlerID should be reset during destroy detach")
173	}
174	if h.registered != nil {
175		t.Fatal("registered shortcuts should be cleared during destroy detach")
176	}
177	if h.shortcutRefs != nil {
178		t.Fatal("shortcut refs should be cleared during destroy detach")
179	}
180	if h.lastDispatchAt != nil {
181		t.Fatal("lastDispatchAt should be cleared during destroy detach")
182	}
183	if h.heldShortcuts != nil {
184		t.Fatal("heldShortcuts should be cleared during destroy detach")
185	}
186}
187
188func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
189	h := &GlobalShortcutHandler{}
190	generation := h.generation
191
192	if h.isStaleGeneration(generation) {
193		t.Fatal("current generation was marked stale")
194	}
195	h.generation++
196	if !h.isStaleGeneration(generation) {
197		t.Fatal("old generation was not marked stale")
198	}
199}
200
201func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
202	var nilHandler *GlobalShortcutHandler
203	if nilHandler.isActiveWindowShortcutHandler() {
204		t.Fatal("nil handler should return inactive")
205	}
206	if (&GlobalShortcutHandler{}).isActiveWindowShortcutHandler() {
207		t.Fatal("handler with nil window should return inactive")
208	}
209
210	h := &GlobalShortcutHandler{
211		registered:     make(map[KeyBinding]Action),
212		lastDispatchAt: make(map[Action]time.Time),
213	}
214	h.Detach()
215	if h.isActiveWindowShortcutHandler() {
216		t.Fatal("detached handler with nil controller should return inactive")
217	}
218}
219
220func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *testing.T) {
221	actions := []Action{
222		ActionPrintPage,
223		ActionReload,
224		ActionHardReload,
225		ActionToggleFullscreen,
226		ActionToggleHistorySystemView,
227		ActionOpenOmnibox,
228		ActionOpenFind,
229		ActionFindNext,
230		ActionFindPrev,
231		ActionOpenDevTools,
232		ActionToggleFloatingPane,
233		ActionToggleFavoritesSystemView,
234		ActionToggleConfigSystemView,
235		ActionCopyURL,
236		ActionConsumeOrExpelLeft,
237		ActionConsumeOrExpelRight,
238		ActionConsumeOrExpelUp,
239		ActionConsumeOrExpelDown,
240	}
241
242	for _, action := range actions {
243		t.Run(string(action), func(t *testing.T) {
244			h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
245			now := time.Unix(100, 0)
246
247			if h.suppressRepeatedShortcut(action, now) {
248				t.Fatalf("first %s dispatch was suppressed", action)
249			}
250			if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
251				t.Fatalf("repeated %s dispatch was not suppressed", action)
252			}
253		})
254	}
255}
256
257func TestGlobalShortcutHandlerSuppressesRepeatedFloatingProfileAction(t *testing.T) {
258	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
259	now := time.Unix(100, 0)
260	action := NewFloatingProfileAction("work", "https://example.com")
261
262	if h.suppressRepeatedShortcut(action, now) {
263		t.Fatal("first floating-profile dispatch was suppressed")
264	}
265	if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
266		t.Fatal("repeated floating-profile dispatch was not suppressed")
267	}
268}
269
270func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
271	if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
272		t.Fatal("ActionSwitchLastTab should be suppressed")
273	}
274
275	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
276	now := time.Unix(100, 0)
277
278	if h.suppressRepeatedShortcut(ActionSwitchLastTab, now) {
279		t.Fatal("first ActionSwitchLastTab dispatch was suppressed")
280	}
281	if !h.suppressRepeatedShortcut(ActionSwitchLastTab, now.Add(time.Millisecond)) {
282		t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
283	}
284}
285
286func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testing.T) {
287	h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
288	info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('f'), eventKeycode: 41}
289
290	if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
291		t.Fatal("first held global shortcut dispatch was suppressed")
292	}
293	if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
294		t.Fatal("held global shortcut repeat after cooldown was not suppressed")
295	}
296
297	h.releaseHeldGlobalShortcuts(uint('x'), 0)
298	if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
299		t.Fatal("unrelated key release re-armed held global shortcut")
300	}
301
302	h.releaseHeldGlobalShortcuts(uint('f'), 0)
303	if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
304		t.Fatal("matching key release did not re-arm held global shortcut")
305	}
306}
307
308func TestGlobalShortcutHandlerSuppressesHeldModeAction(t *testing.T) {
309	h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
310	info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('p'), eventKeycode: 33}
311
312	if h.suppressHeldShortcut(ActionEnterPaneMode, info) {
313		t.Fatal("first mode shortcut dispatch was suppressed")
314	}
315	if !h.suppressHeldShortcut(ActionEnterPaneMode, info) {
316		t.Fatal("held mode shortcut repeat was not suppressed")
317	}
318}
319
320func TestGlobalShortcutHandlerSuppressesHeldFloatingProfileAction(t *testing.T) {
321	h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
322	info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('o'), eventKeycode: 32}
323	action := NewFloatingProfileAction("work", "https://example.com")
324
325	if h.suppressHeldShortcut(action, info) {
326		t.Fatal("first floating-profile shortcut dispatch was suppressed")
327	}
328	if !h.suppressHeldShortcut(action, info) {
329		t.Fatal("held floating-profile shortcut repeat was not suppressed")
330	}
331}
332
333func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
334	if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
335		t.Fatal("global shortcut without current event should not dispatch")
336	}
337	if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyReleaseValue}) {
338		t.Fatal("global shortcut from key-release event should not dispatch")
339	}
340	if !shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue}) {
341		t.Fatal("global shortcut from key-press event should dispatch")
342	}
343}
344
345func TestFormatModifierMask(t *testing.T) {
346	got := formatModifierMask(gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.ModifierType(0x2000))
347	if got != "ctrl+shift+0x2000" {
348		t.Fatalf("unexpected modifier mask: %q", got)
349	}
350	if formatModifierMask(0) != "none" {
351		t.Fatal("zero modifier mask should render as none")
352	}
353}
354
355func TestFormatEventType(t *testing.T) {
356	if got := formatEventType(gdk.KeyPressValue); got != "key-press" {
357		t.Fatalf("unexpected key press event type: %q", got)
358	}
359	if got := formatEventType(gdk.EventType(99)); got != "event-99" {
360		t.Fatalf("unexpected fallback event type: %q", got)
361	}
362}

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