detached baf031a

fix(input): hold global shortcuts until release

commit
baf031ae1e4d36dcc608de4b2324cb5719bbe61e
author
brice · · 2026-05-24 13:29 UTC
parent
e5061b6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +117 -15
M internal/ui/input/global_shortcuts.go +83 -15
@@ -19,13 +19,16 @@ import (
1919 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
2020 // to intercept shortcuts before they reach the WebView.
2121 type GlobalShortcutHandler struct {
22- controller *gtk.ShortcutController
23- window *gtk.ApplicationWindow
24- kbHandler *KeyboardHandler
25- onAction ActionHandler
26- ctx context.Context
27- registered map[KeyBinding]Action
28- lastDispatchAt map[Action]time.Time
22+ controller *gtk.ShortcutController
23+ releaseController *gtk.EventControllerKey
24+ window *gtk.ApplicationWindow
25+ kbHandler *KeyboardHandler
26+ onAction ActionHandler
27+ ctx context.Context
28+ registered map[KeyBinding]Action
29+ lastDispatchAt map[Action]time.Time
30+ heldShortcuts map[globalShortcutHoldKey]time.Time
31+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
2932 // generation is mutated only from the GTK main thread alongside controller replacement.
3033 generation uint64
3134
@@ -47,24 +50,35 @@ func NewGlobalShortcutHandler(
4750 log.Debug().Msg("creating global shortcut handler")
4851
4952 h := &GlobalShortcutHandler{
50- controller: gtk.NewShortcutController(),
51- window: window,
52- kbHandler: kbHandler,
53- onAction: onAction,
54- ctx: ctx,
55- callbacks: make([]gtk.ShortcutFunc, 0),
56- registered: make(map[KeyBinding]Action),
57- lastDispatchAt: make(map[Action]time.Time),
53+ controller: gtk.NewShortcutController(),
54+ releaseController: gtk.NewEventControllerKey(),
55+ window: window,
56+ kbHandler: kbHandler,
57+ onAction: onAction,
58+ ctx: ctx,
59+ callbacks: make([]gtk.ShortcutFunc, 0),
60+ registered: make(map[KeyBinding]Action),
61+ lastDispatchAt: make(map[Action]time.Time),
62+ heldShortcuts: make(map[globalShortcutHoldKey]time.Time),
5863 }
5964
6065 if h.controller == nil {
6166 log.Error().Msg("failed to create shortcut controller")
6267 return nil
6368 }
69+ if h.releaseController == nil {
70+ log.Error().Msg("failed to create global shortcut release controller")
71+ return nil
72+ }
6473
6574 // Set global scope - this is the key to making shortcuts work
6675 // even when WebView has focus
6776 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
77+ h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
78+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
79+ h.releaseHeldGlobalShortcuts(keyval, keycode)
80+ }
81+ h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
6882
6983 // Register Alt+1 through Alt+9 for tab switching
7084 tabActions := []Action{
@@ -157,6 +171,7 @@ func NewGlobalShortcutHandler(
157171 }
158172
159173 // Attach to window
174+ window.AddController(&h.releaseController.EventController)
160175 window.AddController(&h.controller.EventController)
161176
162177 log.Debug().
@@ -217,6 +232,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
217232 Msg("global shortcut ignored without current key event")
218233 return true
219234 }
235+ if h.suppressHeldShortcut(actionToDispatch, eventInfo, time.Now()) {
236+ log.Trace().
237+ Str("action", string(actionToDispatch)).
238+ Str("shortcut", formatBinding(bindingForLog)).
239+ Msg("held global shortcut repeat suppressed")
240+ return true
241+ }
220242 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
221243 log.Trace().
222244 Str("action", string(actionToDispatch)).
@@ -277,6 +299,11 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
277299 }
278300
279301 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
302+type globalShortcutHoldKey struct {
303+ keyval uint
304+ keycode uint
305+}
306+
280307 type globalShortcutEventInfo struct {
281308 hasCurrentEvent bool
282309 eventType gdk.EventType
@@ -432,6 +459,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
432459 h.callbacks = make([]gtk.ShortcutFunc, 0)
433460 h.registered = make(map[KeyBinding]Action)
434461 h.lastDispatchAt = make(map[Action]time.Time)
462+ h.heldShortcuts = make(map[globalShortcutHoldKey]time.Time)
435463
436464 // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
437465 tabActions := []Action{
@@ -501,10 +529,16 @@ func (h *GlobalShortcutHandler) Detach() {
501529 if h.window != nil && h.controller != nil {
502530 h.window.RemoveController(&h.controller.EventController)
503531 }
532+ if h.window != nil && h.releaseController != nil {
533+ h.window.RemoveController(&h.releaseController.EventController)
534+ }
504535 h.controller = nil
536+ h.releaseController = nil
537+ h.keyReleasedCb = nil
505538 h.callbacks = nil
506539 h.registered = nil
507540 h.lastDispatchAt = nil
541+ h.heldShortcuts = nil
508542 }
509543
510544 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
@@ -517,6 +551,36 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
517551 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
518552 }
519553
554+func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo, now time.Time) bool {
555+ if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
556+ return false
557+ }
558+ if h.heldShortcuts == nil {
559+ return true
560+ }
561+ key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
562+ if key.keyval == 0 && key.keycode == 0 {
563+ return false
564+ }
565+ if _, ok := h.heldShortcuts[key]; ok {
566+ return true
567+ }
568+ h.heldShortcuts[key] = now
569+ return false
570+}
571+
572+func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
573+ if h == nil || len(h.heldShortcuts) == 0 {
574+ return
575+ }
576+ keyval = normalizeKeyval(keyval)
577+ for key := range h.heldShortcuts {
578+ if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
579+ delete(h.heldShortcuts, key)
580+ }
581+ }
582+}
583+
520584 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
521585 if h == nil {
522586 return false
@@ -538,6 +602,10 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
538602 return false
539603 }
540604
605+func isHeldGlobalShortcutSuppressed(action Action) bool {
606+ return isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action)
607+}
608+
541609 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
542610 switch action {
543611 case ActionGoBack,
@@ -19,13 +19,16 @@ import (
19 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL19 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
20 // to intercept shortcuts before they reach the WebView.20 // to intercept shortcuts before they reach the WebView.
21 type GlobalShortcutHandler struct {21 type GlobalShortcutHandler struct {
22- controller *gtk.ShortcutController22+ controller *gtk.ShortcutController
23- window *gtk.ApplicationWindow23+ releaseController *gtk.EventControllerKey
24- kbHandler *KeyboardHandler24+ window *gtk.ApplicationWindow
25- onAction ActionHandler25+ kbHandler *KeyboardHandler
26- ctx context.Context26+ onAction ActionHandler
27- registered map[KeyBinding]Action27+ ctx context.Context
28- lastDispatchAt map[Action]time.Time28+ registered map[KeyBinding]Action
29+ lastDispatchAt map[Action]time.Time
30+ heldShortcuts map[globalShortcutHoldKey]time.Time
31+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
29 // generation is mutated only from the GTK main thread alongside controller replacement.32 // generation is mutated only from the GTK main thread alongside controller replacement.
30 generation uint6433 generation uint64
31 34
@@ -47,24 +50,35 @@ func NewGlobalShortcutHandler(
47 log.Debug().Msg("creating global shortcut handler")50 log.Debug().Msg("creating global shortcut handler")
48 51
49 h := &GlobalShortcutHandler{52 h := &GlobalShortcutHandler{
50- controller: gtk.NewShortcutController(),53+ controller: gtk.NewShortcutController(),
51- window: window,54+ releaseController: gtk.NewEventControllerKey(),
52- kbHandler: kbHandler,55+ window: window,
53- onAction: onAction,56+ kbHandler: kbHandler,
54- ctx: ctx,57+ onAction: onAction,
55- callbacks: make([]gtk.ShortcutFunc, 0),58+ ctx: ctx,
56- registered: make(map[KeyBinding]Action),59+ callbacks: make([]gtk.ShortcutFunc, 0),
57- lastDispatchAt: make(map[Action]time.Time),60+ registered: make(map[KeyBinding]Action),
61+ lastDispatchAt: make(map[Action]time.Time),
62+ heldShortcuts: make(map[globalShortcutHoldKey]time.Time),
58 }63 }
59 64
60 if h.controller == nil {65 if h.controller == nil {
61 log.Error().Msg("failed to create shortcut controller")66 log.Error().Msg("failed to create shortcut controller")
62 return nil67 return nil
63 }68 }
69+ if h.releaseController == nil {
70+ log.Error().Msg("failed to create global shortcut release controller")
71+ return nil
72+ }
64 73
65 // Set global scope - this is the key to making shortcuts work74 // Set global scope - this is the key to making shortcuts work
66 // even when WebView has focus75 // even when WebView has focus
67 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)76 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
77+ h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
78+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
79+ h.releaseHeldGlobalShortcuts(keyval, keycode)
80+ }
81+ h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
68 82
69 // Register Alt+1 through Alt+9 for tab switching83 // Register Alt+1 through Alt+9 for tab switching
70 tabActions := []Action{84 tabActions := []Action{
@@ -157,6 +171,7 @@ func NewGlobalShortcutHandler(
157 }171 }
158 172
159 // Attach to window173 // Attach to window
174+ window.AddController(&h.releaseController.EventController)
160 window.AddController(&h.controller.EventController)175 window.AddController(&h.controller.EventController)
161 176
162 log.Debug().177 log.Debug().
@@ -217,6 +232,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
217 Msg("global shortcut ignored without current key event")232 Msg("global shortcut ignored without current key event")
218 return true233 return true
219 }234 }
235+ if h.suppressHeldShortcut(actionToDispatch, eventInfo, time.Now()) {
236+ log.Trace().
237+ Str("action", string(actionToDispatch)).
238+ Str("shortcut", formatBinding(bindingForLog)).
239+ Msg("held global shortcut repeat suppressed")
240+ return true
241+ }
220 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {242 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
221 log.Trace().243 log.Trace().
222 Str("action", string(actionToDispatch)).244 Str("action", string(actionToDispatch)).
@@ -277,6 +299,11 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
277 }299 }
278 300
279 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.301 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
302+type globalShortcutHoldKey struct {
303+ keyval uint
304+ keycode uint
305+}
306+
280 type globalShortcutEventInfo struct {307 type globalShortcutEventInfo struct {
281 hasCurrentEvent bool308 hasCurrentEvent bool
282 eventType gdk.EventType309 eventType gdk.EventType
@@ -432,6 +459,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
432 h.callbacks = make([]gtk.ShortcutFunc, 0)459 h.callbacks = make([]gtk.ShortcutFunc, 0)
433 h.registered = make(map[KeyBinding]Action)460 h.registered = make(map[KeyBinding]Action)
434 h.lastDispatchAt = make(map[Action]time.Time)461 h.lastDispatchAt = make(map[Action]time.Time)
462+ h.heldShortcuts = make(map[globalShortcutHoldKey]time.Time)
435 463
436 // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)464 // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
437 tabActions := []Action{465 tabActions := []Action{
@@ -501,10 +529,16 @@ func (h *GlobalShortcutHandler) Detach() {
501 if h.window != nil && h.controller != nil {529 if h.window != nil && h.controller != nil {
502 h.window.RemoveController(&h.controller.EventController)530 h.window.RemoveController(&h.controller.EventController)
503 }531 }
532+ if h.window != nil && h.releaseController != nil {
533+ h.window.RemoveController(&h.releaseController.EventController)
534+ }
504 h.controller = nil535 h.controller = nil
536+ h.releaseController = nil
537+ h.keyReleasedCb = nil
505 h.callbacks = nil538 h.callbacks = nil
506 h.registered = nil539 h.registered = nil
507 h.lastDispatchAt = nil540 h.lastDispatchAt = nil
541+ h.heldShortcuts = nil
508 }542 }
509 543
510 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond544 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
@@ -517,6 +551,36 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
517 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()551 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
518 }552 }
519 553
554+func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo, now time.Time) bool {
555+ if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
556+ return false
557+ }
558+ if h.heldShortcuts == nil {
559+ return true
560+ }
561+ key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
562+ if key.keyval == 0 && key.keycode == 0 {
563+ return false
564+ }
565+ if _, ok := h.heldShortcuts[key]; ok {
566+ return true
567+ }
568+ h.heldShortcuts[key] = now
569+ return false
570+}
571+
572+func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
573+ if h == nil || len(h.heldShortcuts) == 0 {
574+ return
575+ }
576+ keyval = normalizeKeyval(keyval)
577+ for key := range h.heldShortcuts {
578+ if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
579+ delete(h.heldShortcuts, key)
580+ }
581+ }
582+}
583+
520 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {584 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
521 if h == nil {585 if h == nil {
522 return false586 return false
@@ -538,6 +602,10 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
538 return false602 return false
539 }603 }
540 604
605+func isHeldGlobalShortcutSuppressed(action Action) bool {
606+ return isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action)
607+}
608+
541 func isRepeatedGlobalShortcutSuppressed(action Action) bool {609 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
542 switch action {610 switch action {
543 case ActionGoBack,611 case ActionGoBack,
M internal/ui/input/global_shortcuts_test.go +34 -0
@@ -188,6 +188,40 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
188188 }
189189 }
190190
191+func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testing.T) {
192+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]time.Time)}
193+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('f'), eventKeycode: 41}
194+
195+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(100, 0)) {
196+ t.Fatal("first held global shortcut dispatch was suppressed")
197+ }
198+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(101, 0)) {
199+ t.Fatal("held global shortcut repeat after cooldown was not suppressed")
200+ }
201+
202+ h.releaseHeldGlobalShortcuts(uint('x'), 0)
203+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(102, 0)) {
204+ t.Fatal("unrelated key release re-armed held global shortcut")
205+ }
206+
207+ h.releaseHeldGlobalShortcuts(uint('f'), 0)
208+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(103, 0)) {
209+ t.Fatal("matching key release did not re-arm held global shortcut")
210+ }
211+}
212+
213+func TestGlobalShortcutHandlerSuppressesHeldModeAction(t *testing.T) {
214+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]time.Time)}
215+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('p'), eventKeycode: 33}
216+
217+ if h.suppressHeldShortcut(ActionEnterPaneMode, info, time.Unix(100, 0)) {
218+ t.Fatal("first mode shortcut dispatch was suppressed")
219+ }
220+ if !h.suppressHeldShortcut(ActionEnterPaneMode, info, time.Unix(101, 0)) {
221+ t.Fatal("held mode shortcut repeat was not suppressed")
222+ }
223+}
224+
191225 func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
192226 if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
193227 t.Fatal("global shortcut without current event should not dispatch")
@@ -188,6 +188,40 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
188 }188 }
189 }189 }
190 190
191+func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testing.T) {
192+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]time.Time)}
193+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('f'), eventKeycode: 41}
194+
195+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(100, 0)) {
196+ t.Fatal("first held global shortcut dispatch was suppressed")
197+ }
198+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(101, 0)) {
199+ t.Fatal("held global shortcut repeat after cooldown was not suppressed")
200+ }
201+
202+ h.releaseHeldGlobalShortcuts(uint('x'), 0)
203+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(102, 0)) {
204+ t.Fatal("unrelated key release re-armed held global shortcut")
205+ }
206+
207+ h.releaseHeldGlobalShortcuts(uint('f'), 0)
208+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(103, 0)) {
209+ t.Fatal("matching key release did not re-arm held global shortcut")
210+ }
211+}
212+
213+func TestGlobalShortcutHandlerSuppressesHeldModeAction(t *testing.T) {
214+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]time.Time)}
215+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('p'), eventKeycode: 33}
216+
217+ if h.suppressHeldShortcut(ActionEnterPaneMode, info, time.Unix(100, 0)) {
218+ t.Fatal("first mode shortcut dispatch was suppressed")
219+ }
220+ if !h.suppressHeldShortcut(ActionEnterPaneMode, info, time.Unix(101, 0)) {
221+ t.Fatal("held mode shortcut repeat was not suppressed")
222+ }
223+}
224+
191 func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {225 func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
192 if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {226 if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
193 t.Fatal("global shortcut without current event should not dispatch")227 t.Fatal("global shortcut without current event should not dispatch")

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