detached bd18fcb

fix(input): release gesture callbacks

commit
bd18fcbcb246881638ee80aefffbe443f4d14e91
author
brice · · 2026-05-24 15:38 UTC
committer
brice · · 2026-05-24 19:43 UTC
parent
49f66aa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +189 -88
M go.mod +3 -3
@@ -6,11 +6,11 @@ require (
66 github.com/a-h/templ v0.3.1020
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
9- github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.5.3
9+ github.com/bnema/purego-cef v0.13.1
10+ github.com/bnema/purego-cef2gtk v0.5.4
1111 github.com/bnema/purego-pipewire v0.1.3
1212 github.com/bnema/purego-sqlite v0.1.2
13- github.com/bnema/puregotk v0.5.2
13+ github.com/bnema/puregotk v0.5.3
1414 github.com/charmbracelet/bubbles v1.0.0
1515 github.com/charmbracelet/bubbletea v1.3.10
1616 github.com/charmbracelet/lipgloss v1.1.0
@@ -6,11 +6,11 @@ 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.28 github.com/bnema/purego v0.11.0-bnema.2
9- github.com/bnema/purego-cef v0.13.09+ github.com/bnema/purego-cef v0.13.1
10- github.com/bnema/purego-cef2gtk v0.5.310+ github.com/bnema/purego-cef2gtk v0.5.4
11 github.com/bnema/purego-pipewire v0.1.311 github.com/bnema/purego-pipewire v0.1.3
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
13- github.com/bnema/puregotk v0.5.213+ github.com/bnema/puregotk v0.5.3
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 +6 -6 generated file: collapsed
M internal/ui/input/gesture.go +51 -5
@@ -29,10 +29,14 @@ const (
2929 // GestureHandler handles mouse button gestures for navigation.
3030 // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.
3131 type GestureHandler struct {
32- clickGesture *gtk.GestureClick
32+ clickGesture *gtk.GestureClick
33+ widget *gtk.Widget
34+ pressedHandlerID uint
35+ destroyHandlerID uint
3336
3437 // Callback retention: must stay reachable by Go GC.
3538 pressedCb func(gtk.GestureClick, int, float64, float64)
39+ destroyCb func(gtk.Widget)
3640
3741 // Action handler callback (fallback if no direct navigator)
3842 onAction ActionHandler
@@ -81,6 +85,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
8185 log.Error().Msg("cannot attach gesture handler to nil widget")
8286 return
8387 }
88+ h.detachExistingAttachment()
8489
8590 // Create click gesture that listens to all buttons
8691 h.clickGesture = gtk.NewGestureClick()
@@ -96,7 +101,12 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
96101 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
97102 h.handlePressed(nPress)
98103 }
99- h.clickGesture.ConnectPressed(&h.pressedCb)
104+ h.pressedHandlerID = h.clickGesture.ConnectPressed(&h.pressedCb)
105+ h.widget = widget
106+ h.destroyCb = func(_ gtk.Widget) {
107+ h.Detach()
108+ }
109+ h.destroyHandlerID = widget.ConnectDestroy(&h.destroyCb)
100110
101111 // Add controller to widget
102112 widget.AddController(&h.clickGesture.EventController)
@@ -160,10 +170,46 @@ func (h *GestureHandler) handlePressed(nPress int) {
160170 }
161171 }
162172
163-// Detach removes the gesture handler.
164-// Note: GTK handles cleanup when the widget is destroyed,
165-// but we clear our reference here.
173+func (h *GestureHandler) detachExistingAttachment() {
174+ if h == nil {
175+ return
176+ }
177+ h.mu.RLock()
178+ attached := h.widget != nil || h.clickGesture != nil || h.pressedHandlerID != 0 || h.destroyHandlerID != 0
179+ h.mu.RUnlock()
180+ if attached {
181+ h.Detach()
182+ }
183+}
184+
185+// Detach removes the gesture handler and disconnects its GTK signals.
166186 func (h *GestureHandler) Detach() {
187+ if h == nil {
188+ return
189+ }
190+ h.mu.Lock()
191+ widget := h.widget
192+ clickGesture := h.clickGesture
193+ pressedHandlerID := h.pressedHandlerID
194+ destroyHandlerID := h.destroyHandlerID
195+
196+ h.widget = nil
167197 h.clickGesture = nil
198+ h.pressedHandlerID = 0
199+ h.destroyHandlerID = 0
168200 h.pressedCb = nil
201+ h.destroyCb = nil
202+ h.onAction = nil
203+ h.navigator = nil
204+ h.mu.Unlock()
205+
206+ if clickGesture != nil && pressedHandlerID != 0 {
207+ clickGesture.DisconnectSignal(pressedHandlerID)
208+ }
209+ if widget != nil && destroyHandlerID != 0 {
210+ widget.DisconnectSignal(destroyHandlerID)
211+ }
212+ if widget != nil && clickGesture != nil {
213+ widget.RemoveController(&clickGesture.EventController)
214+ }
169215 }
@@ -29,10 +29,14 @@ const (
29 // GestureHandler handles mouse button gestures for navigation.29 // GestureHandler handles mouse button gestures for navigation.
30 // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.30 // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.
31 type GestureHandler struct {31 type GestureHandler struct {
32- clickGesture *gtk.GestureClick32+ clickGesture *gtk.GestureClick
33+ widget *gtk.Widget
34+ pressedHandlerID uint
35+ destroyHandlerID uint
33 36
34 // Callback retention: must stay reachable by Go GC.37 // Callback retention: must stay reachable by Go GC.
35 pressedCb func(gtk.GestureClick, int, float64, float64)38 pressedCb func(gtk.GestureClick, int, float64, float64)
39+ destroyCb func(gtk.Widget)
36 40
37 // Action handler callback (fallback if no direct navigator)41 // Action handler callback (fallback if no direct navigator)
38 onAction ActionHandler42 onAction ActionHandler
@@ -81,6 +85,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
81 log.Error().Msg("cannot attach gesture handler to nil widget")85 log.Error().Msg("cannot attach gesture handler to nil widget")
82 return86 return
83 }87 }
88+ h.detachExistingAttachment()
84 89
85 // Create click gesture that listens to all buttons90 // Create click gesture that listens to all buttons
86 h.clickGesture = gtk.NewGestureClick()91 h.clickGesture = gtk.NewGestureClick()
@@ -96,7 +101,12 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
96 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {101 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
97 h.handlePressed(nPress)102 h.handlePressed(nPress)
98 }103 }
99- h.clickGesture.ConnectPressed(&h.pressedCb)104+ h.pressedHandlerID = h.clickGesture.ConnectPressed(&h.pressedCb)
105+ h.widget = widget
106+ h.destroyCb = func(_ gtk.Widget) {
107+ h.Detach()
108+ }
109+ h.destroyHandlerID = widget.ConnectDestroy(&h.destroyCb)
100 110
101 // Add controller to widget111 // Add controller to widget
102 widget.AddController(&h.clickGesture.EventController)112 widget.AddController(&h.clickGesture.EventController)
@@ -160,10 +170,46 @@ func (h *GestureHandler) handlePressed(nPress int) {
160 }170 }
161 }171 }
162 172
163-// Detach removes the gesture handler.173+func (h *GestureHandler) detachExistingAttachment() {
164-// Note: GTK handles cleanup when the widget is destroyed,174+ if h == nil {
165-// but we clear our reference here.175+ return
176+ }
177+ h.mu.RLock()
178+ attached := h.widget != nil || h.clickGesture != nil || h.pressedHandlerID != 0 || h.destroyHandlerID != 0
179+ h.mu.RUnlock()
180+ if attached {
181+ h.Detach()
182+ }
183+}
184+
185+// Detach removes the gesture handler and disconnects its GTK signals.
166 func (h *GestureHandler) Detach() {186 func (h *GestureHandler) Detach() {
187+ if h == nil {
188+ return
189+ }
190+ h.mu.Lock()
191+ widget := h.widget
192+ clickGesture := h.clickGesture
193+ pressedHandlerID := h.pressedHandlerID
194+ destroyHandlerID := h.destroyHandlerID
195+
196+ h.widget = nil
167 h.clickGesture = nil197 h.clickGesture = nil
198+ h.pressedHandlerID = 0
199+ h.destroyHandlerID = 0
168 h.pressedCb = nil200 h.pressedCb = nil
201+ h.destroyCb = nil
202+ h.onAction = nil
203+ h.navigator = nil
204+ h.mu.Unlock()
205+
206+ if clickGesture != nil && pressedHandlerID != 0 {
207+ clickGesture.DisconnectSignal(pressedHandlerID)
208+ }
209+ if widget != nil && destroyHandlerID != 0 {
210+ widget.DisconnectSignal(destroyHandlerID)
211+ }
212+ if widget != nil && clickGesture != nil {
213+ widget.RemoveController(&clickGesture.EventController)
214+ }
169 }215 }
A internal/ui/input/gesture_test.go +52 -0
@@ -0,0 +1,52 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/puregotk/v4/gtk"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type testGestureNavigator struct{}
12+
13+func (testGestureNavigator) GoBackDirect() {}
14+func (testGestureNavigator) GoForwardDirect() {}
15+
16+func TestGestureHandlerAttachClearsExistingAttachmentState(t *testing.T) {
17+ h := NewGestureHandler(context.Background())
18+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
19+ h.destroyCb = func(gtk.Widget) {}
20+ h.pressedHandlerID = 1
21+ h.destroyHandlerID = 2
22+ h.onAction = func(context.Context, Action) error { return nil }
23+ h.navigator = testGestureNavigator{}
24+
25+ h.detachExistingAttachment()
26+
27+ assert.Nil(t, h.pressedCb)
28+ assert.Nil(t, h.destroyCb)
29+ assert.Zero(t, h.pressedHandlerID)
30+ assert.Zero(t, h.destroyHandlerID)
31+ assert.Nil(t, h.onAction)
32+ assert.Nil(t, h.navigator)
33+}
34+
35+func TestGestureHandlerDetachClearsRetainedCallbacksAndState(t *testing.T) {
36+ h := NewGestureHandler(context.Background())
37+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
38+ h.destroyCb = func(gtk.Widget) {}
39+ h.pressedHandlerID = 1
40+ h.destroyHandlerID = 2
41+ h.onAction = func(context.Context, Action) error { return nil }
42+ h.navigator = testGestureNavigator{}
43+
44+ h.Detach()
45+
46+ assert.Nil(t, h.pressedCb)
47+ assert.Nil(t, h.destroyCb)
48+ assert.Zero(t, h.pressedHandlerID)
49+ assert.Zero(t, h.destroyHandlerID)
50+ assert.Nil(t, h.onAction)
51+ assert.Nil(t, h.navigator)
52+}
@@ -0,0 +1,52 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/puregotk/v4/gtk"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type testGestureNavigator struct{}
12+
13+func (testGestureNavigator) GoBackDirect() {}
14+func (testGestureNavigator) GoForwardDirect() {}
15+
16+func TestGestureHandlerAttachClearsExistingAttachmentState(t *testing.T) {
17+ h := NewGestureHandler(context.Background())
18+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
19+ h.destroyCb = func(gtk.Widget) {}
20+ h.pressedHandlerID = 1
21+ h.destroyHandlerID = 2
22+ h.onAction = func(context.Context, Action) error { return nil }
23+ h.navigator = testGestureNavigator{}
24+
25+ h.detachExistingAttachment()
26+
27+ assert.Nil(t, h.pressedCb)
28+ assert.Nil(t, h.destroyCb)
29+ assert.Zero(t, h.pressedHandlerID)
30+ assert.Zero(t, h.destroyHandlerID)
31+ assert.Nil(t, h.onAction)
32+ assert.Nil(t, h.navigator)
33+}
34+
35+func TestGestureHandlerDetachClearsRetainedCallbacksAndState(t *testing.T) {
36+ h := NewGestureHandler(context.Background())
37+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
38+ h.destroyCb = func(gtk.Widget) {}
39+ h.pressedHandlerID = 1
40+ h.destroyHandlerID = 2
41+ h.onAction = func(context.Context, Action) error { return nil }
42+ h.navigator = testGestureNavigator{}
43+
44+ h.Detach()
45+
46+ assert.Nil(t, h.pressedCb)
47+ assert.Nil(t, h.destroyCb)
48+ assert.Zero(t, h.pressedHandlerID)
49+ assert.Zero(t, h.destroyHandlerID)
50+ assert.Nil(t, h.onAction)
51+ assert.Nil(t, h.navigator)
52+}
M internal/ui/input/global_shortcuts.go +74 -73
@@ -19,16 +19,17 @@ import (
1919 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
2020 // to intercept shortcuts before they reach the WebView.
2121 type GlobalShortcutHandler struct {
22- controller *gtk.ShortcutController
23- releaseController *gtk.EventControllerKey
24- window *gtk.ApplicationWindow
25- kbHandler *KeyboardHandler
26- onAction ActionHandler
27- ctx context.Context
28- registered map[KeyBinding]Action
29- lastDispatchAt map[Action]time.Time
30- heldShortcuts map[globalShortcutHoldKey]time.Time
31- keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
22+ controller *gtk.ShortcutController
23+ releaseController *gtk.EventControllerKey
24+ window *gtk.ApplicationWindow
25+ kbHandler *KeyboardHandler
26+ onAction ActionHandler
27+ ctx context.Context
28+ registered map[KeyBinding]Action
29+ lastDispatchAt map[Action]time.Time
30+ heldShortcuts map[globalShortcutHoldKey]time.Time
31+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
32+ keyReleasedHandlerID uint
3233 // generation is mutated only from the GTK main thread alongside controller replacement.
3334 generation uint64
3435
@@ -71,57 +72,8 @@ func NewGlobalShortcutHandler(
7172 return nil
7273 }
7374
74- // Set global scope - this is the key to making shortcuts work
75- // even when WebView has focus
76- h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
77- h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
78- h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
79- h.releaseHeldGlobalShortcuts(keyval, keycode)
80- }
81- h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
82-
83- // Register Alt+1 through Alt+9 for tab switching
84- tabActions := []Action{
85- ActionSwitchTabIndex1,
86- ActionSwitchTabIndex2,
87- ActionSwitchTabIndex3,
88- ActionSwitchTabIndex4,
89- ActionSwitchTabIndex5,
90- ActionSwitchTabIndex6,
91- ActionSwitchTabIndex7,
92- ActionSwitchTabIndex8,
93- ActionSwitchTabIndex9,
94- }
95-
96- for i, action := range tabActions {
97- keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
98- h.registerShortcut(keyval, gdk.AltMaskValue, action)
99- log.Trace().
100- Uint("keyval", keyval).
101- Str("action", string(action)).
102- Msg("registered global shortcut")
103- }
104-
105- // Alt+0 for tab 10
106- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
107- log.Trace().
108- Uint("keyval", uint(gdk.KEY_0)).
109- Str("action", string(ActionSwitchTabIndex10)).
110- Msg("registered global shortcut")
111-
112- // Alt+Tab for switching to last active tab
113- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
114- log.Trace().
115- Uint("keyval", uint(gdk.KEY_Tab)).
116- Str("action", string(ActionSwitchLastTab)).
117- Msg("registered global shortcut")
118-
119- // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
120- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
121- log.Trace().
122- Uint("keyval", uint(gdk.KEY_s)).
123- Str("action", string(ActionOpenSessionManager)).
124- Msg("registered global shortcut")
75+ h.configureGlobalControllers()
76+ h.registerDefaultGlobalShortcuts(log)
12577
12678 if workspace != nil {
12779 actionMap := globalShortcutActionMap()
@@ -181,6 +133,62 @@ func NewGlobalShortcutHandler(
181133 return h
182134 }
183135
136+func (h *GlobalShortcutHandler) configureGlobalControllers() {
137+ // Set global scope - this is the key to making shortcuts work
138+ // even when WebView has focus.
139+ h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
140+ h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
141+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
142+ h.releaseHeldGlobalShortcuts(keyval, keycode)
143+ }
144+ h.keyReleasedHandlerID = h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
145+}
146+
147+func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logger) {
148+ // Register Alt+1 through Alt+9 for tab switching.
149+ tabActions := []Action{
150+ ActionSwitchTabIndex1,
151+ ActionSwitchTabIndex2,
152+ ActionSwitchTabIndex3,
153+ ActionSwitchTabIndex4,
154+ ActionSwitchTabIndex5,
155+ ActionSwitchTabIndex6,
156+ ActionSwitchTabIndex7,
157+ ActionSwitchTabIndex8,
158+ ActionSwitchTabIndex9,
159+ }
160+
161+ for i, action := range tabActions {
162+ keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
163+ h.registerShortcut(keyval, gdk.AltMaskValue, action)
164+ log.Trace().
165+ Uint("keyval", keyval).
166+ Str("action", string(action)).
167+ Msg("registered global shortcut")
168+ }
169+
170+ // Alt+0 for tab 10.
171+ h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
172+ log.Trace().
173+ Uint("keyval", uint(gdk.KEY_0)).
174+ Str("action", string(ActionSwitchTabIndex10)).
175+ Msg("registered global shortcut")
176+
177+ // Alt+Tab for switching to last active tab.
178+ h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
179+ log.Trace().
180+ Uint("keyval", uint(gdk.KEY_Tab)).
181+ Str("action", string(ActionSwitchLastTab)).
182+ Msg("registered global shortcut")
183+
184+ // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
185+ h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
186+ log.Trace().
187+ Uint("keyval", uint(gdk.KEY_s)).
188+ Str("action", string(ActionOpenSessionManager)).
189+ Msg("registered global shortcut")
190+}
191+
184192 // registerShortcut creates and registers a single shortcut with the controller.
185193 func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
186194 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
@@ -461,18 +469,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
461469 h.lastDispatchAt = make(map[Action]time.Time)
462470 h.heldShortcuts = make(map[globalShortcutHoldKey]time.Time)
463471
464- // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
465- tabActions := []Action{
466- ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3,
467- ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6,
468- ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9,
469- }
470- for i, action := range tabActions {
471- h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action)
472- }
473- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
474- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
475- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
472+ h.registerDefaultGlobalShortcuts(log)
476473
477474 // Re-register config-driven shortcuts
478475 if workspace != nil {
@@ -529,12 +526,16 @@ func (h *GlobalShortcutHandler) Detach() {
529526 if h.window != nil && h.controller != nil {
530527 h.window.RemoveController(&h.controller.EventController)
531528 }
529+ if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
530+ h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
531+ }
532532 if h.window != nil && h.releaseController != nil {
533533 h.window.RemoveController(&h.releaseController.EventController)
534534 }
535535 h.controller = nil
536536 h.releaseController = nil
537537 h.keyReleasedCb = nil
538+ h.keyReleasedHandlerID = 0
538539 h.callbacks = nil
539540 h.registered = nil
540541 h.lastDispatchAt = nil
@@ -19,16 +19,17 @@ import (
19 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL19 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
20 // to intercept shortcuts before they reach the WebView.20 // to intercept shortcuts before they reach the WebView.
21 type GlobalShortcutHandler struct {21 type GlobalShortcutHandler struct {
22- controller *gtk.ShortcutController22+ controller *gtk.ShortcutController
23- releaseController *gtk.EventControllerKey23+ releaseController *gtk.EventControllerKey
24- window *gtk.ApplicationWindow24+ window *gtk.ApplicationWindow
25- kbHandler *KeyboardHandler25+ kbHandler *KeyboardHandler
26- onAction ActionHandler26+ onAction ActionHandler
27- ctx context.Context27+ ctx context.Context
28- registered map[KeyBinding]Action28+ registered map[KeyBinding]Action
29- lastDispatchAt map[Action]time.Time29+ lastDispatchAt map[Action]time.Time
30- heldShortcuts map[globalShortcutHoldKey]time.Time30+ heldShortcuts map[globalShortcutHoldKey]time.Time
31- keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)31+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
32+ keyReleasedHandlerID uint
32 // generation is mutated only from the GTK main thread alongside controller replacement.33 // generation is mutated only from the GTK main thread alongside controller replacement.
33 generation uint6434 generation uint64
34 35
@@ -71,57 +72,8 @@ func NewGlobalShortcutHandler(
71 return nil72 return nil
72 }73 }
73 74
74- // Set global scope - this is the key to making shortcuts work75+ h.configureGlobalControllers()
75- // even when WebView has focus76+ h.registerDefaultGlobalShortcuts(log)
76- h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
77- h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
78- h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
79- h.releaseHeldGlobalShortcuts(keyval, keycode)
80- }
81- h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
82-
83- // Register Alt+1 through Alt+9 for tab switching
84- tabActions := []Action{
85- ActionSwitchTabIndex1,
86- ActionSwitchTabIndex2,
87- ActionSwitchTabIndex3,
88- ActionSwitchTabIndex4,
89- ActionSwitchTabIndex5,
90- ActionSwitchTabIndex6,
91- ActionSwitchTabIndex7,
92- ActionSwitchTabIndex8,
93- ActionSwitchTabIndex9,
94- }
95-
96- for i, action := range tabActions {
97- keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
98- h.registerShortcut(keyval, gdk.AltMaskValue, action)
99- log.Trace().
100- Uint("keyval", keyval).
101- Str("action", string(action)).
102- Msg("registered global shortcut")
103- }
104-
105- // Alt+0 for tab 10
106- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
107- log.Trace().
108- Uint("keyval", uint(gdk.KEY_0)).
109- Str("action", string(ActionSwitchTabIndex10)).
110- Msg("registered global shortcut")
111-
112- // Alt+Tab for switching to last active tab
113- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
114- log.Trace().
115- Uint("keyval", uint(gdk.KEY_Tab)).
116- Str("action", string(ActionSwitchLastTab)).
117- Msg("registered global shortcut")
118-
119- // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
120- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
121- log.Trace().
122- Uint("keyval", uint(gdk.KEY_s)).
123- Str("action", string(ActionOpenSessionManager)).
124- Msg("registered global shortcut")
125 77
126 if workspace != nil {78 if workspace != nil {
127 actionMap := globalShortcutActionMap()79 actionMap := globalShortcutActionMap()
@@ -181,6 +133,62 @@ func NewGlobalShortcutHandler(
181 return h133 return h
182 }134 }
183 135
136+func (h *GlobalShortcutHandler) configureGlobalControllers() {
137+ // Set global scope - this is the key to making shortcuts work
138+ // even when WebView has focus.
139+ h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
140+ h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
141+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
142+ h.releaseHeldGlobalShortcuts(keyval, keycode)
143+ }
144+ h.keyReleasedHandlerID = h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
145+}
146+
147+func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logger) {
148+ // Register Alt+1 through Alt+9 for tab switching.
149+ tabActions := []Action{
150+ ActionSwitchTabIndex1,
151+ ActionSwitchTabIndex2,
152+ ActionSwitchTabIndex3,
153+ ActionSwitchTabIndex4,
154+ ActionSwitchTabIndex5,
155+ ActionSwitchTabIndex6,
156+ ActionSwitchTabIndex7,
157+ ActionSwitchTabIndex8,
158+ ActionSwitchTabIndex9,
159+ }
160+
161+ for i, action := range tabActions {
162+ keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
163+ h.registerShortcut(keyval, gdk.AltMaskValue, action)
164+ log.Trace().
165+ Uint("keyval", keyval).
166+ Str("action", string(action)).
167+ Msg("registered global shortcut")
168+ }
169+
170+ // Alt+0 for tab 10.
171+ h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
172+ log.Trace().
173+ Uint("keyval", uint(gdk.KEY_0)).
174+ Str("action", string(ActionSwitchTabIndex10)).
175+ Msg("registered global shortcut")
176+
177+ // Alt+Tab for switching to last active tab.
178+ h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
179+ log.Trace().
180+ Uint("keyval", uint(gdk.KEY_Tab)).
181+ Str("action", string(ActionSwitchLastTab)).
182+ Msg("registered global shortcut")
183+
184+ // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
185+ h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
186+ log.Trace().
187+ Uint("keyval", uint(gdk.KEY_s)).
188+ Str("action", string(ActionOpenSessionManager)).
189+ Msg("registered global shortcut")
190+}
191+
184 // registerShortcut creates and registers a single shortcut with the controller.192 // registerShortcut creates and registers a single shortcut with the controller.
185 func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {193 func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
186 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}194 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
@@ -461,18 +469,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
461 h.lastDispatchAt = make(map[Action]time.Time)469 h.lastDispatchAt = make(map[Action]time.Time)
462 h.heldShortcuts = make(map[globalShortcutHoldKey]time.Time)470 h.heldShortcuts = make(map[globalShortcutHoldKey]time.Time)
463 471
464- // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)472+ h.registerDefaultGlobalShortcuts(log)
465- tabActions := []Action{
466- ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3,
467- ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6,
468- ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9,
469- }
470- for i, action := range tabActions {
471- h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action)
472- }
473- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
474- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
475- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
476 473
477 // Re-register config-driven shortcuts474 // Re-register config-driven shortcuts
478 if workspace != nil {475 if workspace != nil {
@@ -529,12 +526,16 @@ func (h *GlobalShortcutHandler) Detach() {
529 if h.window != nil && h.controller != nil {526 if h.window != nil && h.controller != nil {
530 h.window.RemoveController(&h.controller.EventController)527 h.window.RemoveController(&h.controller.EventController)
531 }528 }
529+ if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
530+ h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
531+ }
532 if h.window != nil && h.releaseController != nil {532 if h.window != nil && h.releaseController != nil {
533 h.window.RemoveController(&h.releaseController.EventController)533 h.window.RemoveController(&h.releaseController.EventController)
534 }534 }
535 h.controller = nil535 h.controller = nil
536 h.releaseController = nil536 h.releaseController = nil
537 h.keyReleasedCb = nil537 h.keyReleasedCb = nil
538+ h.keyReleasedHandlerID = 0
538 h.callbacks = nil539 h.callbacks = nil
539 h.registered = nil540 h.registered = nil
540 h.lastDispatchAt = nil541 h.lastDispatchAt = nil
M internal/ui/input/global_shortcuts_test.go +1 -1
@@ -200,7 +200,7 @@ func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testin
200200 }
201201
202202 h.releaseHeldGlobalShortcuts(uint('x'), 0)
203- if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(102, 0)) {
203+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(101, 0)) {
204204 t.Fatal("unrelated key release re-armed held global shortcut")
205205 }
206206
@@ -200,7 +200,7 @@ func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testin
200 }200 }
201 201
202 h.releaseHeldGlobalShortcuts(uint('x'), 0)202 h.releaseHeldGlobalShortcuts(uint('x'), 0)
203- if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(102, 0)) {203+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info, time.Unix(101, 0)) {
204 t.Fatal("unrelated key release re-armed held global shortcut")204 t.Fatal("unrelated key release re-armed held global shortcut")
205 }205 }
206 206
M internal/ui/input/keyboard.go +2 -0
@@ -243,6 +243,8 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
243243 // Note: GTK handles cleanup when the widget is destroyed,
244244 // but we clear our reference here.
245245 func (h *KeyboardHandler) Detach() {
246+ h.mu.Lock()
247+ defer h.mu.Unlock()
246248 h.controller = nil
247249 h.keyPressedCb = nil
248250 h.keyReleasedCb = nil
@@ -243,6 +243,8 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
243 // Note: GTK handles cleanup when the widget is destroyed,243 // Note: GTK handles cleanup when the widget is destroyed,
244 // but we clear our reference here.244 // but we clear our reference here.
245 func (h *KeyboardHandler) Detach() {245 func (h *KeyboardHandler) Detach() {
246+ h.mu.Lock()
247+ defer h.mu.Unlock()
246 h.controller = nil248 h.controller = nil
247 h.keyPressedCb = nil249 h.keyPressedCb = nil
248 h.keyReleasedCb = nil250 h.keyReleasedCb = nil

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