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}