detached f940181

fix: own GTK global shortcut actions

commit
f9401817500079934890ea3e2547175d96c5740d
author
brice · · 2026-08-14 02:15 UTC
parent
1ee1a70
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +76 -13
M internal/ui/input/global_shortcuts.go +10 -10
@@ -31,7 +31,6 @@ type GlobalShortcutHandler struct {
3131 shortcutRefs map[string]globalShortcutRegistration
3232 lastDispatchAt map[Action]time.Time
3333 heldShortcuts map[globalShortcutHoldKey]struct{}
34- shortcutAction *gtk.NamedAction
3534 globalAction *gio.SimpleAction
3635 globalActionCb func(gio.SimpleAction, uintptr)
3736 globalActionHandlerID uint
@@ -145,7 +144,6 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() {
145144 // Set global scope - this is the key to making shortcuts work
146145 // even when WebView has focus.
147146 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
148- h.shortcutAction = gtk.NewNamedAction(globalShortcutActionFullName)
149147 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))
150148 if h.globalAction != nil {
151149 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {
@@ -218,23 +216,26 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
218216 return false
219217 }
220218
221- if h.shortcutAction == nil {
219+ // Create trigger for this key combination.
220+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
221+ if trigger == nil {
222222 logging.FromContext(h.ctx).Error().
223223 Uint("keyval", keyval).
224- Msg("global shortcut action is unavailable")
224+ Msg("failed to create keyval trigger")
225225 return false
226226 }
227227
228- // Create trigger for this key combination.
229- trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
230- if trigger == nil {
228+ // GtkShortcut takes ownership of its action. Each shortcut needs its own
229+ // named action even though they all dispatch through the same window action.
230+ shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
231+ if shortcutAction == nil {
231232 logging.FromContext(h.ctx).Error().
232233 Uint("keyval", keyval).
233- Msg("failed to create keyval trigger")
234+ Msg("failed to create global shortcut action")
234235 return false
235236 }
236237
237- shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction)
238+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
238239 if shortcut == nil {
239240 logging.FromContext(h.ctx).Error().
240241 Uint("keyval", keyval).
@@ -634,7 +635,6 @@ func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
634635 }
635636 h.controller = nil
636637 h.releaseController = nil
637- h.shortcutAction = nil
638638 h.globalAction = nil
639639 h.globalActionCb = nil
640640 h.globalActionHandlerID = 0
@@ -31,7 +31,6 @@ type GlobalShortcutHandler struct {
31 shortcutRefs map[string]globalShortcutRegistration31 shortcutRefs map[string]globalShortcutRegistration
32 lastDispatchAt map[Action]time.Time32 lastDispatchAt map[Action]time.Time
33 heldShortcuts map[globalShortcutHoldKey]struct{}33 heldShortcuts map[globalShortcutHoldKey]struct{}
34- shortcutAction *gtk.NamedAction
35 globalAction *gio.SimpleAction34 globalAction *gio.SimpleAction
36 globalActionCb func(gio.SimpleAction, uintptr)35 globalActionCb func(gio.SimpleAction, uintptr)
37 globalActionHandlerID uint36 globalActionHandlerID uint
@@ -145,7 +144,6 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() {
145 // Set global scope - this is the key to making shortcuts work144 // Set global scope - this is the key to making shortcuts work
146 // even when WebView has focus.145 // even when WebView has focus.
147 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)146 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
148- h.shortcutAction = gtk.NewNamedAction(globalShortcutActionFullName)
149 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))147 h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))
150 if h.globalAction != nil {148 if h.globalAction != nil {
151 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {149 h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {
@@ -218,23 +216,26 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
218 return false216 return false
219 }217 }
220 218
221- if h.shortcutAction == nil {219+ // Create trigger for this key combination.
220+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
221+ if trigger == nil {
222 logging.FromContext(h.ctx).Error().222 logging.FromContext(h.ctx).Error().
223 Uint("keyval", keyval).223 Uint("keyval", keyval).
224- Msg("global shortcut action is unavailable")224+ Msg("failed to create keyval trigger")
225 return false225 return false
226 }226 }
227 227
228- // Create trigger for this key combination.228+ // GtkShortcut takes ownership of its action. Each shortcut needs its own
229- trigger := gtk.NewKeyvalTrigger(keyval, modifiers)229+ // named action even though they all dispatch through the same window action.
230- if trigger == nil {230+ shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
231+ if shortcutAction == nil {
231 logging.FromContext(h.ctx).Error().232 logging.FromContext(h.ctx).Error().
232 Uint("keyval", keyval).233 Uint("keyval", keyval).
233- Msg("failed to create keyval trigger")234+ Msg("failed to create global shortcut action")
234 return false235 return false
235 }236 }
236 237
237- shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction)238+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
238 if shortcut == nil {239 if shortcut == nil {
239 logging.FromContext(h.ctx).Error().240 logging.FromContext(h.ctx).Error().
240 Uint("keyval", keyval).241 Uint("keyval", keyval).
@@ -634,7 +635,6 @@ func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
634 }635 }
635 h.controller = nil636 h.controller = nil
636 h.releaseController = nil637 h.releaseController = nil
637- h.shortcutAction = nil
638 h.globalAction = nil638 h.globalAction = nil
639 h.globalActionCb = nil639 h.globalActionCb = nil
640 h.globalActionHandlerID = 0640 h.globalActionHandlerID = 0
M internal/ui/input/global_shortcuts_test.go +66 -3
@@ -1,6 +1,7 @@
11 package input
22
33 import (
4+ "context"
45 "testing"
56 "time"
67
@@ -54,6 +55,71 @@ func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *test
5455 }
5556 }
5657
58+func TestGlobalShortcutHandlerUsesDistinctNamedActionsAcrossReloads(t *testing.T) {
59+ if !gtk.InitCheck() {
60+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
61+ }
62+ display := gdk.DisplayGetDefault()
63+ if display == nil {
64+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
65+ }
66+ display.Unref()
67+
68+ appID := "com.dumber.GlobalShortcutHandlerTest"
69+ app := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
70+ if app == nil {
71+ t.Fatal("gtk application creation failed")
72+ }
73+ registered, err := app.Register(nil)
74+ if err != nil || !registered {
75+ t.Fatalf("gtk application registration failed: registered=%t err=%v", registered, err)
76+ }
77+ window := gtk.NewApplicationWindow(app)
78+ if window == nil {
79+ t.Fatal("gtk application window creation failed")
80+ }
81+ h := NewGlobalShortcutHandler(context.Background(), window, nil, nil, nil, nil)
82+ if h == nil {
83+ t.Fatal("global shortcut handler creation failed")
84+ }
85+ t.Cleanup(func() {
86+ h.Detach()
87+ window.Destroy()
88+ app.Unref()
89+ })
90+
91+ assertDistinctShortcutActions := func() {
92+ t.Helper()
93+ shortcutCount := h.controller.GetNItems()
94+ if shortcutCount < 2 {
95+ t.Fatalf("shortcut count = %d, want at least 2", shortcutCount)
96+ }
97+ actions := make(map[uintptr]struct{}, shortcutCount)
98+ for index := uint(0); index < shortcutCount; index++ {
99+ shortcut := gtk.ShortcutNewFromInternalPtr(h.controller.GetItem(index))
100+ if shortcut == nil {
101+ t.Fatalf("shortcut %d was nil", index)
102+ }
103+ action := shortcut.GetAction()
104+ if action == nil {
105+ t.Fatalf("shortcut %d action was nil", index)
106+ }
107+ actions[action.GoPointer()] = struct{}{}
108+ action.Unref()
109+ shortcut.Unref()
110+ }
111+ if got := uint(len(actions)); got != shortcutCount {
112+ t.Fatalf("distinct shortcut actions = %d, want %d", got, shortcutCount)
113+ }
114+ }
115+
116+ assertDistinctShortcutActions()
117+ h.ReloadShortcuts(context.Background(), nil, nil)
118+ assertDistinctShortcutActions()
119+ h.ReloadShortcuts(context.Background(), nil, nil)
120+ assertDistinctShortcutActions()
121+}
122+
57123 func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
58124 first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
59125 second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
@@ -174,9 +240,6 @@ func TestGlobalShortcutHandlerDetachForDestroyClearsRetainedState(t *testing.T)
174240 if h.releaseController != nil {
175241 t.Fatal("releaseController should be cleared during destroy detach")
176242 }
177- if h.shortcutAction != nil {
178- t.Fatal("shortcutAction should be cleared during destroy detach")
179- }
180243 if h.globalAction != nil {
181244 t.Fatal("globalAction should be cleared during destroy detach")
182245 }
@@ -1,6 +1,7 @@
1 package input1 package input
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -54,6 +55,71 @@ func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *test
54 }55 }
55 }56 }
56 57
58+func TestGlobalShortcutHandlerUsesDistinctNamedActionsAcrossReloads(t *testing.T) {
59+ if !gtk.InitCheck() {
60+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
61+ }
62+ display := gdk.DisplayGetDefault()
63+ if display == nil {
64+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
65+ }
66+ display.Unref()
67+
68+ appID := "com.dumber.GlobalShortcutHandlerTest"
69+ app := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
70+ if app == nil {
71+ t.Fatal("gtk application creation failed")
72+ }
73+ registered, err := app.Register(nil)
74+ if err != nil || !registered {
75+ t.Fatalf("gtk application registration failed: registered=%t err=%v", registered, err)
76+ }
77+ window := gtk.NewApplicationWindow(app)
78+ if window == nil {
79+ t.Fatal("gtk application window creation failed")
80+ }
81+ h := NewGlobalShortcutHandler(context.Background(), window, nil, nil, nil, nil)
82+ if h == nil {
83+ t.Fatal("global shortcut handler creation failed")
84+ }
85+ t.Cleanup(func() {
86+ h.Detach()
87+ window.Destroy()
88+ app.Unref()
89+ })
90+
91+ assertDistinctShortcutActions := func() {
92+ t.Helper()
93+ shortcutCount := h.controller.GetNItems()
94+ if shortcutCount < 2 {
95+ t.Fatalf("shortcut count = %d, want at least 2", shortcutCount)
96+ }
97+ actions := make(map[uintptr]struct{}, shortcutCount)
98+ for index := uint(0); index < shortcutCount; index++ {
99+ shortcut := gtk.ShortcutNewFromInternalPtr(h.controller.GetItem(index))
100+ if shortcut == nil {
101+ t.Fatalf("shortcut %d was nil", index)
102+ }
103+ action := shortcut.GetAction()
104+ if action == nil {
105+ t.Fatalf("shortcut %d action was nil", index)
106+ }
107+ actions[action.GoPointer()] = struct{}{}
108+ action.Unref()
109+ shortcut.Unref()
110+ }
111+ if got := uint(len(actions)); got != shortcutCount {
112+ t.Fatalf("distinct shortcut actions = %d, want %d", got, shortcutCount)
113+ }
114+ }
115+
116+ assertDistinctShortcutActions()
117+ h.ReloadShortcuts(context.Background(), nil, nil)
118+ assertDistinctShortcutActions()
119+ h.ReloadShortcuts(context.Background(), nil, nil)
120+ assertDistinctShortcutActions()
121+}
122+
57 func TestGlobalShortcutIDIncludesBinding(t *testing.T) {123 func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
58 first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)124 first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
59 second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)125 second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
@@ -174,9 +240,6 @@ func TestGlobalShortcutHandlerDetachForDestroyClearsRetainedState(t *testing.T)
174 if h.releaseController != nil {240 if h.releaseController != nil {
175 t.Fatal("releaseController should be cleared during destroy detach")241 t.Fatal("releaseController should be cleared during destroy detach")
176 }242 }
177- if h.shortcutAction != nil {
178- t.Fatal("shortcutAction should be cleared during destroy detach")
179- }
180 if h.globalAction != nil {243 if h.globalAction != nil {
181 t.Fatal("globalAction should be cleared during destroy detach")244 t.Fatal("globalAction should be cleared during destroy detach")
182 }245 }

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