detached bb2fe9c

Throttle one-shot global shortcuts

commit
bb2fe9ced071332d595203a382fbe713aa841e97
author
brice · · 2026-04-30 03:56 UTC
parent
40b8fb0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
55 "context"
66 "fmt"
77 "strings"
8+ "time"
89
910 "github.com/bnema/dumber/internal/domain/entity"
1011 "github.com/bnema/dumber/internal/logging"
@@ -17,12 +18,13 @@ import (
1718 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
1819 // to intercept shortcuts before they reach the WebView.
1920 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
2628
2729 // Keep references to callbacks to prevent GC from collecting them
2830 callbacks []gtk.ShortcutFunc
@@ -42,13 +44,14 @@ func NewGlobalShortcutHandler(
4244 log.Debug().Msg("creating global shortcut handler")
4345
4446 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),
5255 }
5356
5457 if h.controller == nil {
@@ -186,6 +189,12 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
186189 actionToDispatch := action
187190 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
188191 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+ }
189198 log.Debug().
190199 Str("action", string(actionToDispatch)).
191200 Msg("global shortcut triggered")
@@ -283,6 +292,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
283292 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
284293 h.callbacks = make([]gtk.ShortcutFunc, 0)
285294 h.registered = make(map[KeyBinding]Action)
295+ h.lastDispatchAt = make(map[Action]time.Time)
286296
287297 // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
288298 tabActions := []Action{
@@ -351,6 +361,32 @@ func (h *GlobalShortcutHandler) Detach() {
351361 h.controller = nil
352362 h.callbacks = nil
353363 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+ }
354390 }
355391
356392 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_GLOBAL18 // 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.ShortcutController21+ controller *gtk.ShortcutController
21- window *gtk.ApplicationWindow22+ window *gtk.ApplicationWindow
22- kbHandler *KeyboardHandler23+ kbHandler *KeyboardHandler
23- onAction ActionHandler24+ onAction ActionHandler
24- ctx context.Context25+ ctx context.Context
25- registered map[KeyBinding]Action26+ registered map[KeyBinding]Action
27+ lastDispatchAt map[Action]time.Time
26 28
27 // Keep references to callbacks to prevent GC from collecting them29 // Keep references to callbacks to prevent GC from collecting them
28 callbacks []gtk.ShortcutFunc30 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 := action189 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 = nil361 h.controller = nil
352 h.callbacks = nil362 h.callbacks = nil
353 h.registered = nil363 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close