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 := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
85 action, ok := actionMap[actionName]
86 if !ok {
87 continue
88 }
89 for _, keyStr := range workspace.Shortcuts.Actions[actionName].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
200// registerShortcut creates and registers a single shortcut with the controller.
201func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
202 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
203 if existing, exists := h.registered[binding]; exists {
204 logging.FromContext(h.ctx).Warn().
205 Str("existing_action", string(existing)).
206 Str("new_action", string(action)).
207 Str("shortcut", formatBinding(binding)).
208 Msg("global shortcut conflict, skipping")
209 return false
210 }
211
212 // Create trigger for this key combination.
213 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
214 if trigger == nil {
215 logging.FromContext(h.ctx).Error().
216 Uint("keyval", keyval).
217 Msg("failed to create keyval trigger")
218 return false
219 }
220
221 // GtkShortcut takes ownership of its action. Each shortcut needs its own
222 // named action even though they all dispatch through the same window action.
223 shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
224 if shortcutAction == nil {
225 logging.FromContext(h.ctx).Error().
226 Uint("keyval", keyval).
227 Msg("failed to create global shortcut action")
228 return false
229 }
230
231 shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
232 if shortcut == nil {
233 logging.FromContext(h.ctx).Error().
234 Uint("keyval", keyval).
235 Msg("failed to create shortcut")
236 return false
237 }
238
239 generation := h.generation
240 shortcutID := encodeGlobalShortcutID(action, binding, generation)
241 shortcut.SetArguments(glib.NewVariantString(shortcutID))
242
243 // Add to controller.
244 h.controller.AddShortcut(shortcut)
245 h.registered[binding] = action
246 h.shortcutRefs[shortcutID] = globalShortcutRegistration{
247 action: action,
248 binding: binding,
249 generation: generation,
250 }
251 return true
252}
253
254func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
255 log := logging.FromContext(h.ctx)
256 registration, ok := h.globalShortcutRegistration(parameter)
257 if !ok {
258 log.Warn().Msg("global shortcut activated without registered argument")
259 return
260 }
261 h.dispatchGlobalShortcut(registration.action, registration.binding, registration.generation)
262}
263
264func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
265 if parameter == 0 || h.shortcutRefs == nil {
266 return globalShortcutRegistration{}, false
267 }
268 variant := glib.VariantNewFromInternalPtr(parameter)
269 if variant == nil {
270 return globalShortcutRegistration{}, false
271 }
272 shortcutID := variant.GetString(nil)
273 registration, ok := h.shortcutRefs[shortcutID]
274 return registration, ok
275}
276
277func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
278 log := logging.FromContext(h.ctx)
279 if h.isStaleGeneration(generation) {
280 log.Trace().
281 Str("action", string(actionToDispatch)).
282 Str("shortcut", formatBinding(bindingForLog)).
283 Uint64("callback_generation", generation).
284 Uint64("current_generation", h.generation).
285 Msg("stale global shortcut callback ignored")
286 return true
287 }
288 if !h.isActiveWindowShortcutHandler() {
289 log.Trace().
290 Str("action", string(actionToDispatch)).
291 Str("shortcut", formatBinding(bindingForLog)).
292 Msg("inactive window global shortcut callback ignored")
293 return false
294 }
295 if h.kbHandler != nil && shouldIgnoreGlobalShortcutInMode(h.kbHandler.Mode(), actionToDispatch) {
296 log.Trace().
297 Str("action", string(actionToDispatch)).
298 Str("shortcut", formatBinding(bindingForLog)).
299 Str("mode", h.kbHandler.Mode().String()).
300 Msg("global shortcut ignored in current modal mode")
301 return true
302 }
303 eventInfo := h.inspectCurrentShortcutEvent()
304 if !shouldDispatchGlobalShortcutEvent(eventInfo) {
305 appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
306 Msg("global shortcut ignored without current key event")
307 return true
308 }
309 if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
310 log.Trace().
311 Str("action", string(actionToDispatch)).
312 Str("shortcut", formatBinding(bindingForLog)).
313 Msg("held global shortcut repeat suppressed")
314 return true
315 }
316 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
317 log.Trace().
318 Str("action", string(actionToDispatch)).
319 Str("shortcut", formatBinding(bindingForLog)).
320 Msg("global shortcut repeat suppressed")
321 return true
322 }
323 appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
324 Msg("global shortcut triggered")
325
326 // Mode-enter/exit actions go through KeyboardHandler for modal state.
327 if isModeAction(actionToDispatch) {
328 if h.kbHandler != nil {
329 return h.kbHandler.DispatchAction(actionToDispatch)
330 }
331 log.Warn().
332 Str("action", string(actionToDispatch)).
333 Msg("mode action triggered but keyboard handler not set, falling through to default handler")
334 }
335
336 // After the tmux prefix, dispatch through KeyboardHandler so the
337 // one-shot exit follows the same order as keys it handles directly.
338 if h.kbHandler != nil && h.kbHandler.Mode() == ModePrefix {
339 return h.kbHandler.DispatchAction(actionToDispatch)
340 }
341
342 if h.onAction != nil {
343 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
344 log.Error().
345 Err(err).
346 Str("action", string(actionToDispatch)).
347 Msg("global shortcut action failed")
348 }
349 }
350 return true
351}
352
353const (
354 globalShortcutActionName = "dumber-global-shortcut"
355 globalShortcutActionFullName = "win." + globalShortcutActionName
356)
357
358type globalShortcutRegistration struct {
359 action Action
360 binding KeyBinding
361 generation uint64
362}
363
364func encodeGlobalShortcutID(action Action, binding KeyBinding, generation uint64) string {
365 return fmt.Sprintf("%s\x1f%d\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers, generation)
366}
367
368func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
369 if h == nil {
370 return 0
371 }
372 callbacks := 0
373 if h.globalActionCb != nil {
374 callbacks++
375 }
376 if h.keyReleasedCb != nil {
377 callbacks++
378 }
379 return callbacks
380}
381
382// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
383type globalShortcutHoldKey struct {
384 keyval uint
385 keycode uint
386}
387
388type globalShortcutEventInfo struct {
389 hasCurrentEvent bool
390 eventType gdk.EventType
391 eventKeyval uint
392 eventKeycode uint
393 eventLayout uint
394 eventLevel uint
395 eventState gdk.ModifierType
396 eventConsumedModifiers gdk.ModifierType
397 eventTime uint32
398 hasDevice bool
399 deviceName string
400 deviceSource gdk.InputSource
401 controllerState gdk.ModifierType
402 controllerTime uint32
403}
404
405func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
406 var info globalShortcutEventInfo
407 if h == nil || h.controller == nil {
408 return info
409 }
410
411 info.controllerState = h.controller.GetCurrentEventState()
412 info.controllerTime = h.controller.GetCurrentEventTime()
413 if device := h.controller.GetCurrentEventDevice(); device != nil {
414 info.hasDevice = true
415 info.deviceName = device.GetName()
416 info.deviceSource = device.GetSource()
417 }
418
419 event := h.controller.GetCurrentEvent()
420 if event == nil {
421 return info
422 }
423 info.hasCurrentEvent = true
424 info.eventType = event.GetEventType()
425 info.eventState = event.GetModifierState()
426 info.eventTime = event.GetTime()
427 if !info.hasDevice {
428 if device := event.GetDevice(); device != nil {
429 info.hasDevice = true
430 info.deviceName = device.GetName()
431 info.deviceSource = device.GetSource()
432 }
433 }
434
435 switch info.eventType {
436 case gdk.KeyPressValue, gdk.KeyReleaseValue:
437 keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
438 info.eventKeyval = keyEvent.GetKeyval()
439 info.eventKeycode = keyEvent.GetKeycode()
440 info.eventLayout = keyEvent.GetLayout()
441 info.eventLevel = keyEvent.GetLevel()
442 info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
443 }
444
445 return info
446}
447
448func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
449 return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
450}
451
452func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
453 if evt == nil {
454 return nil
455 }
456 evt = evt.
457 Str("action", string(action)).
458 Str("shortcut", formatBinding(binding)).
459 Uint("shortcut_keyval", binding.Keyval).
460 Str("shortcut_key", formatKeyval(binding.Keyval)).
461 Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
462 Str("controller_modifiers", formatModifierMask(info.controllerState)).
463 Uint32("controller_event_time", info.controllerTime).
464 Bool("has_current_event", info.hasCurrentEvent)
465 if info.hasCurrentEvent {
466 evt = evt.
467 Str("event_type", formatEventType(info.eventType)).
468 Int("event_type_value", int(info.eventType)).
469 Str("event_modifiers", formatModifierMask(info.eventState)).
470 Uint32("event_time", info.eventTime)
471 if info.eventKeyval != 0 {
472 evt = evt.
473 Uint("event_keyval", info.eventKeyval).
474 Str("event_key", formatKeyval(info.eventKeyval))
475 }
476 if info.eventKeycode != 0 {
477 evt = evt.Uint("event_keycode", info.eventKeycode)
478 }
479 if info.eventLayout != 0 {
480 evt = evt.Uint("event_layout", info.eventLayout)
481 }
482 if info.eventLevel != 0 {
483 evt = evt.Uint("event_level", info.eventLevel)
484 }
485 if info.eventConsumedModifiers != 0 {
486 evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
487 }
488 }
489 if info.hasDevice {
490 evt = evt.
491 Str("event_device", info.deviceName).
492 Int("event_device_source", int(info.deviceSource))
493 }
494 return evt
495}
496
497func shouldRegisterGTKGlobalShortcut(action Action) bool {
498 return action != ActionEnterVimMode
499}
500
501func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
502 return mode == ModeVim && action != ActionEnterVimMode
503}
504
505func globalShortcutActionMap() map[string]Action {
506 return map[string]Action{
507 "toggle_floating_pane": ActionToggleFloatingPane,
508 "toggle-floating-pane": ActionToggleFloatingPane,
509 "consume_or_expel_left": ActionConsumeOrExpelLeft,
510 "consume-or-expel-left": ActionConsumeOrExpelLeft,
511 "consume_or_expel_right": ActionConsumeOrExpelRight,
512 "consume-or-expel-right": ActionConsumeOrExpelRight,
513 "consume_or_expel_up": ActionConsumeOrExpelUp,
514 "consume-or-expel-up": ActionConsumeOrExpelUp,
515 "consume_or_expel_down": ActionConsumeOrExpelDown,
516 "consume-or-expel-down": ActionConsumeOrExpelDown,
517 "toggle_history_systemview": ActionToggleHistorySystemView,
518 "toggle-history-systemview": ActionToggleHistorySystemView,
519 "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
520 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
521 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523 "toggle_config_systemview": ActionToggleConfigSystemView,
524 "toggle-config-systemview": ActionToggleConfigSystemView,
525 }
526}
527
528// ReloadShortcuts rebuilds global shortcuts from a new config.
529// It replaces the GTK shortcut controller to ensure stale shortcuts
530// are removed.
531func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
532 log := logging.FromContext(ctx)
533 h.ctx = ctx
534 h.generation++
535
536 if h.window == nil || h.controller == nil {
537 return
538 }
539
540 // Remove old controller from window
541 h.window.RemoveController(&h.controller.EventController)
542
543 // Create fresh controller
544 h.controller = gtk.NewShortcutController()
545 if h.controller == nil {
546 log.Error().Msg("failed to create shortcut controller during reload")
547 return
548 }
549 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
550 h.registered = make(map[KeyBinding]Action)
551 h.shortcutRefs = make(map[string]globalShortcutRegistration)
552 h.lastDispatchAt = make(map[Action]time.Time)
553 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
554
555 h.registerDefaultGlobalShortcuts(log)
556
557 // Re-register config-driven shortcuts
558 if workspace != nil {
559 actionMap := globalShortcutActionMap()
560
561 for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
562 action, ok := actionMap[actionName]
563 if !ok {
564 continue
565 }
566 for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
567 binding, ok := ParseKeyString(keyStr)
568 if !ok {
569 continue
570 }
571 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
572 }
573 }
574
575 occupied := make(map[KeyBinding]Action, len(h.registered))
576 maps.Copy(occupied, h.registered)
577 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
578 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
579 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
580 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
581 }
582 }
583 }
584
585 shortcuts := NewShortcutSet(ctx, workspace, session)
586 for binding, action := range shortcuts.Global {
587 if !shouldRegisterGTKGlobalShortcut(action) {
588 continue
589 }
590 if _, exists := h.registered[binding]; exists {
591 continue
592 }
593 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
594 }
595 }
596
597 // Attach new controller to window
598 h.window.AddController(&h.controller.EventController)
599
600 log.Debug().
601 Int("shortcuts", len(h.registered)).
602 Msg("global shortcuts reloaded")
603}
604
605// Detach removes the global shortcut handler from a live GTK window.
606// Use DetachForDestroy when the window is already closing or being destroyed.
607func (h *GlobalShortcutHandler) Detach() {
608 h.detach(true)
609}
610
611// DetachForDestroy releases retained GTK callbacks during window teardown
612// without mutating the window's controller or action lists while GTK is
613// destroying them.
614func (h *GlobalShortcutHandler) DetachForDestroy() {
615 h.detach(false)
616}
617
618func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
619 h.generation++
620 if removeFromWindow && h.window != nil && h.controller != nil {
621 h.window.RemoveController(&h.controller.EventController)
622 }
623 if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
624 h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
625 }
626 if removeFromWindow && h.window != nil && h.releaseController != nil {
627 h.window.RemoveController(&h.releaseController.EventController)
628 }
629 if h.globalAction != nil && h.globalActionHandlerID != 0 {
630 h.globalAction.DisconnectSignal(h.globalActionHandlerID)
631 }
632 if removeFromWindow && h.window != nil && h.globalAction != nil {
633 h.window.RemoveAction(globalShortcutActionName)
634 }
635 h.controller = nil
636 h.releaseController = nil
637 h.globalAction = nil
638 h.globalActionCb = nil
639 h.globalActionHandlerID = 0
640 h.keyReleasedCb = nil
641 h.keyReleasedHandlerID = 0
642 h.registered = nil
643 h.shortcutRefs = nil
644 h.lastDispatchAt = nil
645 h.heldShortcuts = nil
646}
647
648const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
649
650func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
651 return h == nil || generation != h.generation
652}
653
654func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
655 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
656}
657
658func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
659 if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
660 return false
661 }
662 if h.heldShortcuts == nil {
663 // Defensive: if hold tracking is unavailable during detach/reload,
664 // suppress rather than risk dispatching a stale global callback.
665 return true
666 }
667 key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
668 if key.keyval == 0 && key.keycode == 0 {
669 return false
670 }
671 if _, ok := h.heldShortcuts[key]; ok {
672 return true
673 }
674 h.heldShortcuts[key] = struct{}{}
675 return false
676}
677
678func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
679 if h == nil || len(h.heldShortcuts) == 0 {
680 return
681 }
682 keyval = normalizeKeyval(keyval)
683 for key := range h.heldShortcuts {
684 if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
685 delete(h.heldShortcuts, key)
686 }
687 }
688}
689
690func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
691 if h == nil {
692 return false
693 }
694 if !isRepeatedGlobalShortcutSuppressed(action) {
695 if _, ok := ParseFloatingProfileTarget(action); !ok {
696 return false
697 }
698 }
699 if h.lastDispatchAt == nil {
700 // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
701 // rather than letting a stale GTK callback repeat destructive actions.
702 return true
703 }
704 if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
705 return true
706 }
707 h.lastDispatchAt[action] = now
708 return false
709}
710
711func isHeldGlobalShortcutSuppressed(action Action) bool {
712 if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
713 return true
714 }
715 _, ok := ParseFloatingProfileTarget(action)
716 return ok
717}
718
719func isRepeatedGlobalShortcutSuppressed(action Action) bool {
720 switch action {
721 case ActionGoBack,
722 ActionGoForward,
723 ActionZoomReset,
724 ActionReload,
725 ActionHardReload,
726 ActionPrintPage,
727 ActionOpenOmnibox,
728 ActionOpenFind,
729 ActionFindNext,
730 ActionFindPrev,
731 ActionOpenDevTools,
732 ActionToggleFullscreen,
733 ActionToggleFloatingPane,
734 ActionToggleHistorySystemView,
735 ActionToggleFavoritesSidebar,
736 ActionToggleCurrentPageFavorite,
737 ActionToggleConfigSystemView,
738 ActionCopyURL,
739 ActionConsumeOrExpelLeft,
740 ActionConsumeOrExpelRight,
741 ActionConsumeOrExpelUp,
742 ActionConsumeOrExpelDown,
743 ActionClosePane,
744 ActionCloseTab,
745 ActionQuit,
746 ActionOpenSessionManager,
747 ActionSwitchTabIndex1,
748 ActionSwitchTabIndex2,
749 ActionSwitchTabIndex3,
750 ActionSwitchTabIndex4,
751 ActionSwitchTabIndex5,
752 ActionSwitchTabIndex6,
753 ActionSwitchTabIndex7,
754 ActionSwitchTabIndex8,
755 ActionSwitchTabIndex9,
756 ActionSwitchTabIndex10,
757 ActionSwitchLastTab:
758 return true
759 default:
760 return false
761 }
762}
763
764func formatBinding(binding KeyBinding) string {
765 parts := make([]string, 0, 3)
766 if binding.Modifiers&ModCtrl != 0 {
767 parts = append(parts, "ctrl")
768 }
769 if binding.Modifiers&ModShift != 0 {
770 parts = append(parts, "shift")
771 }
772 if binding.Modifiers&ModAlt != 0 {
773 parts = append(parts, "alt")
774 }
775 parts = append(parts, formatKeyval(binding.Keyval))
776 return strings.Join(parts, "+")
777}
778
779func formatKeyval(keyval uint) string {
780 if keyval == 0 {
781 return ""
782 }
783 keyName := gdk.KeyvalName(keyval)
784 if keyName == "" {
785 return fmt.Sprintf("0x%x", keyval)
786 }
787 return strings.ToLower(keyName)
788}
789
790func formatModifierMask(modifiers gdk.ModifierType) string {
791 if modifiers == 0 {
792 return "none"
793 }
794 parts := make([]string, 0, 4)
795 if modifiers&gdk.ControlMaskValue != 0 {
796 parts = append(parts, "ctrl")
797 }
798 if modifiers&gdk.ShiftMaskValue != 0 {
799 parts = append(parts, "shift")
800 }
801 if modifiers&gdk.AltMaskValue != 0 {
802 parts = append(parts, "alt")
803 }
804 remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
805 if remaining != 0 {
806 parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
807 }
808 return strings.Join(parts, "+")
809}
810
811func formatEventType(eventType gdk.EventType) string {
812 switch eventType {
813 case gdk.KeyPressValue:
814 return "key-press"
815 case gdk.KeyReleaseValue:
816 return "key-release"
817 case gdk.ButtonPressValue:
818 return "button-press"
819 case gdk.ButtonReleaseValue:
820 return "button-release"
821 case gdk.MotionNotifyValue:
822 return "motion"
823 case gdk.ScrollValue:
824 return "scroll"
825 case gdk.TouchBeginValue:
826 return "touch-begin"
827 case gdk.TouchUpdateValue:
828 return "touch-update"
829 case gdk.TouchEndValue:
830 return "touch-end"
831 case gdk.FocusChangeValue:
832 return "focus-change"
833 case gdk.DeleteValue:
834 return "delete"
835 default:
836 return fmt.Sprintf("event-%d", int(eventType))
837 }
838}
839
840func isModeAction(action Action) bool {
841 switch action {
842 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode,
843 ActionEnterPrefixMode, ActionExitMode:
844 return true
845 default:
846 return false
847 }
848}