v0.31.0 bf0756b

global_shortcuts_test.go RawHistoryFolder

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

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