detached 9807be4

Merge pull request #281 from bnema/refactor/callback-budget-releases

refactor(input): reduce browser window callback pressure
commit
9807be418976f027ba81066882e131372b2ddb02
author
bnema · · 2026-06-09 19:41 UTC
committer
GitHub · · 2026-06-09 19:41 UTC
merge of
ff7151a + 1d4e329 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +265 -121
M go.mod +4 -4
@@ -5,12 +5,12 @@ go 1.26.3
55 require (
66 github.com/a-h/templ v0.3.1020
77 github.com/andybalholm/brotli v1.2.1
8- github.com/bnema/purego v0.11.0-bnema.3
9- github.com/bnema/purego-cef v0.13.2
10- github.com/bnema/purego-cef2gtk v0.8.0
8+ github.com/bnema/purego v0.11.0-bnema.4
9+ github.com/bnema/purego-cef v0.13.3
10+ github.com/bnema/purego-cef2gtk v0.8.1
1111 github.com/bnema/purego-pipewire v0.1.4
1212 github.com/bnema/purego-sqlite v0.1.3
13- github.com/bnema/puregotk v0.6.0
13+ github.com/bnema/puregotk v0.7.0
1414 github.com/charmbracelet/bubbles v1.0.0
1515 github.com/charmbracelet/bubbletea v1.3.10
1616 github.com/charmbracelet/lipgloss v1.1.0
@@ -5,12 +5,12 @@ go 1.26.3
5 require (5 require (
6 github.com/a-h/templ v0.3.10206 github.com/a-h/templ v0.3.1020
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8- github.com/bnema/purego v0.11.0-bnema.38+ github.com/bnema/purego v0.11.0-bnema.4
9- github.com/bnema/purego-cef v0.13.29+ github.com/bnema/purego-cef v0.13.3
10- github.com/bnema/purego-cef2gtk v0.8.010+ github.com/bnema/purego-cef2gtk v0.8.1
11 github.com/bnema/purego-pipewire v0.1.411 github.com/bnema/purego-pipewire v0.1.4
12 github.com/bnema/purego-sqlite v0.1.312 github.com/bnema/purego-sqlite v0.1.3
13- github.com/bnema/puregotk v0.6.013+ github.com/bnema/puregotk v0.7.0
14 github.com/charmbracelet/bubbles v1.0.014 github.com/charmbracelet/bubbles v1.0.0
15 github.com/charmbracelet/bubbletea v1.3.1015 github.com/charmbracelet/bubbletea v1.3.10
16 github.com/charmbracelet/lipgloss v1.1.016 github.com/charmbracelet/lipgloss v1.1.0
M go.sum +8 -8 generated file: collapsed
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 +173 -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,136 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
300237 return false
301238 }
302239
303- // Add to controller
240+ generation := h.generation
241+ shortcutID := encodeGlobalShortcutID(action, binding, generation)
242+ shortcut.SetArguments(glib.NewVariantString(shortcutID))
243+
244+ // Add to controller.
304245 h.controller.AddShortcut(shortcut)
305246 h.registered[binding] = action
247+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{
248+ action: action,
249+ binding: binding,
250+ generation: generation,
251+ }
306252 return true
307253 }
308254
255+func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
256+ log := logging.FromContext(h.ctx)
257+ registration, ok := h.globalShortcutRegistration(parameter)
258+ if !ok {
259+ log.Warn().Msg("global shortcut activated without registered argument")
260+ return
261+ }
262+ h.dispatchGlobalShortcut(registration.action, registration.binding, registration.generation)
263+}
264+
265+func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
266+ if parameter == 0 || h.shortcutRefs == nil {
267+ return globalShortcutRegistration{}, false
268+ }
269+ variant := glib.VariantNewFromInternalPtr(parameter)
270+ if variant == nil {
271+ return globalShortcutRegistration{}, false
272+ }
273+ shortcutID := variant.GetString(nil)
274+ registration, ok := h.shortcutRefs[shortcutID]
275+ return registration, ok
276+}
277+
278+func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
279+ log := logging.FromContext(h.ctx)
280+ if h.isStaleGeneration(generation) {
281+ log.Trace().
282+ Str("action", string(actionToDispatch)).
283+ Str("shortcut", formatBinding(bindingForLog)).
284+ Uint64("callback_generation", generation).
285+ Uint64("current_generation", h.generation).
286+ Msg("stale global shortcut callback ignored")
287+ return true
288+ }
289+ if !h.isActiveWindowShortcutHandler() {
290+ log.Trace().
291+ Str("action", string(actionToDispatch)).
292+ Str("shortcut", formatBinding(bindingForLog)).
293+ Msg("inactive window global shortcut callback ignored")
294+ return false
295+ }
296+ eventInfo := h.inspectCurrentShortcutEvent()
297+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
298+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
299+ Msg("global shortcut ignored without current key event")
300+ return true
301+ }
302+ if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
303+ log.Trace().
304+ Str("action", string(actionToDispatch)).
305+ Str("shortcut", formatBinding(bindingForLog)).
306+ Msg("held global shortcut repeat suppressed")
307+ return true
308+ }
309+ if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
310+ log.Trace().
311+ Str("action", string(actionToDispatch)).
312+ Str("shortcut", formatBinding(bindingForLog)).
313+ Msg("global shortcut repeat suppressed")
314+ return true
315+ }
316+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
317+ Msg("global shortcut triggered")
318+
319+ // Mode-enter/exit actions go through KeyboardHandler for modal state.
320+ if isModeAction(actionToDispatch) {
321+ if h.kbHandler != nil {
322+ h.kbHandler.DispatchAction(actionToDispatch)
323+ return true
324+ }
325+ log.Warn().
326+ Str("action", string(actionToDispatch)).
327+ Msg("mode action triggered but keyboard handler not set, falling through to default handler")
328+ }
329+
330+ if h.onAction != nil {
331+ if err := h.onAction(h.ctx, actionToDispatch); err != nil {
332+ log.Error().
333+ Err(err).
334+ Str("action", string(actionToDispatch)).
335+ Msg("global shortcut action failed")
336+ }
337+ }
338+ return true
339+}
340+
341+const (
342+ globalShortcutActionName = "dumber-global-shortcut"
343+ globalShortcutActionFullName = "win." + globalShortcutActionName
344+)
345+
346+type globalShortcutRegistration struct {
347+ action Action
348+ binding KeyBinding
349+ generation uint64
350+}
351+
352+func encodeGlobalShortcutID(action Action, binding KeyBinding, generation uint64) string {
353+ return fmt.Sprintf("%s\x1f%d\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers, generation)
354+}
355+
356+func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
357+ if h == nil {
358+ return 0
359+ }
360+ callbacks := 0
361+ if h.globalActionCb != nil {
362+ callbacks++
363+ }
364+ if h.keyReleasedCb != nil {
365+ callbacks++
366+ }
367+ return callbacks
368+}
369+
309370 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
310371 type globalShortcutHoldKey struct {
311372 keyval uint
@@ -464,8 +525,8 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
464525 return
465526 }
466527 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
467- h.callbacks = make([]gtk.ShortcutFunc, 0)
468528 h.registered = make(map[KeyBinding]Action)
529+ h.shortcutRefs = make(map[string]globalShortcutRegistration)
469530 h.lastDispatchAt = make(map[Action]time.Time)
470531 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
471532
@@ -514,7 +575,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
514575 h.window.AddController(&h.controller.EventController)
515576
516577 log.Debug().
517- Int("shortcuts", len(h.callbacks)).
578+ Int("shortcuts", len(h.registered)).
518579 Msg("global shortcuts reloaded")
519580 }
520581
@@ -532,12 +593,22 @@ func (h *GlobalShortcutHandler) Detach() {
532593 if h.window != nil && h.releaseController != nil {
533594 h.window.RemoveController(&h.releaseController.EventController)
534595 }
596+ if h.globalAction != nil && h.globalActionHandlerID != 0 {
597+ h.globalAction.DisconnectSignal(h.globalActionHandlerID)
598+ }
599+ if h.window != nil && h.globalAction != nil {
600+ h.window.RemoveAction(globalShortcutActionName)
601+ }
535602 h.controller = nil
536603 h.releaseController = nil
604+ h.shortcutAction = nil
605+ h.globalAction = nil
606+ h.globalActionCb = nil
607+ h.globalActionHandlerID = 0
537608 h.keyReleasedCb = nil
538609 h.keyReleasedHandlerID = 0
539- h.callbacks = nil
540610 h.registered = nil
611+ h.shortcutRefs = nil
541612 h.lastDispatchAt = nil
542613 h.heldShortcuts = nil
543614 }
@@ -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,136 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
300 return false237 return false
301 }238 }
302 239
303- // Add to controller240+ generation := h.generation
241+ shortcutID := encodeGlobalShortcutID(action, binding, generation)
242+ shortcut.SetArguments(glib.NewVariantString(shortcutID))
243+
244+ // Add to controller.
304 h.controller.AddShortcut(shortcut)245 h.controller.AddShortcut(shortcut)
305 h.registered[binding] = action246 h.registered[binding] = action
247+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{
248+ action: action,
249+ binding: binding,
250+ generation: generation,
251+ }
306 return true252 return true
307 }253 }
308 254
255+func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
256+ log := logging.FromContext(h.ctx)
257+ registration, ok := h.globalShortcutRegistration(parameter)
258+ if !ok {
259+ log.Warn().Msg("global shortcut activated without registered argument")
260+ return
261+ }
262+ h.dispatchGlobalShortcut(registration.action, registration.binding, registration.generation)
263+}
264+
265+func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
266+ if parameter == 0 || h.shortcutRefs == nil {
267+ return globalShortcutRegistration{}, false
268+ }
269+ variant := glib.VariantNewFromInternalPtr(parameter)
270+ if variant == nil {
271+ return globalShortcutRegistration{}, false
272+ }
273+ shortcutID := variant.GetString(nil)
274+ registration, ok := h.shortcutRefs[shortcutID]
275+ return registration, ok
276+}
277+
278+func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
279+ log := logging.FromContext(h.ctx)
280+ if h.isStaleGeneration(generation) {
281+ log.Trace().
282+ Str("action", string(actionToDispatch)).
283+ Str("shortcut", formatBinding(bindingForLog)).
284+ Uint64("callback_generation", generation).
285+ Uint64("current_generation", h.generation).
286+ Msg("stale global shortcut callback ignored")
287+ return true
288+ }
289+ if !h.isActiveWindowShortcutHandler() {
290+ log.Trace().
291+ Str("action", string(actionToDispatch)).
292+ Str("shortcut", formatBinding(bindingForLog)).
293+ Msg("inactive window global shortcut callback ignored")
294+ return false
295+ }
296+ eventInfo := h.inspectCurrentShortcutEvent()
297+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
298+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
299+ Msg("global shortcut ignored without current key event")
300+ return true
301+ }
302+ if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
303+ log.Trace().
304+ Str("action", string(actionToDispatch)).
305+ Str("shortcut", formatBinding(bindingForLog)).
306+ Msg("held global shortcut repeat suppressed")
307+ return true
308+ }
309+ if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
310+ log.Trace().
311+ Str("action", string(actionToDispatch)).
312+ Str("shortcut", formatBinding(bindingForLog)).
313+ Msg("global shortcut repeat suppressed")
314+ return true
315+ }
316+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
317+ Msg("global shortcut triggered")
318+
319+ // Mode-enter/exit actions go through KeyboardHandler for modal state.
320+ if isModeAction(actionToDispatch) {
321+ if h.kbHandler != nil {
322+ h.kbHandler.DispatchAction(actionToDispatch)
323+ return true
324+ }
325+ log.Warn().
326+ Str("action", string(actionToDispatch)).
327+ Msg("mode action triggered but keyboard handler not set, falling through to default handler")
328+ }
329+
330+ if h.onAction != nil {
331+ if err := h.onAction(h.ctx, actionToDispatch); err != nil {
332+ log.Error().
333+ Err(err).
334+ Str("action", string(actionToDispatch)).
335+ Msg("global shortcut action failed")
336+ }
337+ }
338+ return true
339+}
340+
341+const (
342+ globalShortcutActionName = "dumber-global-shortcut"
343+ globalShortcutActionFullName = "win." + globalShortcutActionName
344+)
345+
346+type globalShortcutRegistration struct {
347+ action Action
348+ binding KeyBinding
349+ generation uint64
350+}
351+
352+func encodeGlobalShortcutID(action Action, binding KeyBinding, generation uint64) string {
353+ return fmt.Sprintf("%s\x1f%d\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers, generation)
354+}
355+
356+func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
357+ if h == nil {
358+ return 0
359+ }
360+ callbacks := 0
361+ if h.globalActionCb != nil {
362+ callbacks++
363+ }
364+ if h.keyReleasedCb != nil {
365+ callbacks++
366+ }
367+ return callbacks
368+}
369+
309 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.370 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
310 type globalShortcutHoldKey struct {371 type globalShortcutHoldKey struct {
311 keyval uint372 keyval uint
@@ -464,8 +525,8 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
464 return525 return
465 }526 }
466 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)527 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
467- h.callbacks = make([]gtk.ShortcutFunc, 0)
468 h.registered = make(map[KeyBinding]Action)528 h.registered = make(map[KeyBinding]Action)
529+ h.shortcutRefs = make(map[string]globalShortcutRegistration)
469 h.lastDispatchAt = make(map[Action]time.Time)530 h.lastDispatchAt = make(map[Action]time.Time)
470 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})531 h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
471 532
@@ -514,7 +575,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
514 h.window.AddController(&h.controller.EventController)575 h.window.AddController(&h.controller.EventController)
515 576
516 log.Debug().577 log.Debug().
517- Int("shortcuts", len(h.callbacks)).578+ Int("shortcuts", len(h.registered)).
518 Msg("global shortcuts reloaded")579 Msg("global shortcuts reloaded")
519 }580 }
520 581
@@ -532,12 +593,22 @@ func (h *GlobalShortcutHandler) Detach() {
532 if h.window != nil && h.releaseController != nil {593 if h.window != nil && h.releaseController != nil {
533 h.window.RemoveController(&h.releaseController.EventController)594 h.window.RemoveController(&h.releaseController.EventController)
534 }595 }
596+ if h.globalAction != nil && h.globalActionHandlerID != 0 {
597+ h.globalAction.DisconnectSignal(h.globalActionHandlerID)
598+ }
599+ if h.window != nil && h.globalAction != nil {
600+ h.window.RemoveAction(globalShortcutActionName)
601+ }
535 h.controller = nil602 h.controller = nil
536 h.releaseController = nil603 h.releaseController = nil
604+ h.shortcutAction = nil
605+ h.globalAction = nil
606+ h.globalActionCb = nil
607+ h.globalActionHandlerID = 0
537 h.keyReleasedCb = nil608 h.keyReleasedCb = nil
538 h.keyReleasedHandlerID = 0609 h.keyReleasedHandlerID = 0
539- h.callbacks = nil
540 h.registered = nil610 h.registered = nil
611+ h.shortcutRefs = nil
541 h.lastDispatchAt = nil612 h.lastDispatchAt = nil
542 h.heldShortcuts = nil613 h.heldShortcuts = nil
543 }614 }
M internal/ui/input/global_shortcuts_test.go +43 -0
@@ -5,8 +5,51 @@ 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, h.generation)
22+ h.registered[binding] = action
23+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{action: action, binding: binding, generation: h.generation}
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)}, 0)
38+ second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
39+ if first == second {
40+ t.Fatal("global shortcut IDs should distinguish bindings for the same action")
41+ }
42+}
43+
44+func TestGlobalShortcutIDIncludesGeneration(t *testing.T) {
45+ binding := KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}
46+ first := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 1)
47+ second := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 2)
48+ if first == second {
49+ t.Fatal("global shortcut IDs should distinguish registrations across reload generations")
50+ }
51+}
52+
1053 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
1154 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
1255 now := time.Unix(100, 0)
@@ -5,8 +5,51 @@ 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, h.generation)
22+ h.registered[binding] = action
23+ h.shortcutRefs[shortcutID] = globalShortcutRegistration{action: action, binding: binding, generation: h.generation}
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)}, 0)
38+ second := encodeGlobalShortcutID(ActionSwitchTabIndex1, KeyBinding{Keyval: uint(gdk.KEY_2), Modifiers: Modifier(gdk.AltMaskValue)}, 0)
39+ if first == second {
40+ t.Fatal("global shortcut IDs should distinguish bindings for the same action")
41+ }
42+}
43+
44+func TestGlobalShortcutIDIncludesGeneration(t *testing.T) {
45+ binding := KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: Modifier(gdk.AltMaskValue)}
46+ first := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 1)
47+ second := encodeGlobalShortcutID(ActionSwitchTabIndex1, binding, 2)
48+ if first == second {
49+ t.Fatal("global shortcut IDs should distinguish registrations across reload generations")
50+ }
51+}
52+
10 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {53 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
11 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}54 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
12 now := time.Unix(100, 0)55 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