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