Throttle one-shot global shortcuts
- commit
- bb2fe9ced071332d595203a382fbe713aa841e97
- author
- brice · · 2026-04-30 03:56 UTC
- parent
- 40b8fb0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +89 -13
M internal/ui/input/global_shortcuts.go +49 -13
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| @@ -17,12 +18,13 @@ import ( | |||
| 17 | 18 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | |
| 18 | 19 | // to intercept shortcuts before they reach the WebView. | |
| 19 | 20 | type GlobalShortcutHandler struct { | |
| 20 | - | controller *gtk.ShortcutController | |
| 21 | - | window *gtk.ApplicationWindow | |
| 22 | - | kbHandler *KeyboardHandler | |
| 23 | - | onAction ActionHandler | |
| 24 | - | ctx context.Context | |
| 25 | - | registered map[KeyBinding]Action | |
| 21 | + | controller *gtk.ShortcutController | |
| 22 | + | window *gtk.ApplicationWindow | |
| 23 | + | kbHandler *KeyboardHandler | |
| 24 | + | onAction ActionHandler | |
| 25 | + | ctx context.Context | |
| 26 | + | registered map[KeyBinding]Action | |
| 27 | + | lastDispatchAt map[Action]time.Time | |
| 26 | 28 | ||
| 27 | 29 | // Keep references to callbacks to prevent GC from collecting them | |
| 28 | 30 | callbacks []gtk.ShortcutFunc | |
| @@ -42,13 +44,14 @@ func NewGlobalShortcutHandler( | |||
| 42 | 44 | log.Debug().Msg("creating global shortcut handler") | |
| 43 | 45 | ||
| 44 | 46 | h := &GlobalShortcutHandler{ | |
| 45 | - | controller: gtk.NewShortcutController(), | |
| 46 | - | window: window, | |
| 47 | - | kbHandler: kbHandler, | |
| 48 | - | onAction: onAction, | |
| 49 | - | ctx: ctx, | |
| 50 | - | callbacks: make([]gtk.ShortcutFunc, 0), | |
| 51 | - | registered: make(map[KeyBinding]Action), | |
| 47 | + | controller: gtk.NewShortcutController(), | |
| 48 | + | window: window, | |
| 49 | + | kbHandler: kbHandler, | |
| 50 | + | onAction: onAction, | |
| 51 | + | ctx: ctx, | |
| 52 | + | callbacks: make([]gtk.ShortcutFunc, 0), | |
| 53 | + | registered: make(map[KeyBinding]Action), | |
| 54 | + | lastDispatchAt: make(map[Action]time.Time), | |
| 52 | 55 | } | |
| 53 | 56 | ||
| 54 | 57 | if h.controller == nil { | |
| @@ -186,6 +189,12 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 186 | 189 | actionToDispatch := action | |
| 187 | 190 | callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool { | |
| 188 | 191 | log := logging.FromContext(h.ctx) | |
| 192 | + | if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) { | |
| 193 | + | log.Trace(). | |
| 194 | + | Str("action", string(actionToDispatch)). | |
| 195 | + | Msg("global shortcut repeat suppressed") | |
| 196 | + | return true | |
| 197 | + | } | |
| 189 | 198 | log.Debug(). | |
| 190 | 199 | Str("action", string(actionToDispatch)). | |
| 191 | 200 | Msg("global shortcut triggered") | |
| @@ -283,6 +292,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 283 | 292 | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | |
| 284 | 293 | h.callbacks = make([]gtk.ShortcutFunc, 0) | |
| 285 | 294 | h.registered = make(map[KeyBinding]Action) | |
| 295 | + | h.lastDispatchAt = make(map[Action]time.Time) | |
| 286 | 296 | ||
| 287 | 297 | // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S) | |
| 288 | 298 | tabActions := []Action{ | |
| @@ -351,6 +361,32 @@ func (h *GlobalShortcutHandler) Detach() { | |||
| 351 | 361 | h.controller = nil | |
| 352 | 362 | h.callbacks = nil | |
| 353 | 363 | h.registered = nil | |
| 364 | + | h.lastDispatchAt = nil | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond | |
| 368 | + | ||
| 369 | + | func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool { | |
| 370 | + | if h == nil || !isRepeatedGlobalShortcutSuppressed(action) { | |
| 371 | + | return false | |
| 372 | + | } | |
| 373 | + | if h.lastDispatchAt == nil { | |
| 374 | + | return true | |
| 375 | + | } | |
| 376 | + | if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow { | |
| 377 | + | return true | |
| 378 | + | } | |
| 379 | + | h.lastDispatchAt[action] = now | |
| 380 | + | return false | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |
| 384 | + | switch action { | |
| 385 | + | case ActionZoomReset, ActionClosePane, ActionCloseTab, ActionQuit, ActionOpenSessionManager: | |
| 386 | + | return true | |
| 387 | + | default: | |
| 388 | + | return false | |
| 389 | + | } | |
| 354 | 390 | } | |
| 355 | 391 | ||
| 356 | 392 | func formatBinding(binding KeyBinding) string { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -17,12 +18,13 @@ import ( | |||||
| 17 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | 18 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | ||
| 18 | // to intercept shortcuts before they reach the WebView. | 19 | // to intercept shortcuts before they reach the WebView. | ||
| 19 | type GlobalShortcutHandler struct { | 20 | type GlobalShortcutHandler struct { | ||
| 20 | - | controller *gtk.ShortcutController | 21 | + | controller *gtk.ShortcutController |
| 21 | - | window *gtk.ApplicationWindow | 22 | + | window *gtk.ApplicationWindow |
| 22 | - | kbHandler *KeyboardHandler | 23 | + | kbHandler *KeyboardHandler |
| 23 | - | onAction ActionHandler | 24 | + | onAction ActionHandler |
| 24 | - | ctx context.Context | 25 | + | ctx context.Context |
| 25 | - | registered map[KeyBinding]Action | 26 | + | registered map[KeyBinding]Action |
| 27 | + | lastDispatchAt map[Action]time.Time | |||
| 26 | 28 | ||||
| 27 | // Keep references to callbacks to prevent GC from collecting them | 29 | // Keep references to callbacks to prevent GC from collecting them | ||
| 28 | callbacks []gtk.ShortcutFunc | 30 | callbacks []gtk.ShortcutFunc | ||
| @@ -42,13 +44,14 @@ func NewGlobalShortcutHandler( | |||||
| 42 | log.Debug().Msg("creating global shortcut handler") | 44 | log.Debug().Msg("creating global shortcut handler") | ||
| 43 | 45 | ||||
| 44 | h := &GlobalShortcutHandler{ | 46 | h := &GlobalShortcutHandler{ | ||
| 45 | - | controller: gtk.NewShortcutController(), | 47 | + | controller: gtk.NewShortcutController(), |
| 46 | - | window: window, | 48 | + | window: window, |
| 47 | - | kbHandler: kbHandler, | 49 | + | kbHandler: kbHandler, |
| 48 | - | onAction: onAction, | 50 | + | onAction: onAction, |
| 49 | - | ctx: ctx, | 51 | + | ctx: ctx, |
| 50 | - | callbacks: make([]gtk.ShortcutFunc, 0), | 52 | + | callbacks: make([]gtk.ShortcutFunc, 0), |
| 51 | - | registered: make(map[KeyBinding]Action), | 53 | + | registered: make(map[KeyBinding]Action), |
| 54 | + | lastDispatchAt: make(map[Action]time.Time), | |||
| 52 | } | 55 | } | ||
| 53 | 56 | ||||
| 54 | if h.controller == nil { | 57 | if h.controller == nil { | ||
| @@ -186,6 +189,12 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||||
| 186 | actionToDispatch := action | 189 | actionToDispatch := action | ||
| 187 | callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool { | 190 | callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool { | ||
| 188 | log := logging.FromContext(h.ctx) | 191 | log := logging.FromContext(h.ctx) | ||
| 192 | + | if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) { | |||
| 193 | + | log.Trace(). | |||
| 194 | + | Str("action", string(actionToDispatch)). | |||
| 195 | + | Msg("global shortcut repeat suppressed") | |||
| 196 | + | return true | |||
| 197 | + | } | |||
| 189 | log.Debug(). | 198 | log.Debug(). | ||
| 190 | Str("action", string(actionToDispatch)). | 199 | Str("action", string(actionToDispatch)). | ||
| 191 | Msg("global shortcut triggered") | 200 | Msg("global shortcut triggered") | ||
| @@ -283,6 +292,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||||
| 283 | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | 292 | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | ||
| 284 | h.callbacks = make([]gtk.ShortcutFunc, 0) | 293 | h.callbacks = make([]gtk.ShortcutFunc, 0) | ||
| 285 | h.registered = make(map[KeyBinding]Action) | 294 | h.registered = make(map[KeyBinding]Action) | ||
| 295 | + | h.lastDispatchAt = make(map[Action]time.Time) | |||
| 286 | 296 | ||||
| 287 | // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S) | 297 | // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S) | ||
| 288 | tabActions := []Action{ | 298 | tabActions := []Action{ | ||
| @@ -351,6 +361,32 @@ func (h *GlobalShortcutHandler) Detach() { | |||||
| 351 | h.controller = nil | 361 | h.controller = nil | ||
| 352 | h.callbacks = nil | 362 | h.callbacks = nil | ||
| 353 | h.registered = nil | 363 | h.registered = nil | ||
| 364 | + | h.lastDispatchAt = nil | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond | |||
| 368 | + | ||||
| 369 | + | func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool { | |||
| 370 | + | if h == nil || !isRepeatedGlobalShortcutSuppressed(action) { | |||
| 371 | + | return false | |||
| 372 | + | } | |||
| 373 | + | if h.lastDispatchAt == nil { | |||
| 374 | + | return true | |||
| 375 | + | } | |||
| 376 | + | if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow { | |||
| 377 | + | return true | |||
| 378 | + | } | |||
| 379 | + | h.lastDispatchAt[action] = now | |||
| 380 | + | return false | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |||
| 384 | + | switch action { | |||
| 385 | + | case ActionZoomReset, ActionClosePane, ActionCloseTab, ActionQuit, ActionOpenSessionManager: | |||
| 386 | + | return true | |||
| 387 | + | default: | |||
| 388 | + | return false | |||
| 389 | + | } | |||
| 354 | } | 390 | } | ||
| 355 | 391 | ||||
| 356 | func formatBinding(binding KeyBinding) string { | 392 | func formatBinding(binding KeyBinding) string { | ||
A internal/ui/input/global_shortcuts_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) { | |
| 9 | + | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |
| 10 | + | now := time.Unix(100, 0) | |
| 11 | + | ||
| 12 | + | if h.suppressRepeatedShortcut(ActionZoomReset, now) { | |
| 13 | + | t.Fatal("first zoom reset dispatch was suppressed") | |
| 14 | + | } | |
| 15 | + | if !h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow/2)) { | |
| 16 | + | t.Fatal("repeated zoom reset dispatch inside suppression window was not suppressed") | |
| 17 | + | } | |
| 18 | + | if h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow)) { | |
| 19 | + | t.Fatal("zoom reset dispatch at suppression boundary was suppressed") | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) { | |
| 24 | + | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |
| 25 | + | now := time.Unix(100, 0) | |
| 26 | + | ||
| 27 | + | if h.suppressRepeatedShortcut(ActionFocusRight, now) { | |
| 28 | + | t.Fatal("first focus-right dispatch was suppressed") | |
| 29 | + | } | |
| 30 | + | if h.suppressRepeatedShortcut(ActionFocusRight, now.Add(time.Millisecond)) { | |
| 31 | + | t.Fatal("focus-right repeat was suppressed") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) { | |
| 36 | + | h := &GlobalShortcutHandler{} | |
| 37 | + | if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) { | |
| 38 | + | t.Fatal("one-shot shortcut was not suppressed with detached handler state") | |
| 39 | + | } | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) { | |||
| 9 | + | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |||
| 10 | + | now := time.Unix(100, 0) | |||
| 11 | + | ||||
| 12 | + | if h.suppressRepeatedShortcut(ActionZoomReset, now) { | |||
| 13 | + | t.Fatal("first zoom reset dispatch was suppressed") | |||
| 14 | + | } | |||
| 15 | + | if !h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow/2)) { | |||
| 16 | + | t.Fatal("repeated zoom reset dispatch inside suppression window was not suppressed") | |||
| 17 | + | } | |||
| 18 | + | if h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow)) { | |||
| 19 | + | t.Fatal("zoom reset dispatch at suppression boundary was suppressed") | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) { | |||
| 24 | + | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |||
| 25 | + | now := time.Unix(100, 0) | |||
| 26 | + | ||||
| 27 | + | if h.suppressRepeatedShortcut(ActionFocusRight, now) { | |||
| 28 | + | t.Fatal("first focus-right dispatch was suppressed") | |||
| 29 | + | } | |||
| 30 | + | if h.suppressRepeatedShortcut(ActionFocusRight, now.Add(time.Millisecond)) { | |||
| 31 | + | t.Fatal("focus-right repeat was suppressed") | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) { | |||
| 36 | + | h := &GlobalShortcutHandler{} | |||
| 37 | + | if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) { | |||
| 38 | + | t.Fatal("one-shot shortcut was not suppressed with detached handler state") | |||
| 39 | + | } | |||
| 40 | + | } | |||