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 "consume-or-expel-left": ActionConsumeOrExpelLeft,
509 "consume-or-expel-right": ActionConsumeOrExpelRight,
510 "consume-or-expel-up": ActionConsumeOrExpelUp,
511 "consume-or-expel-down": ActionConsumeOrExpelDown,
512 "toggle-history-systemview": ActionToggleHistorySystemView,
513 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
514 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
515 "toggle-config-systemview": ActionToggleConfigSystemView,
516 }
517}
518
519// ReloadShortcuts rebuilds global shortcuts from a new config.
520// It replaces the GTK shortcut controller to ensure stale shortcuts
521// are removed.
522func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
523 log := logging.FromContext(ctx)
524 h.ctx = ctx
525 h.generation++
526
527 if h.window == nil || h.controller == nil {
528 return
529 }
530
531 // Remove old controller from window
532 h.window.RemoveController(&h.controller.EventController)
533
534 // Create fresh controller
535 h.controller = gtk.NewShortcutController()
536 if h.controller == nil {
537 log.Error().Msg("failed to create shortcut controller during reload")
538 return
539 }
540 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
541 h.registered = make(map[KeyBinding]Action)
542 h.shortcutRefs = make(map[string]globalShortcutRegistration)
543 h.lastDispatchAt = make(map[Action]time.Time)
544 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
545
546 h.registerDefaultGlobalShortcuts(log)
547
548 // Re-register config-driven shortcuts
549 if workspace != nil {
550 actionMap := globalShortcutActionMap()
551
552 for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
553 action, ok := actionMap[actionName]
554 if !ok {
555 continue
556 }
557 for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
558 binding, ok := ParseKeyString(keyStr)
559 if !ok {
560 continue
561 }
562 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
563 }
564 }
565
566 occupied := make(map[KeyBinding]Action, len(h.registered))
567 maps.Copy(occupied, h.registered)
568 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
569 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
570 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
571 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
572 }
573 }
574 }
575
576 shortcuts := NewShortcutSet(ctx, workspace, session)
577 for binding, action := range shortcuts.Global {
578 if !shouldRegisterGTKGlobalShortcut(action) {
579 continue
580 }
581 if _, exists := h.registered[binding]; exists {
582 continue
583 }
584 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
585 }
586 }
587
588 // Attach new controller to window
589 h.window.AddController(&h.controller.EventController)
590
591 log.Debug().
592 Int("shortcuts", len(h.registered)).
593 Msg("global shortcuts reloaded")
594}
595
596// Detach removes the global shortcut handler from a live GTK window.
597// Use DetachForDestroy when the window is already closing or being destroyed.
598func (h *GlobalShortcutHandler) Detach() {
599 h.detach(true)
600}
601
602// DetachForDestroy releases retained GTK callbacks during window teardown
603// without mutating the window's controller or action lists while GTK is
604// destroying them.
605func (h *GlobalShortcutHandler) DetachForDestroy() {
606 h.detach(false)
607}
608
609func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
610 h.generation++
611 if removeFromWindow && h.window != nil && h.controller != nil {
612 h.window.RemoveController(&h.controller.EventController)
613 }
614 if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
615 h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
616 }
617 if removeFromWindow && h.window != nil && h.releaseController != nil {
618 h.window.RemoveController(&h.releaseController.EventController)
619 }
620 if h.globalAction != nil && h.globalActionHandlerID != 0 {
621 h.globalAction.DisconnectSignal(h.globalActionHandlerID)
622 }
623 if removeFromWindow && h.window != nil && h.globalAction != nil {
624 h.window.RemoveAction(globalShortcutActionName)
625 }
626 h.controller = nil
627 h.releaseController = nil
628 h.globalAction = nil
629 h.globalActionCb = nil
630 h.globalActionHandlerID = 0
631 h.keyReleasedCb = nil
632 h.keyReleasedHandlerID = 0
633 h.registered = nil
634 h.shortcutRefs = nil
635 h.lastDispatchAt = nil
636 h.heldShortcuts = nil
637}
638
639const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
640
641func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
642 return h == nil || generation != h.generation
643}
644
645func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
646 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
647}
648
649func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
650 if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
651 return false
652 }
653 if h.heldShortcuts == nil {
654 // Defensive: if hold tracking is unavailable during detach/reload,
655 // suppress rather than risk dispatching a stale global callback.
656 return true
657 }
658 key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
659 if key.keyval == 0 && key.keycode == 0 {
660 return false
661 }
662 if _, ok := h.heldShortcuts[key]; ok {
663 return true
664 }
665 h.heldShortcuts[key] = struct{}{}
666 return false
667}
668
669func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
670 if h == nil || len(h.heldShortcuts) == 0 {
671 return
672 }
673 keyval = normalizeKeyval(keyval)
674 for key := range h.heldShortcuts {
675 if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
676 delete(h.heldShortcuts, key)
677 }
678 }
679}
680
681func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
682 if h == nil {
683 return false
684 }
685 if !isRepeatedGlobalShortcutSuppressed(action) {
686 if _, ok := ParseFloatingProfileTarget(action); !ok {
687 return false
688 }
689 }
690 if h.lastDispatchAt == nil {
691 // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
692 // rather than letting a stale GTK callback repeat destructive actions.
693 return true
694 }
695 if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
696 return true
697 }
698 h.lastDispatchAt[action] = now
699 return false
700}
701
702func isHeldGlobalShortcutSuppressed(action Action) bool {
703 if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
704 return true
705 }
706 _, ok := ParseFloatingProfileTarget(action)
707 return ok
708}
709
710func isRepeatedGlobalShortcutSuppressed(action Action) bool {
711 switch action {
712 case ActionGoBack,
713 ActionGoForward,
714 ActionZoomReset,
715 ActionReload,
716 ActionHardReload,
717 ActionPrintPage,
718 ActionOpenOmnibox,
719 ActionOpenFind,
720 ActionFindNext,
721 ActionFindPrev,
722 ActionOpenDevTools,
723 ActionToggleFullscreen,
724 ActionToggleFloatingPane,
725 ActionToggleHistorySystemView,
726 ActionToggleFavoritesSidebar,
727 ActionToggleCurrentPageFavorite,
728 ActionToggleConfigSystemView,
729 ActionCopyURL,
730 ActionConsumeOrExpelLeft,
731 ActionConsumeOrExpelRight,
732 ActionConsumeOrExpelUp,
733 ActionConsumeOrExpelDown,
734 ActionClosePane,
735 ActionCloseTab,
736 ActionQuit,
737 ActionOpenSessionManager,
738 ActionSwitchTabIndex1,
739 ActionSwitchTabIndex2,
740 ActionSwitchTabIndex3,
741 ActionSwitchTabIndex4,
742 ActionSwitchTabIndex5,
743 ActionSwitchTabIndex6,
744 ActionSwitchTabIndex7,
745 ActionSwitchTabIndex8,
746 ActionSwitchTabIndex9,
747 ActionSwitchTabIndex10,
748 ActionSwitchLastTab:
749 return true
750 default:
751 return false
752 }
753}
754
755func formatBinding(binding KeyBinding) string {
756 parts := make([]string, 0, 3)
757 if binding.Modifiers&ModCtrl != 0 {
758 parts = append(parts, "ctrl")
759 }
760 if binding.Modifiers&ModShift != 0 {
761 parts = append(parts, "shift")
762 }
763 if binding.Modifiers&ModAlt != 0 {
764 parts = append(parts, "alt")
765 }
766 parts = append(parts, formatKeyval(binding.Keyval))
767 return strings.Join(parts, "+")
768}
769
770func formatKeyval(keyval uint) string {
771 if keyval == 0 {
772 return ""
773 }
774 keyName := gdk.KeyvalName(keyval)
775 if keyName == "" {
776 return fmt.Sprintf("0x%x", keyval)
777 }
778 return strings.ToLower(keyName)
779}
780
781func formatModifierMask(modifiers gdk.ModifierType) string {
782 if modifiers == 0 {
783 return "none"
784 }
785 parts := make([]string, 0, 4)
786 if modifiers&gdk.ControlMaskValue != 0 {
787 parts = append(parts, "ctrl")
788 }
789 if modifiers&gdk.ShiftMaskValue != 0 {
790 parts = append(parts, "shift")
791 }
792 if modifiers&gdk.AltMaskValue != 0 {
793 parts = append(parts, "alt")
794 }
795 remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
796 if remaining != 0 {
797 parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
798 }
799 return strings.Join(parts, "+")
800}
801
802func formatEventType(eventType gdk.EventType) string {
803 switch eventType {
804 case gdk.KeyPressValue:
805 return "key-press"
806 case gdk.KeyReleaseValue:
807 return "key-release"
808 case gdk.ButtonPressValue:
809 return "button-press"
810 case gdk.ButtonReleaseValue:
811 return "button-release"
812 case gdk.MotionNotifyValue:
813 return "motion"
814 case gdk.ScrollValue:
815 return "scroll"
816 case gdk.TouchBeginValue:
817 return "touch-begin"
818 case gdk.TouchUpdateValue:
819 return "touch-update"
820 case gdk.TouchEndValue:
821 return "touch-end"
822 case gdk.FocusChangeValue:
823 return "focus-change"
824 case gdk.DeleteValue:
825 return "delete"
826 default:
827 return fmt.Sprintf("event-%d", int(eventType))
828 }
829}
830
831func isModeAction(action Action) bool {
832 switch action {
833 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode,
834 ActionEnterPrefixMode, ActionExitMode:
835 return true
836 default:
837 return false
838 }
839}