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}