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}