main 5041353

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		// The test window is never presented, so it has no Wayland surface.
86		// gtk_window_destroy on such an unrealized window segfaults on the
87		// Wayland backend (gdk_surface_get_display assertion). Release the
88		// initial reference instead, matching the unrealized-window paths in
89		// window.New/PopupWindow. Destroy remains correct for shown windows.
90		window.Unref()
91		app.Unref()
92	})
93
94	assertDistinctShortcutActions := func() {
95		t.Helper()
96		shortcutCount := h.controller.GetNItems()
97		if shortcutCount < 2 {
98			t.Fatalf("shortcut count = %d, want at least 2", shortcutCount)
99		}
100		actions := make(map[uintptr]struct{}, shortcutCount)
101		for index := uint(0); index < shortcutCount; index++ {
102			shortcut := gtk.ShortcutNewFromInternalPtr(h.controller.GetItem(index))
103			if shortcut == nil {
104				t.Fatalf("shortcut %d was nil", index)
105			}
106			action := shortcut.GetAction()
107			if action == nil {
108				t.Fatalf("shortcut %d action was nil", index)
109			}
110			actions[action.GoPointer()] = struct{}{}
111			shortcut.Unref()
112		}
113		if got := uint(len(actions)); got != shortcutCount {
114			t.Fatalf("distinct shortcut actions = %d, want %d", got, shortcutCount)
115		}
116	}
117
118	assertDistinctShortcutActions()
119	h.ReloadShortcuts(context.Background(), nil, nil)
120	assertDistinctShortcutActions()
121	h.ReloadShortcuts(context.Background(), nil, nil)
122	assertDistinctShortcutActions()
123}
124
125func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
126	first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
127	second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
128	if first == second {
129		t.Fatal("global shortcut IDs should distinguish bindings for the same action")
130	}
131}
132
133func TestGlobalShortcutIDIncludesGeneration(t *testing.T) {
134	binding := KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}
135	first := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 1)
136	second := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 2)
137	if first == second {
138		t.Fatal("global shortcut IDs should distinguish registrations across reload generations")
139	}
140}
141
142func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
143	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
144	now := time.Unix(100, 0)
145
146	if h.suppressRepeatedShortcut(ActionZoomReset, now) {
147		t.Fatal("first zoom reset dispatch was suppressed")
148	}
149	if !h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow/2)) {
150		t.Fatal("repeated zoom reset dispatch inside suppression window was not suppressed")
151	}
152	if h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow)) {
153		t.Fatal("zoom reset dispatch at suppression boundary was suppressed")
154	}
155}
156
157func TestGlobalShortcutHandlerAllowsRepeatingFocusNavigationActions(t *testing.T) {
158	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
159	now := time.Unix(100, 0)
160
161	if h.suppressRepeatedShortcut(ActionFocusRight, now) {
162		t.Fatal("first focus-right dispatch was suppressed")
163	}
164	if h.suppressRepeatedShortcut(ActionFocusRight, now.Add(time.Millisecond)) {
165		t.Fatal("focus-right repeat was suppressed")
166	}
167}
168
169func TestGlobalShortcutHandlerSuppressesRepeatedBrowserNavigationActions(t *testing.T) {
170	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
171	now := time.Unix(100, 0)
172
173	if h.suppressRepeatedShortcut(ActionGoBack, now) {
174		t.Fatal("first go-back dispatch was suppressed")
175	}
176	if !h.suppressRepeatedShortcut(ActionGoBack, now.Add(time.Millisecond)) {
177		t.Fatal("repeated go-back dispatch was not suppressed")
178	}
179}
180
181func TestGlobalShortcutHandlerSuppressesRepeatedTabIndexSwitchActions(t *testing.T) {
182	actions := []Action{
183		ActionSwitchTabIndex1,
184		ActionSwitchTabIndex2,
185		ActionSwitchTabIndex3,
186		ActionSwitchTabIndex4,
187		ActionSwitchTabIndex5,
188		ActionSwitchTabIndex6,
189		ActionSwitchTabIndex7,
190		ActionSwitchTabIndex8,
191		ActionSwitchTabIndex9,
192		ActionSwitchTabIndex10,
193	}
194
195	for _, action := range actions {
196		t.Run(string(action), func(t *testing.T) {
197			h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
198			now := time.Unix(100, 0)
199
200			if h.suppressRepeatedShortcut(action, now) {
201				t.Fatalf("first %s dispatch was suppressed", action)
202			}
203			if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
204				t.Fatalf("repeated %s dispatch was not suppressed", action)
205			}
206		})
207	}
208}
209
210func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
211	h := &GlobalShortcutHandler{
212		registered:     make(map[KeyBinding]Action),
213		lastDispatchAt: make(map[Action]time.Time),
214	}
215
216	if h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
217		t.Fatal("one-shot shortcut was suppressed before detach")
218	}
219	h.Detach()
220	if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) {
221		t.Fatal("one-shot shortcut was not suppressed with detached handler state")
222	}
223}
224
225func TestGlobalShortcutHandlerDetachForDestroyClearsRetainedState(t *testing.T) {
226	h := &GlobalShortcutHandler{
227		registered:            make(map[KeyBinding]Action),
228		shortcutRefs:          make(map[string]globalShortcutRegistration),
229		lastDispatchAt:        make(map[Action]time.Time),
230		heldShortcuts:         make(map[globalShortcutHoldKey]struct{}),
231		globalActionCb:        func(_ gio.SimpleAction, _ uintptr) {},
232		globalActionHandlerID: 1,
233		keyReleasedCb:         func(_ gtk.EventControllerKey, _ uint, _ uint, _ gdk.ModifierType) {},
234		keyReleasedHandlerID:  2,
235	}
236
237	h.DetachForDestroy()
238
239	if h.controller != nil {
240		t.Fatal("controller should be cleared during destroy detach")
241	}
242	if h.releaseController != nil {
243		t.Fatal("releaseController should be cleared during destroy detach")
244	}
245	if h.globalAction != nil {
246		t.Fatal("globalAction should be cleared during destroy detach")
247	}
248	if h.globalActionCb != nil {
249		t.Fatal("globalActionCb should be cleared during destroy detach")
250	}
251	if h.keyReleasedCb != nil {
252		t.Fatal("keyReleasedCb should be cleared during destroy detach")
253	}
254	if h.globalActionHandlerID != 0 {
255		t.Fatal("globalActionHandlerID should be reset during destroy detach")
256	}
257	if h.keyReleasedHandlerID != 0 {
258		t.Fatal("keyReleasedHandlerID should be reset during destroy detach")
259	}
260	if h.registered != nil {
261		t.Fatal("registered shortcuts should be cleared during destroy detach")
262	}
263	if h.shortcutRefs != nil {
264		t.Fatal("shortcut refs should be cleared during destroy detach")
265	}
266	if h.lastDispatchAt != nil {
267		t.Fatal("lastDispatchAt should be cleared during destroy detach")
268	}
269	if h.heldShortcuts != nil {
270		t.Fatal("heldShortcuts should be cleared during destroy detach")
271	}
272}
273
274func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
275	h := &GlobalShortcutHandler{}
276	generation := h.generation
277
278	if h.isStaleGeneration(generation) {
279		t.Fatal("current generation was marked stale")
280	}
281	h.generation++
282	if !h.isStaleGeneration(generation) {
283		t.Fatal("old generation was not marked stale")
284	}
285}
286
287func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
288	var nilHandler *GlobalShortcutHandler
289	if nilHandler.isActiveWindowShortcutHandler() {
290		t.Fatal("nil handler should return inactive")
291	}
292	if (&GlobalShortcutHandler{}).isActiveWindowShortcutHandler() {
293		t.Fatal("handler with nil window should return inactive")
294	}
295
296	h := &GlobalShortcutHandler{
297		registered:     make(map[KeyBinding]Action),
298		lastDispatchAt: make(map[Action]time.Time),
299	}
300	h.Detach()
301	if h.isActiveWindowShortcutHandler() {
302		t.Fatal("detached handler with nil controller should return inactive")
303	}
304}
305
306func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *testing.T) {
307	actions := []Action{
308		ActionPrintPage,
309		ActionReload,
310		ActionHardReload,
311		ActionToggleFullscreen,
312		ActionToggleHistorySystemView,
313		ActionOpenOmnibox,
314		ActionOpenFind,
315		ActionFindNext,
316		ActionFindPrev,
317		ActionOpenDevTools,
318		ActionToggleFloatingPane,
319		ActionToggleFavoritesSidebar,
320		ActionToggleCurrentPageFavorite,
321		ActionToggleConfigSystemView,
322		ActionCopyURL,
323		ActionConsumeOrExpelLeft,
324		ActionConsumeOrExpelRight,
325		ActionConsumeOrExpelUp,
326		ActionConsumeOrExpelDown,
327	}
328
329	for _, action := range actions {
330		t.Run(string(action), func(t *testing.T) {
331			h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
332			now := time.Unix(100, 0)
333
334			if h.suppressRepeatedShortcut(action, now) {
335				t.Fatalf("first %s dispatch was suppressed", action)
336			}
337			if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
338				t.Fatalf("repeated %s dispatch was not suppressed", action)
339			}
340		})
341	}
342}
343
344func TestGlobalShortcutHandlerSuppressesRepeatedFloatingProfileAction(t *testing.T) {
345	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
346	now := time.Unix(100, 0)
347	action := NewFloatingProfileAction("work", "https://example.com")
348
349	if h.suppressRepeatedShortcut(action, now) {
350		t.Fatal("first floating-profile dispatch was suppressed")
351	}
352	if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
353		t.Fatal("repeated floating-profile dispatch was not suppressed")
354	}
355}
356
357func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
358	if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
359		t.Fatal("ActionSwitchLastTab should be suppressed")
360	}
361
362	h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
363	now := time.Unix(100, 0)
364
365	if h.suppressRepeatedShortcut(ActionSwitchLastTab, now) {
366		t.Fatal("first ActionSwitchLastTab dispatch was suppressed")
367	}
368	if !h.suppressRepeatedShortcut(ActionSwitchLastTab, now.Add(time.Millisecond)) {
369		t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
370	}
371}
372
373func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testing.T) {
374	h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
375	info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('f'), eventKeycode: 41}
376
377	if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
378		t.Fatal("first held global shortcut dispatch was suppressed")
379	}
380	if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
381		t.Fatal("held global shortcut repeat after cooldown was not suppressed")
382	}
383
384	h.releaseHeldGlobalShortcuts(uint('x'), 0)
385	if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
386		t.Fatal("unrelated key release re-armed held global shortcut")
387	}
388
389	h.releaseHeldGlobalShortcuts(uint('f'), 0)
390	if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
391		t.Fatal("matching key release did not re-arm held global shortcut")
392	}
393}
394
395func TestGlobalShortcutHandlerSuppressesHeldModeAction(t *testing.T) {
396	h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
397	info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('p'), eventKeycode: 33}
398
399	if h.suppressHeldShortcut(ActionEnterPaneMode, info) {
400		t.Fatal("first mode shortcut dispatch was suppressed")
401	}
402	if !h.suppressHeldShortcut(ActionEnterPaneMode, info) {
403		t.Fatal("held mode shortcut repeat was not suppressed")
404	}
405}
406
407func TestGlobalShortcutHandlerSuppressesHeldFloatingProfileAction(t *testing.T) {
408	h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
409	info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('o'), eventKeycode: 32}
410	action := NewFloatingProfileAction("work", "https://example.com")
411
412	if h.suppressHeldShortcut(action, info) {
413		t.Fatal("first floating-profile shortcut dispatch was suppressed")
414	}
415	if !h.suppressHeldShortcut(action, info) {
416		t.Fatal("held floating-profile shortcut repeat was not suppressed")
417	}
418}
419
420func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
421	if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
422		t.Fatal("global shortcut without current event should not dispatch")
423	}
424	if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyReleaseValue}) {
425		t.Fatal("global shortcut from key-release event should not dispatch")
426	}
427	if !shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue}) {
428		t.Fatal("global shortcut from key-press event should dispatch")
429	}
430}
431
432func TestFormatModifierMask(t *testing.T) {
433	got := formatModifierMask(gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.ModifierType(0x2000))
434	if got != "ctrl+shift+0x2000" {
435		t.Fatalf("unexpected modifier mask: %q", got)
436	}
437	if formatModifierMask(0) != "none" {
438		t.Fatal("zero modifier mask should render as none")
439	}
440}
441
442func TestFormatEventType(t *testing.T) {
443	if got := formatEventType(gdk.KeyPressValue); got != "key-press" {
444		t.Fatalf("unexpected key press event type: %q", got)
445	}
446	if got := formatEventType(gdk.EventType(99)); got != "event-99" {
447		t.Fatalf("unexpected fallback event type: %q", got)
448	}
449}

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