detached 14af573

refactor(input): reduce callback pressure for windows

commit
14af5738b1453e562aa4effeb0b698cbfe6d64df
author
brice · · 2026-06-09 12:35 UTC
parent
ff7151a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +249 -117
M go.mod +8 -0
@@ -83,3 +83,11 @@ require (
8383 )
8484
8585 tool github.com/a-h/templ/cmd/templ
86+
87+replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk
88+
89+replace github.com/bnema/puregotk => ../puregotk
90+
91+replace github.com/bnema/purego => ../purego
92+
93+replace github.com/bnema/purego-cef => ../purego-cef
@@ -83,3 +83,11 @@ require (
83 )83 )
84 84
85 tool github.com/a-h/templ/cmd/templ85 tool github.com/a-h/templ/cmd/templ
86+
87+replace github.com/bnema/purego-cef2gtk => ../purego-cef2gtk
88+
89+replace github.com/bnema/puregotk => ../puregotk
90+
91+replace github.com/bnema/purego => ../purego
92+
93+replace github.com/bnema/purego-cef => ../purego-cef
M go.sum +0 -8
@@ -10,18 +10,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
1010 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
1111 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
1212 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
13-github.com/bnema/purego v0.11.0-bnema.3 h1:lpTXuapRIBH2OTgLJTjYlK1olHmZkEQLloqgP8zpocs=
14-github.com/bnema/purego v0.11.0-bnema.3/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng=
15-github.com/bnema/purego-cef v0.13.2 h1:weSttuJVxjyzp/A5J15ZdzOAVWO/jvvd6nBf8/N4zsI=
16-github.com/bnema/purego-cef v0.13.2/go.mod h1:Jbc4GgUvGakEOfbRnXJGYrDbXz03YEpVgTWKPk5ss9k=
17-github.com/bnema/purego-cef2gtk v0.8.0 h1:Fd1umUyyPuIqmqyg252dChcv6XJtYVsudleEZ7we9LY=
18-github.com/bnema/purego-cef2gtk v0.8.0/go.mod h1:eLgf2GwRirBmRNzPWAp/VMHf4MyAN8xoWbdoUNgW48A=
1913 github.com/bnema/purego-pipewire v0.1.4 h1:ThuPiZo9BeYn1w06hg/g0INgQUIBi8n9Gq1o5VRtjxk=
2014 github.com/bnema/purego-pipewire v0.1.4/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk=
2115 github.com/bnema/purego-sqlite v0.1.3 h1:aXysq9+wuRkbFzlVe2oIiooYseFZFb6ArwbfDYYafvY=
2216 github.com/bnema/purego-sqlite v0.1.3/go.mod h1:a15W2PgD/3b9W9HytbmPXz3pBAbjwf7dhNv997fSbhI=
23-github.com/bnema/puregotk v0.6.0 h1:yZJoCdY9f+souLxWV5JJfI+yBB/MD0aEQbSF0aLDNII=
24-github.com/bnema/puregotk v0.6.0/go.mod h1:pXquWfuhZzVdDJU08CH3De8l9N4MsuOpTjbKHpiOeIs=
2517 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
2618 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
2719 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -10,18 +10,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
10 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=10 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
11 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=11 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
12 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=12 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
13-github.com/bnema/purego v0.11.0-bnema.3 h1:lpTXuapRIBH2OTgLJTjYlK1olHmZkEQLloqgP8zpocs=
14-github.com/bnema/purego v0.11.0-bnema.3/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng=
15-github.com/bnema/purego-cef v0.13.2 h1:weSttuJVxjyzp/A5J15ZdzOAVWO/jvvd6nBf8/N4zsI=
16-github.com/bnema/purego-cef v0.13.2/go.mod h1:Jbc4GgUvGakEOfbRnXJGYrDbXz03YEpVgTWKPk5ss9k=
17-github.com/bnema/purego-cef2gtk v0.8.0 h1:Fd1umUyyPuIqmqyg252dChcv6XJtYVsudleEZ7we9LY=
18-github.com/bnema/purego-cef2gtk v0.8.0/go.mod h1:eLgf2GwRirBmRNzPWAp/VMHf4MyAN8xoWbdoUNgW48A=
19 github.com/bnema/purego-pipewire v0.1.4 h1:ThuPiZo9BeYn1w06hg/g0INgQUIBi8n9Gq1o5VRtjxk=13 github.com/bnema/purego-pipewire v0.1.4 h1:ThuPiZo9BeYn1w06hg/g0INgQUIBi8n9Gq1o5VRtjxk=
20 github.com/bnema/purego-pipewire v0.1.4/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk=14 github.com/bnema/purego-pipewire v0.1.4/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk=
21 github.com/bnema/purego-sqlite v0.1.3 h1:aXysq9+wuRkbFzlVe2oIiooYseFZFb6ArwbfDYYafvY=15 github.com/bnema/purego-sqlite v0.1.3 h1:aXysq9+wuRkbFzlVe2oIiooYseFZFb6ArwbfDYYafvY=
22 github.com/bnema/purego-sqlite v0.1.3/go.mod h1:a15W2PgD/3b9W9HytbmPXz3pBAbjwf7dhNv997fSbhI=16 github.com/bnema/purego-sqlite v0.1.3/go.mod h1:a15W2PgD/3b9W9HytbmPXz3pBAbjwf7dhNv997fSbhI=
23-github.com/bnema/puregotk v0.6.0 h1:yZJoCdY9f+souLxWV5JJfI+yBB/MD0aEQbSF0aLDNII=
24-github.com/bnema/puregotk v0.6.0/go.mod h1:pXquWfuhZzVdDJU08CH3De8l9N4MsuOpTjbKHpiOeIs=
25 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=17 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
26 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=18 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
27 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=19 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
M internal/ui/browser_window.go +13 -0
@@ -39,6 +39,18 @@ type browserWindow struct {
3939 webrtcIndicator *component.WebRTCPermissionIndicator
4040 }
4141
42+func (bw *browserWindow) detachInput() {
43+ if bw == nil {
44+ return
45+ }
46+ if bw.keyboardHandler != nil {
47+ bw.keyboardHandler.Detach()
48+ }
49+ if bw.globalShortcutHandler != nil {
50+ bw.globalShortcutHandler.Detach()
51+ }
52+}
53+
4254 func (bw *browserWindow) clearShellState() {
4355 if bw == nil {
4456 return
@@ -287,6 +299,7 @@ func (a *App) removeBrowserWindow(id string) {
287299 }
288300 }
289301 if removed != nil {
302+ removed.detachInput()
290303 removed.clearShellState()
291304 }
292305 if a.contentCoord != nil {
@@ -39,6 +39,18 @@ type browserWindow struct {
39 webrtcIndicator *component.WebRTCPermissionIndicator39 webrtcIndicator *component.WebRTCPermissionIndicator
40 }40 }
41 41
42+func (bw *browserWindow) detachInput() {
43+ if bw == nil {
44+ return
45+ }
46+ if bw.keyboardHandler != nil {
47+ bw.keyboardHandler.Detach()
48+ }
49+ if bw.globalShortcutHandler != nil {
50+ bw.globalShortcutHandler.Detach()
51+ }
52+}
53+
42 func (bw *browserWindow) clearShellState() {54 func (bw *browserWindow) clearShellState() {
43 if bw == nil {55 if bw == nil {
44 return56 return
@@ -287,6 +299,7 @@ func (a *App) removeBrowserWindow(id string) {
287 }299 }
288 }300 }
289 if removed != nil {301 if removed != nil {
302+ removed.detachInput()
290 removed.clearShellState()303 removed.clearShellState()
291 }304 }
292 if a.contentCoord != nil {305 if a.contentCoord != nil {
M internal/ui/browser_window_test.go +3 -0
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/dumber/internal/shared/syncdispatch"
1313 "github.com/bnema/dumber/internal/ui/component"
1414 "github.com/bnema/dumber/internal/ui/focus"
15+ "github.com/bnema/dumber/internal/ui/input"
1516 "github.com/bnema/dumber/internal/ui/layout"
1617 "github.com/bnema/dumber/internal/ui/window"
1718 "github.com/bnema/puregotk/v4/gtk"
@@ -38,6 +39,8 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
3839 setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
3940 setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
4041 setShellField(t, removed, "accentPicker", &component.AccentPicker{})
42+ setShellField(t, removed, "keyboardHandler", &input.KeyboardHandler{})
43+ setShellField(t, removed, "globalShortcutHandler", &input.GlobalShortcutHandler{})
4144 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
4245 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
4346
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/dumber/internal/shared/syncdispatch"12 "github.com/bnema/dumber/internal/shared/syncdispatch"
13 "github.com/bnema/dumber/internal/ui/component"13 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/dumber/internal/ui/focus"14 "github.com/bnema/dumber/internal/ui/focus"
15+ "github.com/bnema/dumber/internal/ui/input"
15 "github.com/bnema/dumber/internal/ui/layout"16 "github.com/bnema/dumber/internal/ui/layout"
16 "github.com/bnema/dumber/internal/ui/window"17 "github.com/bnema/dumber/internal/ui/window"
17 "github.com/bnema/puregotk/v4/gtk"18 "github.com/bnema/puregotk/v4/gtk"
@@ -38,6 +39,8 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
38 setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))39 setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
39 setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))40 setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
40 setShellField(t, removed, "accentPicker", &component.AccentPicker{})41 setShellField(t, removed, "accentPicker", &component.AccentPicker{})
42+ setShellField(t, removed, "keyboardHandler", &input.KeyboardHandler{})
43+ setShellField(t, removed, "globalShortcutHandler", &input.GlobalShortcutHandler{})
41 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))44 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
42 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})45 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
43 46
M internal/ui/input/global_shortcuts.go +170 -102
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/logging"
1212 "github.com/bnema/puregotk/v4/gdk"
13+ "github.com/bnema/puregotk/v4/gio"
1314 "github.com/bnema/puregotk/v4/glib"
1415 "github.com/bnema/puregotk/v4/gtk"
1516 "github.com/rs/zerolog"
@@ -19,22 +20,24 @@ import (
1920 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
2021 // to intercept shortcuts before they reach the WebView.
2122 type GlobalShortcutHandler struct {
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]struct{}
31- keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
32- keyReleasedHandlerID uint
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
3339 // generation is mutated only from the GTK main thread alongside controller replacement.
3440 generation uint64
35-
36- // Keep references to callbacks to prevent GC from collecting them
37- callbacks []gtk.ShortcutFunc
3841 }
3942
4043 // NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
@@ -57,8 +60,8 @@ func NewGlobalShortcutHandler(
5760 kbHandler: kbHandler,
5861 onAction: onAction,
5962 ctx: ctx,
60- callbacks: make([]gtk.ShortcutFunc, 0),
6163 registered: make(map[KeyBinding]Action),
64+ shortcutRefs: make(map[string]globalShortcutRegistration),
6265 lastDispatchAt: make(map[Action]time.Time),
6366 heldShortcuts: make(map[globalShortcutHoldKey]struct{}),
6467 }
@@ -127,7 +130,7 @@ func NewGlobalShortcutHandler(
127130 window.AddController(&h.controller.EventController)
128131
129132 log.Debug().
130- Int("shortcuts", len(h.callbacks)).
133+ Int("shortcuts", len(h.registered)).
131134 Msg("global shortcut handler created and attached")
132135
133136 return h
@@ -137,6 +140,15 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() {
137140 // Set global scope - this is the key to making shortcuts work
138141 // even when WebView has focus.
139142 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+ }
140152 h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
141153 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
142154 h.releaseHeldGlobalShortcuts(keyval, keycode)
@@ -201,98 +213,23 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
201213 return false
202214 }
203215
204- // Create trigger for this key combination
205- trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
206- if trigger == nil {
216+ if h.shortcutAction == nil {
207217 logging.FromContext(h.ctx).Error().
208218 Uint("keyval", keyval).
209- Msg("failed to create keyval trigger")
219+ Msg("global shortcut action is unavailable")
210220 return false
211221 }
212222
213- // Create callback action.
214- // Capture the generation so callbacks from removed/reloaded controllers cannot
215- // dispatch one-shot actions after a shortcut reload.
216- actionToDispatch := action
217- generation := h.generation
218- bindingForLog := binding
219- callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
220- log := logging.FromContext(h.ctx)
221- if h.isStaleGeneration(generation) {
222- log.Trace().
223- Str("action", string(actionToDispatch)).
224- Str("shortcut", formatBinding(bindingForLog)).
225- Uint64("callback_generation", generation).
226- Uint64("current_generation", h.generation).
227- Msg("stale global shortcut callback ignored")
228- return true
229- }
230- if !h.isActiveWindowShortcutHandler() {
231- log.Trace().
232- Str("action", string(actionToDispatch)).
233- Str("shortcut", formatBinding(bindingForLog)).
234- Msg("inactive window global shortcut callback ignored")
235- return false
236- }
237- eventInfo := h.inspectCurrentShortcutEvent()
238- if !shouldDispatchGlobalShortcutEvent(eventInfo) {
239- appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
240- Msg("global shortcut ignored without current key event")
241- return true
242- }
243- if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
244- log.Trace().
245- Str("action", string(actionToDispatch)).
246- Str("shortcut", formatBinding(bindingForLog)).
247- Msg("held global shortcut repeat suppressed")
248- return true
249- }
250- if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
251- log.Trace().
252- Str("action", string(actionToDispatch)).
253- Str("shortcut", formatBinding(bindingForLog)).
254- Msg("global shortcut repeat suppressed")
255- return true
256- }
257- appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
258- Msg("global shortcut triggered")
259-
260- // Mode-enter/exit actions go through KeyboardHandler for modal state
261- if isModeAction(actionToDispatch) {
262- if h.kbHandler != nil {
263- h.kbHandler.DispatchAction(actionToDispatch)
264- return true
265- }
266- log.Warn().
267- Str("action", string(actionToDispatch)).
268- Msg("mode action triggered but keyboard handler not set, falling through to default handler")
269- }
270-
271- if h.onAction != nil {
272- if err := h.onAction(h.ctx, actionToDispatch); err != nil {
273- log.Error().
274- Err(err).
275- Str("action", string(actionToDispatch)).
276- Msg("global shortcut action failed")
277- }
278- }
279- return true // Event consumed
280- })
281-
282- // Store callback reference to prevent GC
283- h.callbacks = append(h.callbacks, callback)
284-
285- // Create the shortcut action
286- shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
287- if shortcutAction == nil {
223+ // Create trigger for this key combination.
224+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
225+ if trigger == nil {
288226 logging.FromContext(h.ctx).Error().
289227 Uint("keyval", keyval).
290- Msg("failed to create callback action")
228+ Msg("failed to create keyval trigger")
291229 return false
292230 }
293231
294- // Create the shortcut combining trigger and action
295- shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
232+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction)
296233 if shortcut == nil {
297234 logging.FromContext(h.ctx).Error().
298235 Uint("keyval", keyval).
@@ -300,12 +237,133 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
300237 return false
301238 }
302239
303- // Add to controller
240+ shortcutID := encodeGlobalShortcutID(action, binding)
241+ shortcut.SetArguments(glib.NewVariantString(shortcutID))
242+
243+ // Add to controller.
304244 h.controller.AddShortcut(shortcut)
305245 h.registered[binding] = action
246+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{
247+ action: action,
248+ binding: binding,
249+ }
250+ return true
251+}
252+
253+func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
254+ log := logging.FromContext(h.ctx)
255+ registration, ok := h.globalShortcutRegistration(parameter)
256+ if !ok {
257+ log.Warn().Msg("global shortcut activated without registered argument")
258+ return
259+ }
260+ h.dispatchGlobalShortcut(registration.action, registration.binding, h.generation)
261+}
262+
263+func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
264+ if parameter == 0 || h.shortcutRefs == nil {
265+ return globalShortcutRegistration{}, false
266+ }
267+ variant := glib.VariantNewFromInternalPtr(parameter)
268+ if variant == nil {
269+ return globalShortcutRegistration{}, false
270+ }
271+ shortcutID := variant.GetString(nil)
272+ registration, ok := h.shortcutRefs[shortcutID]
273+ return registration, ok
274+}
275+
276+func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
277+ log := logging.FromContext(h.ctx)
278+ if h.isStaleGeneration(generation) {
279+ log.Trace().
280+ Str("action", string(actionToDispatch)).
281+ Str("shortcut", formatBinding(bindingForLog)).
282+ Uint64("callback_generation", generation).
283+ Uint64("current_generation", h.generation).
284+ Msg("stale global shortcut callback ignored")
285+ return true
286+ }
287+ if !h.isActiveWindowShortcutHandler() {
288+ log.Trace().
289+ Str("action", string(actionToDispatch)).
290+ Str("shortcut", formatBinding(bindingForLog)).
291+ Msg("inactive window global shortcut callback ignored")
292+ return false
293+ }
294+ eventInfo := h.inspectCurrentShortcutEvent()
295+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
296+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
297+ Msg("global shortcut ignored without current key event")
298+ return true
299+ }
300+ if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
301+ log.Trace().
302+ Str("action", string(actionToDispatch)).
303+ Str("shortcut", formatBinding(bindingForLog)).
304+ Msg("held global shortcut repeat suppressed")
305+ return true
306+ }
307+ if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
308+ log.Trace().
309+ Str("action", string(actionToDispatch)).
310+ Str("shortcut", formatBinding(bindingForLog)).
311+ Msg("global shortcut repeat suppressed")
312+ return true
313+ }
314+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
315+ Msg("global shortcut triggered")
316+
317+ // Mode-enter/exit actions go through KeyboardHandler for modal state.
318+ if isModeAction(actionToDispatch) {
319+ if h.kbHandler != nil {
320+ h.kbHandler.DispatchAction(actionToDispatch)
321+ return true
322+ }
323+ log.Warn().
324+ Str("action", string(actionToDispatch)).
325+ Msg("mode action triggered but keyboard handler not set, falling through to default handler")
326+ }
327+
328+ if h.onAction != nil {
329+ if err := h.onAction(h.ctx, actionToDispatch); err != nil {
330+ log.Error().
331+ Err(err).
332+ Str("action", string(actionToDispatch)).
333+ Msg("global shortcut action failed")
334+ }
335+ }
306336 return true
307337 }
308338
339+const (
340+ globalShortcutActionName = "dumber-global-shortcut"
341+ globalShortcutActionFullName = "win." + globalShortcutActionName
342+)
343+
344+type globalShortcutRegistration struct {
345+ action Action
346+ binding KeyBinding
347+}
348+
349+func encodeGlobalShortcutID(action Action, binding KeyBinding) string {
350+ return fmt.Sprintf("%s\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers)
351+}
352+
353+func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
354+ if h == nil {
355+ return 0
356+ }
357+ callbacks := 0
358+ if h.globalActionCb != nil {
359+ callbacks++
360+ }
361+ if h.keyReleasedCb != nil {
362+ callbacks++
363+ }
364+ return callbacks
365+}
366+
309367 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
310368 type globalShortcutHoldKey struct {
311369 keyval uint
@@ -464,8 +522,8 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
464522 return
465523 }
466524 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
467- h.callbacks = make([]gtk.ShortcutFunc, 0)
468525 h.registered = make(map[KeyBinding]Action)
526+ h.shortcutRefs = make(map[string]globalShortcutRegistration)
469527 h.lastDispatchAt = make(map[Action]time.Time)
470528 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
471529
@@ -514,7 +572,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
514572 h.window.AddController(&h.controller.EventController)
515573
516574 log.Debug().
517- Int("shortcuts", len(h.callbacks)).
575+ Int("shortcuts", len(h.registered)).
518576 Msg("global shortcuts reloaded")
519577 }
520578
@@ -532,12 +590,22 @@ func (h *GlobalShortcutHandler) Detach() {
532590 if h.window != nil && h.releaseController != nil {
533591 h.window.RemoveController(&h.releaseController.EventController)
534592 }
593+ if h.globalAction != nil && h.globalActionHandlerID != 0 {
594+ h.globalAction.DisconnectSignal(h.globalActionHandlerID)
595+ }
596+ if h.window != nil && h.globalAction != nil {
597+ h.window.RemoveAction(globalShortcutActionName)
598+ }
535599 h.controller = nil
536600 h.releaseController = nil
601+ h.shortcutAction = nil
602+ h.globalAction = nil
603+ h.globalActionCb = nil
604+ h.globalActionHandlerID = 0
537605 h.keyReleasedCb = nil
538606 h.keyReleasedHandlerID = 0
539- h.callbacks = nil
540607 h.registered = nil
608+ h.shortcutRefs = nil
541609 h.lastDispatchAt = nil
542610 h.heldShortcuts = nil
543611 }
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gdk"12 "github.com/bnema/puregotk/v4/gdk"
13+ "github.com/bnema/puregotk/v4/gio"
13 "github.com/bnema/puregotk/v4/glib"14 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/gtk"15 "github.com/bnema/puregotk/v4/gtk"
15 "github.com/rs/zerolog"16 "github.com/rs/zerolog"
@@ -19,22 +20,24 @@ import (
19 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL20 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
20 // to intercept shortcuts before they reach the WebView.21 // to intercept shortcuts before they reach the WebView.
21 type GlobalShortcutHandler struct {22 type GlobalShortcutHandler struct {
22- controller *gtk.ShortcutController23+ controller *gtk.ShortcutController
23- releaseController *gtk.EventControllerKey24+ releaseController *gtk.EventControllerKey
24- window *gtk.ApplicationWindow25+ window *gtk.ApplicationWindow
25- kbHandler *KeyboardHandler26+ kbHandler *KeyboardHandler
26- onAction ActionHandler27+ onAction ActionHandler
27- ctx context.Context28+ ctx context.Context
28- registered map[KeyBinding]Action29+ registered map[KeyBinding]Action
29- lastDispatchAt map[Action]time.Time30+ shortcutRefs map[string]globalShortcutRegistration
30- heldShortcuts map[globalShortcutHoldKey]struct{}31+ lastDispatchAt map[Action]time.Time
31- keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)32+ heldShortcuts map[globalShortcutHoldKey]struct{}
32- keyReleasedHandlerID uint33+ 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
33 // generation is mutated only from the GTK main thread alongside controller replacement.39 // generation is mutated only from the GTK main thread alongside controller replacement.
34 generation uint6440 generation uint64
35-
36- // Keep references to callbacks to prevent GC from collecting them
37- callbacks []gtk.ShortcutFunc
38 }41 }
39 42
40 // NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts43 // NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
@@ -57,8 +60,8 @@ func NewGlobalShortcutHandler(
57 kbHandler: kbHandler,60 kbHandler: kbHandler,
58 onAction: onAction,61 onAction: onAction,
59 ctx: ctx,62 ctx: ctx,
60- callbacks: make([]gtk.ShortcutFunc, 0),
61 registered: make(map[KeyBinding]Action),63 registered: make(map[KeyBinding]Action),
64+ shortcutRefs: make(map[string]globalShortcutRegistration),
62 lastDispatchAt: make(map[Action]time.Time),65 lastDispatchAt: make(map[Action]time.Time),
63 heldShortcuts: make(map[globalShortcutHoldKey]struct{}),66 heldShortcuts: make(map[globalShortcutHoldKey]struct{}),
64 }67 }
@@ -127,7 +130,7 @@ func NewGlobalShortcutHandler(
127 window.AddController(&h.controller.EventController)130 window.AddController(&h.controller.EventController)
128 131
129 log.Debug().132 log.Debug().
130- Int("shortcuts", len(h.callbacks)).133+ Int("shortcuts", len(h.registered)).
131 Msg("global shortcut handler created and attached")134 Msg("global shortcut handler created and attached")
132 135
133 return h136 return h
@@ -137,6 +140,15 @@ func (h *GlobalShortcutHandler) configureGlobalControllers() {
137 // Set global scope - this is the key to making shortcuts work140 // Set global scope - this is the key to making shortcuts work
138 // even when WebView has focus.141 // even when WebView has focus.
139 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)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+ }
140 h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)152 h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
141 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {153 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
142 h.releaseHeldGlobalShortcuts(keyval, keycode)154 h.releaseHeldGlobalShortcuts(keyval, keycode)
@@ -201,98 +213,23 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
201 return false213 return false
202 }214 }
203 215
204- // Create trigger for this key combination216+ if h.shortcutAction == nil {
205- trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
206- if trigger == nil {
207 logging.FromContext(h.ctx).Error().217 logging.FromContext(h.ctx).Error().
208 Uint("keyval", keyval).218 Uint("keyval", keyval).
209- Msg("failed to create keyval trigger")219+ Msg("global shortcut action is unavailable")
210 return false220 return false
211 }221 }
212 222
213- // Create callback action.223+ // Create trigger for this key combination.
214- // Capture the generation so callbacks from removed/reloaded controllers cannot224+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
215- // dispatch one-shot actions after a shortcut reload.225+ if trigger == nil {
216- actionToDispatch := action
217- generation := h.generation
218- bindingForLog := binding
219- callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
220- log := logging.FromContext(h.ctx)
221- if h.isStaleGeneration(generation) {
222- log.Trace().
223- Str("action", string(actionToDispatch)).
224- Str("shortcut", formatBinding(bindingForLog)).
225- Uint64("callback_generation", generation).
226- Uint64("current_generation", h.generation).
227- Msg("stale global shortcut callback ignored")
228- return true
229- }
230- if !h.isActiveWindowShortcutHandler() {
231- log.Trace().
232- Str("action", string(actionToDispatch)).
233- Str("shortcut", formatBinding(bindingForLog)).
234- Msg("inactive window global shortcut callback ignored")
235- return false
236- }
237- eventInfo := h.inspectCurrentShortcutEvent()
238- if !shouldDispatchGlobalShortcutEvent(eventInfo) {
239- appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
240- Msg("global shortcut ignored without current key event")
241- return true
242- }
243- if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
244- log.Trace().
245- Str("action", string(actionToDispatch)).
246- Str("shortcut", formatBinding(bindingForLog)).
247- Msg("held global shortcut repeat suppressed")
248- return true
249- }
250- if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
251- log.Trace().
252- Str("action", string(actionToDispatch)).
253- Str("shortcut", formatBinding(bindingForLog)).
254- Msg("global shortcut repeat suppressed")
255- return true
256- }
257- appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
258- Msg("global shortcut triggered")
259-
260- // Mode-enter/exit actions go through KeyboardHandler for modal state
261- if isModeAction(actionToDispatch) {
262- if h.kbHandler != nil {
263- h.kbHandler.DispatchAction(actionToDispatch)
264- return true
265- }
266- log.Warn().
267- Str("action", string(actionToDispatch)).
268- Msg("mode action triggered but keyboard handler not set, falling through to default handler")
269- }
270-
271- if h.onAction != nil {
272- if err := h.onAction(h.ctx, actionToDispatch); err != nil {
273- log.Error().
274- Err(err).
275- Str("action", string(actionToDispatch)).
276- Msg("global shortcut action failed")
277- }
278- }
279- return true // Event consumed
280- })
281-
282- // Store callback reference to prevent GC
283- h.callbacks = append(h.callbacks, callback)
284-
285- // Create the shortcut action
286- shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
287- if shortcutAction == nil {
288 logging.FromContext(h.ctx).Error().226 logging.FromContext(h.ctx).Error().
289 Uint("keyval", keyval).227 Uint("keyval", keyval).
290- Msg("failed to create callback action")228+ Msg("failed to create keyval trigger")
291 return false229 return false
292 }230 }
293 231
294- // Create the shortcut combining trigger and action232+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &h.shortcutAction.ShortcutAction)
295- shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
296 if shortcut == nil {233 if shortcut == nil {
297 logging.FromContext(h.ctx).Error().234 logging.FromContext(h.ctx).Error().
298 Uint("keyval", keyval).235 Uint("keyval", keyval).
@@ -300,12 +237,133 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
300 return false237 return false
301 }238 }
302 239
303- // Add to controller240+ shortcutID := encodeGlobalShortcutID(action, binding)
241+ shortcut.SetArguments(glib.NewVariantString(shortcutID))
242+
243+ // Add to controller.
304 h.controller.AddShortcut(shortcut)244 h.controller.AddShortcut(shortcut)
305 h.registered[binding] = action245 h.registered[binding] = action
246+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{
247+ action: action,
248+ binding: binding,
249+ }
250+ return true
251+}
252+
253+func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
254+ log := logging.FromContext(h.ctx)
255+ registration, ok := h.globalShortcutRegistration(parameter)
256+ if !ok {
257+ log.Warn().Msg("global shortcut activated without registered argument")
258+ return
259+ }
260+ h.dispatchGlobalShortcut(registration.action, registration.binding, h.generation)
261+}
262+
263+func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
264+ if parameter == 0 || h.shortcutRefs == nil {
265+ return globalShortcutRegistration{}, false
266+ }
267+ variant := glib.VariantNewFromInternalPtr(parameter)
268+ if variant == nil {
269+ return globalShortcutRegistration{}, false
270+ }
271+ shortcutID := variant.GetString(nil)
272+ registration, ok := h.shortcutRefs[shortcutID]
273+ return registration, ok
274+}
275+
276+func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
277+ log := logging.FromContext(h.ctx)
278+ if h.isStaleGeneration(generation) {
279+ log.Trace().
280+ Str("action", string(actionToDispatch)).
281+ Str("shortcut", formatBinding(bindingForLog)).
282+ Uint64("callback_generation", generation).
283+ Uint64("current_generation", h.generation).
284+ Msg("stale global shortcut callback ignored")
285+ return true
286+ }
287+ if !h.isActiveWindowShortcutHandler() {
288+ log.Trace().
289+ Str("action", string(actionToDispatch)).
290+ Str("shortcut", formatBinding(bindingForLog)).
291+ Msg("inactive window global shortcut callback ignored")
292+ return false
293+ }
294+ eventInfo := h.inspectCurrentShortcutEvent()
295+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
296+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
297+ Msg("global shortcut ignored without current key event")
298+ return true
299+ }
300+ if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
301+ log.Trace().
302+ Str("action", string(actionToDispatch)).
303+ Str("shortcut", formatBinding(bindingForLog)).
304+ Msg("held global shortcut repeat suppressed")
305+ return true
306+ }
307+ if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
308+ log.Trace().
309+ Str("action", string(actionToDispatch)).
310+ Str("shortcut", formatBinding(bindingForLog)).
311+ Msg("global shortcut repeat suppressed")
312+ return true
313+ }
314+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
315+ Msg("global shortcut triggered")
316+
317+ // Mode-enter/exit actions go through KeyboardHandler for modal state.
318+ if isModeAction(actionToDispatch) {
319+ if h.kbHandler != nil {
320+ h.kbHandler.DispatchAction(actionToDispatch)
321+ return true
322+ }
323+ log.Warn().
324+ Str("action", string(actionToDispatch)).
325+ Msg("mode action triggered but keyboard handler not set, falling through to default handler")
326+ }
327+
328+ if h.onAction != nil {
329+ if err := h.onAction(h.ctx, actionToDispatch); err != nil {
330+ log.Error().
331+ Err(err).
332+ Str("action", string(actionToDispatch)).
333+ Msg("global shortcut action failed")
334+ }
335+ }
306 return true336 return true
307 }337 }
308 338
339+const (
340+ globalShortcutActionName = "dumber-global-shortcut"
341+ globalShortcutActionFullName = "win." + globalShortcutActionName
342+)
343+
344+type globalShortcutRegistration struct {
345+ action Action
346+ binding KeyBinding
347+}
348+
349+func encodeGlobalShortcutID(action Action, binding KeyBinding) string {
350+ return fmt.Sprintf("%s\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers)
351+}
352+
353+func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
354+ if h == nil {
355+ return 0
356+ }
357+ callbacks := 0
358+ if h.globalActionCb != nil {
359+ callbacks++
360+ }
361+ if h.keyReleasedCb != nil {
362+ callbacks++
363+ }
364+ return callbacks
365+}
366+
309 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.367 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
310 type globalShortcutHoldKey struct {368 type globalShortcutHoldKey struct {
311 keyval uint369 keyval uint
@@ -464,8 +522,8 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
464 return522 return
465 }523 }
466 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)524 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
467- h.callbacks = make([]gtk.ShortcutFunc, 0)
468 h.registered = make(map[KeyBinding]Action)525 h.registered = make(map[KeyBinding]Action)
526+ h.shortcutRefs = make(map[string]globalShortcutRegistration)
469 h.lastDispatchAt = make(map[Action]time.Time)527 h.lastDispatchAt = make(map[Action]time.Time)
470 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})528 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
471 529
@@ -514,7 +572,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
514 h.window.AddController(&h.controller.EventController)572 h.window.AddController(&h.controller.EventController)
515 573
516 log.Debug().574 log.Debug().
517- Int("shortcuts", len(h.callbacks)).575+ Int("shortcuts", len(h.registered)).
518 Msg("global shortcuts reloaded")576 Msg("global shortcuts reloaded")
519 }577 }
520 578
@@ -532,12 +590,22 @@ func (h *GlobalShortcutHandler) Detach() {
532 if h.window != nil && h.releaseController != nil {590 if h.window != nil && h.releaseController != nil {
533 h.window.RemoveController(&h.releaseController.EventController)591 h.window.RemoveController(&h.releaseController.EventController)
534 }592 }
593+ if h.globalAction != nil && h.globalActionHandlerID != 0 {
594+ h.globalAction.DisconnectSignal(h.globalActionHandlerID)
595+ }
596+ if h.window != nil && h.globalAction != nil {
597+ h.window.RemoveAction(globalShortcutActionName)
598+ }
535 h.controller = nil599 h.controller = nil
536 h.releaseController = nil600 h.releaseController = nil
601+ h.shortcutAction = nil
602+ h.globalAction = nil
603+ h.globalActionCb = nil
604+ h.globalActionHandlerID = 0
537 h.keyReleasedCb = nil605 h.keyReleasedCb = nil
538 h.keyReleasedHandlerID = 0606 h.keyReleasedHandlerID = 0
539- h.callbacks = nil
540 h.registered = nil607 h.registered = nil
608+ h.shortcutRefs = nil
541 h.lastDispatchAt = nil609 h.lastDispatchAt = nil
542 h.heldShortcuts = nil610 h.heldShortcuts = nil
543 }611 }
M internal/ui/input/global_shortcuts_test.go +34 -0
@@ -5,8 +5,42 @@ import (
55 "time"
66
77 "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/bnema/puregotk/v4/gio"
9+ "github.com/bnema/puregotk/v4/gtk"
810 )
911
12+func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {
13+ h := &GlobalShortcutHandler{
14+ registered: make(map[KeyBinding]Action),
15+ shortcutRefs: make(map[string]globalShortcutRegistration),
16+ }
17+
18+ for i := 0; i < 57; i++ {
19+ binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: Modifier(gdk.AltMaskValue)}
20+ action := ActionSwitchTabIndex1
21+ shortcutID := encodeGlobalShortcutID(action, binding)
22+ h.registered[binding] = action
23+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{action: action, binding: binding}
24+ }
25+ if got := h.estimatedPuregoCallbackBudget(); got != 0 {
26+ t.Fatalf("callback budget without retained GTK callbacks = %d, want 0", got)
27+ }
28+
29+ h.globalActionCb = func(_ gio.SimpleAction, _ uintptr) {}
30+ h.keyReleasedCb = func(_ gtk.EventControllerKey, _ uint, _ uint, _ gdk.ModifierType) {}
31+ if got := h.estimatedPuregoCallbackBudget(); got != 2 {
32+ t.Fatalf("callback budget with 57 shortcuts = %d, want 2", got)
33+ }
34+}
35+
36+func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
37+ first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)})
38+ second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)})
39+ if first == second {
40+ t.Fatal("global shortcut IDs should distinguish bindings for the same action")
41+ }
42+}
43+
1044 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
1145 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
1246 now := time.Unix(100, 0)
@@ -5,8 +5,42 @@ import (
5 "time"5 "time"
6 6
7 "github.com/bnema/puregotk/v4/gdk"7 "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/bnema/puregotk/v4/gio"
9+ "github.com/bnema/puregotk/v4/gtk"
8 )10 )
9 11
12+func TestGlobalShortcutHandlerCallbackBudgetIsIndependentOfShortcutCount(t *testing.T) {
13+ h := &GlobalShortcutHandler{
14+ registered: make(map[KeyBinding]Action),
15+ shortcutRefs: make(map[string]globalShortcutRegistration),
16+ }
17+
18+ for i := 0; i < 57; i++ {
19+ binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: Modifier(gdk.AltMaskValue)}
20+ action := ActionSwitchTabIndex1
21+ shortcutID := encodeGlobalShortcutID(action, binding)
22+ h.registered[binding] = action
23+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{action: action, binding: binding}
24+ }
25+ if got := h.estimatedPuregoCallbackBudget(); got != 0 {
26+ t.Fatalf("callback budget without retained GTK callbacks = %d, want 0", got)
27+ }
28+
29+ h.globalActionCb = func(_ gio.SimpleAction, _ uintptr) {}
30+ h.keyReleasedCb = func(_ gtk.EventControllerKey, _ uint, _ uint, _ gdk.ModifierType) {}
31+ if got := h.estimatedPuregoCallbackBudget(); got != 2 {
32+ t.Fatalf("callback budget with 57 shortcuts = %d, want 2", got)
33+ }
34+}
35+
36+func TestGlobalShortcutIDIncludesBinding(t *testing.T) {
37+ first := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)})
38+ second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)})
39+ if first == second {
40+ t.Fatal("global shortcut IDs should distinguish bindings for the same action")
41+ }
42+}
43+
10 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {44 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
11 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}45 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
12 now := time.Unix(100, 0)46 now := time.Unix(100, 0)
M internal/ui/input/keyboard.go +21 -7
@@ -71,10 +71,13 @@ type KeyboardHandler struct {
7171
7272 // GTK controller (nil until attached)
7373 controller *gtk.EventControllerKey
74+ window *gtk.ApplicationWindow
7475
7576 // Callback retention: must stay reachable by Go GC.
76- keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
77- keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
77+ keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
78+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
79+ keyPressedHandlerID uint
80+ keyReleasedHandlerID uint
7881
7982 activePressedActions map[Action]uint
8083 ctx context.Context
@@ -225,29 +228,40 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
225228 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
226229 return h.handleKeyPress(keyval, keycode, state)
227230 }
228- h.controller.ConnectKeyPressed(&h.keyPressedCb)
231+ h.keyPressedHandlerID = h.controller.ConnectKeyPressed(&h.keyPressedCb)
229232
230233 // Connect key released handler for accent detection.
231234 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
232235 h.handleKeyRelease(keyval)
233236 }
234- h.controller.ConnectKeyReleased(&h.keyReleasedCb)
237+ h.keyReleasedHandlerID = h.controller.ConnectKeyReleased(&h.keyReleasedCb)
235238
236239 // Add controller to window
240+ h.window = window
237241 window.AddController(&h.controller.EventController)
238242
239243 log.Debug().Msg("keyboard handler attached to window")
240244 }
241245
242-// Detach removes the keyboard handler.
243-// Note: GTK handles cleanup when the widget is destroyed,
244-// but we clear our reference here.
246+// Detach removes the keyboard handler and releases its GTK signal callbacks.
245247 func (h *KeyboardHandler) Detach() {
246248 h.mu.Lock()
247249 defer h.mu.Unlock()
250+ if h.controller != nil && h.keyPressedHandlerID != 0 {
251+ h.controller.DisconnectSignal(h.keyPressedHandlerID)
252+ }
253+ if h.controller != nil && h.keyReleasedHandlerID != 0 {
254+ h.controller.DisconnectSignal(h.keyReleasedHandlerID)
255+ }
256+ if h.window != nil && h.controller != nil {
257+ h.window.RemoveController(&h.controller.EventController)
258+ }
248259 h.controller = nil
260+ h.window = nil
249261 h.keyPressedCb = nil
250262 h.keyReleasedCb = nil
263+ h.keyPressedHandlerID = 0
264+ h.keyReleasedHandlerID = 0
251265 h.activePressedActions = nil
252266 }
253267
@@ -71,10 +71,13 @@ type KeyboardHandler struct {
71 71
72 // GTK controller (nil until attached)72 // GTK controller (nil until attached)
73 controller *gtk.EventControllerKey73 controller *gtk.EventControllerKey
74+ window *gtk.ApplicationWindow
74 75
75 // Callback retention: must stay reachable by Go GC.76 // Callback retention: must stay reachable by Go GC.
76- keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool77+ keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
77- keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)78+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
79+ keyPressedHandlerID uint
80+ keyReleasedHandlerID uint
78 81
79 activePressedActions map[Action]uint82 activePressedActions map[Action]uint
80 ctx context.Context83 ctx context.Context
@@ -225,29 +228,40 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
225 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {228 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
226 return h.handleKeyPress(keyval, keycode, state)229 return h.handleKeyPress(keyval, keycode, state)
227 }230 }
228- h.controller.ConnectKeyPressed(&h.keyPressedCb)231+ h.keyPressedHandlerID = h.controller.ConnectKeyPressed(&h.keyPressedCb)
229 232
230 // Connect key released handler for accent detection.233 // Connect key released handler for accent detection.
231 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {234 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
232 h.handleKeyRelease(keyval)235 h.handleKeyRelease(keyval)
233 }236 }
234- h.controller.ConnectKeyReleased(&h.keyReleasedCb)237+ h.keyReleasedHandlerID = h.controller.ConnectKeyReleased(&h.keyReleasedCb)
235 238
236 // Add controller to window239 // Add controller to window
240+ h.window = window
237 window.AddController(&h.controller.EventController)241 window.AddController(&h.controller.EventController)
238 242
239 log.Debug().Msg("keyboard handler attached to window")243 log.Debug().Msg("keyboard handler attached to window")
240 }244 }
241 245
242-// Detach removes the keyboard handler.246+// Detach removes the keyboard handler and releases its GTK signal callbacks.
243-// Note: GTK handles cleanup when the widget is destroyed,
244-// but we clear our reference here.
245 func (h *KeyboardHandler) Detach() {247 func (h *KeyboardHandler) Detach() {
246 h.mu.Lock()248 h.mu.Lock()
247 defer h.mu.Unlock()249 defer h.mu.Unlock()
250+ if h.controller != nil && h.keyPressedHandlerID != 0 {
251+ h.controller.DisconnectSignal(h.keyPressedHandlerID)
252+ }
253+ if h.controller != nil && h.keyReleasedHandlerID != 0 {
254+ h.controller.DisconnectSignal(h.keyReleasedHandlerID)
255+ }
256+ if h.window != nil && h.controller != nil {
257+ h.window.RemoveController(&h.controller.EventController)
258+ }
248 h.controller = nil259 h.controller = nil
260+ h.window = nil
249 h.keyPressedCb = nil261 h.keyPressedCb = nil
250 h.keyReleasedCb = nil262 h.keyReleasedCb = nil
263+ h.keyPressedHandlerID = 0
264+ h.keyReleasedHandlerID = 0
251 h.activePressedActions = nil265 h.activePressedActions = nil
252 }266 }
253 267

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