1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5 "context"
6 "fmt"
7 "maps"
8 "strings"
9 "time"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk/v4/gdk"
14 "github.com/bnema/puregotk/v4/gio"
15 "github.com/bnema/puregotk/v4/glib"
16 "github.com/bnema/puregotk/v4/gtk"
17 "github.com/rs/zerolog"
18)
19
20// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
21// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
22// to intercept shortcuts before they reach the WebView.
23type GlobalShortcutHandler struct {
24 controller *gtk.ShortcutController
25 releaseController *gtk.EventControllerKey
26 window *gtk.ApplicationWindow
27 kbHandler *KeyboardHandler
28 onAction ActionHandler
29 ctx context.Context
30 registered map[KeyBinding]Action
31 shortcutRefs map[string]globalShortcutRegistration
32 lastDispatchAt map[Action]time.Time
33 heldShortcuts map[globalShortcutHoldKey]struct{}
34 globalAction *gio.SimpleAction
35 globalActionCb func(gio.SimpleAction, uintptr)
36 globalActionHandlerID uint
37 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
38 keyReleasedHandlerID uint
39 // generation is mutated only from the GTK main thread alongside controller replacement.
40 generation uint64
41}
42
43// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
44// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
45func NewGlobalShortcutHandler(
46 ctx context.Context,
47 window *gtk.ApplicationWindow,
48 workspace *entity.WorkspaceConfig,
49 session *entity.SessionConfig,
50 kbHandler *KeyboardHandler,
51 onAction ActionHandler,
52) *GlobalShortcutHandler {
53 log := logging.FromContext(ctx)
54 log.Debug().Msg("creating global shortcut handler")
55
56 h := &GlobalShortcutHandler{
57 controller: gtk.NewShortcutController(),
58 releaseController: gtk.NewEventControllerKey(),
59 window: window,
60 kbHandler: kbHandler,
61 onAction: onAction,
62 ctx: ctx,
63 registered: make(map[KeyBinding]Action),
64 shortcutRefs: make(map[string]globalShortcutRegistration),
65 lastDispatchAt: make(map[Action]time.Time),
66 heldShortcuts: make(map[globalShortcutHoldKey]struct{}),
67 }
68
69 if h.controller == nil {
70 log.Error().Msg("failed to create shortcut controller")
71 return nil
72 }
73 if h.releaseController == nil {
74 log.Error().Msg("failed to create global shortcut release controller")
75 return nil
76 }
77
78 h.configureGlobalControllers()
79 h.registerDefaultGlobalShortcuts(log)
80
81 if workspace != nil {
82 actionMap := globalShortcutActionMap()
83
84 for actionName, actionBinding := range workspace.Shortcuts.Actions {
85 action, ok := actionMap[actionName]
86 if !ok {
87 continue
88 }
89 for _, keyStr := range actionBinding.Keys {
90 binding, ok := ParseKeyString(keyStr)
91 if !ok {
92 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
93 continue
94 }
95 if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
96 log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
97 }
98 }
99 }
100
101 occupied := make(map[KeyBinding]Action, len(h.registered))
102 maps.Copy(occupied, h.registered)
103 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
104 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
105 continue
106 }
107 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
108 log.Trace().
109 Str("shortcut", formatBinding(shortcut.Binding)).
110 Str("url", url).
111 Msg("registered floating profile global shortcut")
112 }
113 }
114
115 // Register app-reserved shortcuts (Ctrl+L, Ctrl+F, other mode activations, etc.)
116 // so they work even when WebView has focus.
117 // Vim mode activation is intentionally excluded here: it must flow through
118 // the normal KeyboardHandler path so editable page contexts can pass Ctrl+Y
119 // through instead of having a global shortcut controller consume it first.
120 shortcuts := NewShortcutSet(ctx, workspace, session)
121 for binding, action := range shortcuts.Global {
122 if !shouldRegisterGTKGlobalShortcut(action) {
123 continue
124 }
125 if _, exists := h.registered[binding]; exists {
126 continue
127 }
128 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
129 }
130 }
131
132 // Attach to window
133 window.AddController(&h.releaseController.EventController)
134 window.AddController(&h.controller.EventController)
135
136 log.Debug().
137 Int("shortcuts", len(h.registered)).
138 Msg("global shortcut handler created and attached")
139
140 return h
141}
142
143func (h *GlobalShortcutHandler) configureGlobalControllers() {
144 // Set global scope - this is the key to making shortcuts work
145 // even when WebView has focus.
146 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
147 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))
148 if h.globalAction != nil {
149 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {
150 h.activateGlobalShortcut(parameter)
151 }
152 h.globalActionHandlerID = h.globalAction.ConnectActivate(&h.globalActionCb)
153 h.window.AddAction(h.globalAction)
154 }
155 h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
156 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
157 h.releaseHeldGlobalShortcuts(keyval, keycode)
158 }
159 h.keyReleasedHandlerID = h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
160}
161
162func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logger) {
163 // Register Alt+1 through Alt+9 for tab switching.
164 tabActions := []Action{
165 ActionSwitchTabIndex1,
166 ActionSwitchTabIndex2,
167 ActionSwitchTabIndex3,
168 ActionSwitchTabIndex4,
169 ActionSwitchTabIndex5,
170 ActionSwitchTabIndex6,
171 ActionSwitchTabIndex7,
172 ActionSwitchTabIndex8,
173 ActionSwitchTabIndex9,
174 }
175
176 for i, action := range tabActions {
177 keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
178 h.registerShortcut(keyval, gdk.AltMaskValue, action)
179 log.Trace().
180 Uint("keyval", keyval).
181 Str("action", string(action)).
182 Msg("registered global shortcut")
183 }
184
185 // Alt+0 for tab 10.
186 h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
187 log.Trace().
188 Uint("keyval", uint(gdk.KEY_0)).
189 Str("action", string(ActionSwitchTabIndex10)).
190 Msg("registered global shortcut")
191
192 // Alt+Tab for switching to last active tab.
193 h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
194 log.Trace().
195 Uint("keyval", uint(gdk.KEY_Tab)).
196 Str("action", string(ActionSwitchLastTab)).
197 Msg("registered global shortcut")
198
199 // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
200 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
201 log.Trace().
202 Uint("keyval", uint(gdk.KEY_s)).
203 Str("action", string(ActionOpenSessionManager)).
204 Msg("registered global shortcut")
205}
206
207// registerShortcut creates and registers a single shortcut with the controller.
208func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
209 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
210 if existing, exists := h.registered[binding]; exists {
211 logging.FromContext(h.ctx).Warn().
212 Str("existing_action", string(existing)).
213 Str("new_action", string(action)).
214 Str("shortcut", formatBinding(binding)).
215 Msg("global shortcut conflict, skipping")
216 return false
217 }
218
219 // Create trigger for this key combination.
220 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
221 if trigger == nil {
222 logging.FromContext(h.ctx).Error().
223 Uint("keyval", keyval).
224 Msg("failed to create keyval trigger")
225 return false
226 }
227
228 // GtkShortcut takes ownership of its action. Each shortcut needs its own
229 // named action even though they all dispatch through the same window action.
230 shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
231 if shortcutAction == nil {
232 logging.FromContext(h.ctx).Error().
233 Uint("keyval", keyval).
234 Msg("failed to create global shortcut action")
235 return false
236 }
237
238 shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
239 if shortcut == nil {
240 logging.FromContext(h.ctx).Error().
241 Uint("keyval", keyval).
242 Msg("failed to create shortcut")
243 return false
244 }
245
246 generation := h.generation
247 shortcutID := encodeGlobalShortcutID(action, binding, generation)
248 shortcut.SetArguments(glib.NewVariantString(shortcutID))
249
250 // Add to controller.
251 h.controller.AddShortcut(shortcut)
252 h.registered[binding] = action
253 h.shortcutRefs[shortcutID] = globalShortcutRegistration{
254 action: action,
255 binding: binding,
256 generation: generation,
257 }
258 return true
259}
260
261func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
262 log := logging.FromContext(h.ctx)
263 registration, ok := h.globalShortcutRegistration(parameter)
264 if !ok {
265 log.Warn().Msg("global shortcut activated without registered argument")
266 return
267 }
268 h.dispatchGlobalShortcut(registration.action, registration.binding, registration.generation)
269}
270
271func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
272 if parameter == 0 || h.shortcutRefs == nil {
273 return globalShortcutRegistration{}, false
274 }
275 variant := glib.VariantNewFromInternalPtr(parameter)
276 if variant == nil {
277 return globalShortcutRegistration{}, false
278 }
279 shortcutID := variant.GetString(nil)
280 registration, ok := h.shortcutRefs[shortcutID]
281 return registration, ok
282}
283
284func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
285 log := logging.FromContext(h.ctx)
286 if h.isStaleGeneration(generation) {
287 log.Trace().
288 Str("action", string(actionToDispatch)).
289 Str("shortcut", formatBinding(bindingForLog)).
290 Uint64("callback_generation", generation).
291 Uint64("current_generation", h.generation).
292 Msg("stale global shortcut callback ignored")
293 return true
294 }
295 if !h.isActiveWindowShortcutHandler() {
296 log.Trace().
297 Str("action", string(actionToDispatch)).
298 Str("shortcut", formatBinding(bindingForLog)).
299 Msg("inactive window global shortcut callback ignored")
300 return false
301 }
302 if h.kbHandler != nil && shouldIgnoreGlobalShortcutInMode(h.kbHandler.Mode(), actionToDispatch) {
303 log.Trace().
304 Str("action", string(actionToDispatch)).
305 Str("shortcut", formatBinding(bindingForLog)).
306 Str("mode", h.kbHandler.Mode().String()).
307 Msg("global shortcut ignored in current modal mode")
308 return true
309 }
310 eventInfo := h.inspectCurrentShortcutEvent()
311 if !shouldDispatchGlobalShortcutEvent(eventInfo) {
312 appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
313 Msg("global shortcut ignored without current key event")
314 return true
315 }
316 if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
317 log.Trace().
318 Str("action", string(actionToDispatch)).
319 Str("shortcut", formatBinding(bindingForLog)).
320 Msg("held global shortcut repeat suppressed")
321 return true
322 }
323 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
324 log.Trace().
325 Str("action", string(actionToDispatch)).
326 Str("shortcut", formatBinding(bindingForLog)).
327 Msg("global shortcut repeat suppressed")
328 return true
329 }
330 appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
331 Msg("global shortcut triggered")
332
333 // Mode-enter/exit actions go through KeyboardHandler for modal state.
334 if isModeAction(actionToDispatch) {
335 if h.kbHandler != nil {
336 return h.kbHandler.DispatchAction(actionToDispatch)
337 }
338 log.Warn().
339 Str("action", string(actionToDispatch)).
340 Msg("mode action triggered but keyboard handler not set, falling through to default handler")
341 }
342
343 if h.onAction != nil {
344 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
345 log.Error().
346 Err(err).
347 Str("action", string(actionToDispatch)).
348 Msg("global shortcut action failed")
349 }
350 }
351 return true
352}
353
354const (
355 globalShortcutActionName = "dumber-global-shortcut"
356 globalShortcutActionFullName = "win." + globalShortcutActionName
357)
358
359type globalShortcutRegistration struct {
360 action Action
361 binding KeyBinding
362 generation uint64
363}
364
365func encodeGlobalShortcutID(action Action, binding KeyBinding, generation uint64) string {
366 return fmt.Sprintf("%s\x1f%d\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers, generation)
367}
368
369func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
370 if h == nil {
371 return 0
372 }
373 callbacks := 0
374 if h.globalActionCb != nil {
375 callbacks++
376 }
377 if h.keyReleasedCb != nil {
378 callbacks++
379 }
380 return callbacks
381}
382
383// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
384type globalShortcutHoldKey struct {
385 keyval uint
386 keycode uint
387}
388
389type globalShortcutEventInfo struct {
390 hasCurrentEvent bool
391 eventType gdk.EventType
392 eventKeyval uint
393 eventKeycode uint
394 eventLayout uint
395 eventLevel uint
396 eventState gdk.ModifierType
397 eventConsumedModifiers gdk.ModifierType
398 eventTime uint32
399 hasDevice bool
400 deviceName string
401 deviceSource gdk.InputSource
402 controllerState gdk.ModifierType
403 controllerTime uint32
404}
405
406func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
407 var info globalShortcutEventInfo
408 if h == nil || h.controller == nil {
409 return info
410 }
411
412 info.controllerState = h.controller.GetCurrentEventState()
413 info.controllerTime = h.controller.GetCurrentEventTime()
414 if device := h.controller.GetCurrentEventDevice(); device != nil {
415 info.hasDevice = true
416 info.deviceName = device.GetName()
417 info.deviceSource = device.GetSource()
418 }
419
420 event := h.controller.GetCurrentEvent()
421 if event == nil {
422 return info
423 }
424 info.hasCurrentEvent = true
425 info.eventType = event.GetEventType()
426 info.eventState = event.GetModifierState()
427 info.eventTime = event.GetTime()
428 if !info.hasDevice {
429 if device := event.GetDevice(); device != nil {
430 info.hasDevice = true
431 info.deviceName = device.GetName()
432 info.deviceSource = device.GetSource()
433 }
434 }
435
436 switch info.eventType {
437 case gdk.KeyPressValue, gdk.KeyReleaseValue:
438 keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
439 info.eventKeyval = keyEvent.GetKeyval()
440 info.eventKeycode = keyEvent.GetKeycode()
441 info.eventLayout = keyEvent.GetLayout()
442 info.eventLevel = keyEvent.GetLevel()
443 info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
444 }
445
446 return info
447}
448
449func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
450 return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
451}
452
453func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
454 if evt == nil {
455 return nil
456 }
457 evt = evt.
458 Str("action", string(action)).
459 Str("shortcut", formatBinding(binding)).
460 Uint("shortcut_keyval", binding.Keyval).
461 Str("shortcut_key", formatKeyval(binding.Keyval)).
462 Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
463 Str("controller_modifiers", formatModifierMask(info.controllerState)).
464 Uint32("controller_event_time", info.controllerTime).
465 Bool("has_current_event", info.hasCurrentEvent)
466 if info.hasCurrentEvent {
467 evt = evt.
468 Str("event_type", formatEventType(info.eventType)).
469 Int("event_type_value", int(info.eventType)).
470 Str("event_modifiers", formatModifierMask(info.eventState)).
471 Uint32("event_time", info.eventTime)
472 if info.eventKeyval != 0 {
473 evt = evt.
474 Uint("event_keyval", info.eventKeyval).
475 Str("event_key", formatKeyval(info.eventKeyval))
476 }
477 if info.eventKeycode != 0 {
478 evt = evt.Uint("event_keycode", info.eventKeycode)
479 }
480 if info.eventLayout != 0 {
481 evt = evt.Uint("event_layout", info.eventLayout)
482 }
483 if info.eventLevel != 0 {
484 evt = evt.Uint("event_level", info.eventLevel)
485 }
486 if info.eventConsumedModifiers != 0 {
487 evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
488 }
489 }
490 if info.hasDevice {
491 evt = evt.
492 Str("event_device", info.deviceName).
493 Int("event_device_source", int(info.deviceSource))
494 }
495 return evt
496}
497
498func shouldRegisterGTKGlobalShortcut(action Action) bool {
499 return action != ActionEnterVimMode
500}
501
502func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
503 return mode == ModeVim && action != ActionEnterVimMode
504}
505
506func globalShortcutActionMap() map[string]Action {
507 return map[string]Action{
508 "toggle_floating_pane": ActionToggleFloatingPane,
509 "toggle-floating-pane": ActionToggleFloatingPane,
510 "consume_or_expel_left": ActionConsumeOrExpelLeft,
511 "consume-or-expel-left": ActionConsumeOrExpelLeft,
512 "consume_or_expel_right": ActionConsumeOrExpelRight,
513 "consume-or-expel-right": ActionConsumeOrExpelRight,
514 "consume_or_expel_up": ActionConsumeOrExpelUp,
515 "consume-or-expel-up": ActionConsumeOrExpelUp,
516 "consume_or_expel_down": ActionConsumeOrExpelDown,
517 "consume-or-expel-down": ActionConsumeOrExpelDown,
518 "toggle_history_systemview": ActionToggleHistorySystemView,
519 "toggle-history-systemview": ActionToggleHistorySystemView,
520 "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
521 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
522 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
523 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
524 "toggle_config_systemview": ActionToggleConfigSystemView,
525 "toggle-config-systemview": ActionToggleConfigSystemView,
526 }
527}
528
529// ReloadShortcuts rebuilds global shortcuts from a new config.
530// It replaces the GTK shortcut controller to ensure stale shortcuts
531// are removed.
532func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
533 log := logging.FromContext(ctx)
534 h.ctx = ctx
535 h.generation++
536
537 if h.window == nil || h.controller == nil {
538 return
539 }
540
541 // Remove old controller from window
542 h.window.RemoveController(&h.controller.EventController)
543
544 // Create fresh controller
545 h.controller = gtk.NewShortcutController()
546 if h.controller == nil {
547 log.Error().Msg("failed to create shortcut controller during reload")
548 return
549 }
550 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
551 h.registered = make(map[KeyBinding]Action)
552 h.shortcutRefs = make(map[string]globalShortcutRegistration)
553 h.lastDispatchAt = make(map[Action]time.Time)
554 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
555
556 h.registerDefaultGlobalShortcuts(log)
557
558 // Re-register config-driven shortcuts
559 if workspace != nil {
560 actionMap := globalShortcutActionMap()
561
562 for actionName, actionBinding := range workspace.Shortcuts.Actions {
563 action, ok := actionMap[actionName]
564 if !ok {
565 continue
566 }
567 for _, keyStr := range actionBinding.Keys {
568 binding, ok := ParseKeyString(keyStr)
569 if !ok {
570 continue
571 }
572 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
573 }
574 }
575
576 occupied := make(map[KeyBinding]Action, len(h.registered))
577 maps.Copy(occupied, h.registered)
578 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
579 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
580 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
581 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
582 }
583 }
584 }
585
586 shortcuts := NewShortcutSet(ctx, workspace, session)
587 for binding, action := range shortcuts.Global {
588 if !shouldRegisterGTKGlobalShortcut(action) {
589 continue
590 }
591 if _, exists := h.registered[binding]; exists {
592 continue
593 }
594 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
595 }
596 }
597
598 // Attach new controller to window
599 h.window.AddController(&h.controller.EventController)
600
601 log.Debug().
602 Int("shortcuts", len(h.registered)).
603 Msg("global shortcuts reloaded")
604}
605
606// Detach removes the global shortcut handler from a live GTK window.
607// Use DetachForDestroy when the window is already closing or being destroyed.
608func (h *GlobalShortcutHandler) Detach() {
609 h.detach(true)
610}
611
612// DetachForDestroy releases retained GTK callbacks during window teardown
613// without mutating the window's controller or action lists while GTK is
614// destroying them.
615func (h *GlobalShortcutHandler) DetachForDestroy() {
616 h.detach(false)
617}
618
619func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
620 h.generation++
621 if removeFromWindow && h.window != nil && h.controller != nil {
622 h.window.RemoveController(&h.controller.EventController)
623 }
624 if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
625 h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
626 }
627 if removeFromWindow && h.window != nil && h.releaseController != nil {
628 h.window.RemoveController(&h.releaseController.EventController)
629 }
630 if h.globalAction != nil && h.globalActionHandlerID != 0 {
631 h.globalAction.DisconnectSignal(h.globalActionHandlerID)
632 }
633 if removeFromWindow && h.window != nil && h.globalAction != nil {
634 h.window.RemoveAction(globalShortcutActionName)
635 }
636 h.controller = nil
637 h.releaseController = nil
638 h.globalAction = nil
639 h.globalActionCb = nil
640 h.globalActionHandlerID = 0
641 h.keyReleasedCb = nil
642 h.keyReleasedHandlerID = 0
643 h.registered = nil
644 h.shortcutRefs = nil
645 h.lastDispatchAt = nil
646 h.heldShortcuts = nil
647}
648
649const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
650
651func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
652 return h == nil || generation != h.generation
653}
654
655func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
656 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
657}
658
659func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
660 if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
661 return false
662 }
663 if h.heldShortcuts == nil {
664 // Defensive: if hold tracking is unavailable during detach/reload,
665 // suppress rather than risk dispatching a stale global callback.
666 return true
667 }
668 key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
669 if key.keyval == 0 && key.keycode == 0 {
670 return false
671 }
672 if _, ok := h.heldShortcuts[key]; ok {
673 return true
674 }
675 h.heldShortcuts[key] = struct{}{}
676 return false
677}
678
679func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
680 if h == nil || len(h.heldShortcuts) == 0 {
681 return
682 }
683 keyval = normalizeKeyval(keyval)
684 for key := range h.heldShortcuts {
685 if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
686 delete(h.heldShortcuts, key)
687 }
688 }
689}
690
691func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
692 if h == nil {
693 return false
694 }
695 if !isRepeatedGlobalShortcutSuppressed(action) {
696 if _, ok := ParseFloatingProfileTarget(action); !ok {
697 return false
698 }
699 }
700 if h.lastDispatchAt == nil {
701 // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
702 // rather than letting a stale GTK callback repeat destructive actions.
703 return true
704 }
705 if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
706 return true
707 }
708 h.lastDispatchAt[action] = now
709 return false
710}
711
712func isHeldGlobalShortcutSuppressed(action Action) bool {
713 if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
714 return true
715 }
716 _, ok := ParseFloatingProfileTarget(action)
717 return ok
718}
719
720func isRepeatedGlobalShortcutSuppressed(action Action) bool {
721 switch action {
722 case ActionGoBack,
723 ActionGoForward,
724 ActionZoomReset,
725 ActionReload,
726 ActionHardReload,
727 ActionPrintPage,
728 ActionOpenOmnibox,
729 ActionOpenFind,
730 ActionFindNext,
731 ActionFindPrev,
732 ActionOpenDevTools,
733 ActionToggleFullscreen,
734 ActionToggleFloatingPane,
735 ActionToggleHistorySystemView,
736 ActionToggleFavoritesSidebar,
737 ActionToggleCurrentPageFavorite,
738 ActionToggleConfigSystemView,
739 ActionCopyURL,
740 ActionConsumeOrExpelLeft,
741 ActionConsumeOrExpelRight,
742 ActionConsumeOrExpelUp,
743 ActionConsumeOrExpelDown,
744 ActionClosePane,
745 ActionCloseTab,
746 ActionQuit,
747 ActionOpenSessionManager,
748 ActionSwitchTabIndex1,
749 ActionSwitchTabIndex2,
750 ActionSwitchTabIndex3,
751 ActionSwitchTabIndex4,
752 ActionSwitchTabIndex5,
753 ActionSwitchTabIndex6,
754 ActionSwitchTabIndex7,
755 ActionSwitchTabIndex8,
756 ActionSwitchTabIndex9,
757 ActionSwitchTabIndex10,
758 ActionSwitchLastTab:
759 return true
760 default:
761 return false
762 }
763}
764
765func formatBinding(binding KeyBinding) string {
766 parts := make([]string, 0, 3)
767 if binding.Modifiers&ModCtrl != 0 {
768 parts = append(parts, "ctrl")
769 }
770 if binding.Modifiers&ModShift != 0 {
771 parts = append(parts, "shift")
772 }
773 if binding.Modifiers&ModAlt != 0 {
774 parts = append(parts, "alt")
775 }
776 parts = append(parts, formatKeyval(binding.Keyval))
777 return strings.Join(parts, "+")
778}
779
780func formatKeyval(keyval uint) string {
781 if keyval == 0 {
782 return ""
783 }
784 keyName := gdk.KeyvalName(keyval)
785 if keyName == "" {
786 return fmt.Sprintf("0x%x", keyval)
787 }
788 return strings.ToLower(keyName)
789}
790
791func formatModifierMask(modifiers gdk.ModifierType) string {
792 if modifiers == 0 {
793 return "none"
794 }
795 parts := make([]string, 0, 4)
796 if modifiers&gdk.ControlMaskValue != 0 {
797 parts = append(parts, "ctrl")
798 }
799 if modifiers&gdk.ShiftMaskValue != 0 {
800 parts = append(parts, "shift")
801 }
802 if modifiers&gdk.AltMaskValue != 0 {
803 parts = append(parts, "alt")
804 }
805 remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
806 if remaining != 0 {
807 parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
808 }
809 return strings.Join(parts, "+")
810}
811
812func formatEventType(eventType gdk.EventType) string {
813 switch eventType {
814 case gdk.KeyPressValue:
815 return "key-press"
816 case gdk.KeyReleaseValue:
817 return "key-release"
818 case gdk.ButtonPressValue:
819 return "button-press"
820 case gdk.ButtonReleaseValue:
821 return "button-release"
822 case gdk.MotionNotifyValue:
823 return "motion"
824 case gdk.ScrollValue:
825 return "scroll"
826 case gdk.TouchBeginValue:
827 return "touch-begin"
828 case gdk.TouchUpdateValue:
829 return "touch-update"
830 case gdk.TouchEndValue:
831 return "touch-end"
832 case gdk.FocusChangeValue:
833 return "focus-change"
834 case gdk.DeleteValue:
835 return "delete"
836 default:
837 return fmt.Sprintf("event-%d", int(eventType))
838 }
839}
840
841func isModeAction(action Action) bool {
842 switch action {
843 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode:
844 return true
845 default:
846 return false
847 }
848}