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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +189 -88
M go.mod +3 -3
| @@ -6,11 +6,11 @@ require ( | |||
| 6 | 6 | github.com/a-h/templ v0.3.1020 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | 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 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.3 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | - | github.com/bnema/puregotk v0.5.2 | |
| 13 | + | github.com/bnema/puregotk v0.5.3 | |
| 14 | 14 | github.com/charmbracelet/bubbles v1.0.0 | |
| 15 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 16 | 16 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -6,11 +6,11 @@ require ( | |||||
| 6 | github.com/a-h/templ v0.3.1020 | 6 | github.com/a-h/templ v0.3.1020 | ||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | - | github.com/bnema/purego-cef v0.13.0 | 9 | + | github.com/bnema/purego-cef v0.13.1 |
| 10 | - | github.com/bnema/purego-cef2gtk v0.5.3 | 10 | + | github.com/bnema/purego-cef2gtk v0.5.4 |
| 11 | github.com/bnema/purego-pipewire v0.1.3 | 11 | github.com/bnema/purego-pipewire v0.1.3 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | - | github.com/bnema/puregotk v0.5.2 | 13 | + | github.com/bnema/puregotk v0.5.3 |
| 14 | github.com/charmbracelet/bubbles v1.0.0 | 14 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 15 | github.com/charmbracelet/bubbletea v1.3.10 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 16 | github.com/charmbracelet/lipgloss v1.1.0 | 16 | 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 ( | |||
| 29 | 29 | // GestureHandler handles mouse button gestures for navigation. | |
| 30 | 30 | // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets. | |
| 31 | 31 | type GestureHandler struct { | |
| 32 | - | clickGesture *gtk.GestureClick | |
| 32 | + | clickGesture *gtk.GestureClick | |
| 33 | + | widget *gtk.Widget | |
| 34 | + | pressedHandlerID uint | |
| 35 | + | destroyHandlerID uint | |
| 33 | 36 | ||
| 34 | 37 | // Callback retention: must stay reachable by Go GC. | |
| 35 | 38 | pressedCb func(gtk.GestureClick, int, float64, float64) | |
| 39 | + | destroyCb func(gtk.Widget) | |
| 36 | 40 | ||
| 37 | 41 | // Action handler callback (fallback if no direct navigator) | |
| 38 | 42 | onAction ActionHandler | |
| @@ -81,6 +85,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | |||
| 81 | 85 | log.Error().Msg("cannot attach gesture handler to nil widget") | |
| 82 | 86 | return | |
| 83 | 87 | } | |
| 88 | + | h.detachExistingAttachment() | |
| 84 | 89 | ||
| 85 | 90 | // Create click gesture that listens to all buttons | |
| 86 | 91 | h.clickGesture = gtk.NewGestureClick() | |
| @@ -96,7 +101,12 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | |||
| 96 | 101 | h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) { | |
| 97 | 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 | 111 | // Add controller to widget | |
| 102 | 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. | |
| 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. | |
| 166 | 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 | 197 | h.clickGesture = nil | |
| 198 | + | h.pressedHandlerID = 0 | |
| 199 | + | h.destroyHandlerID = 0 | |
| 168 | 200 | 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 | } | |
| @@ -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.GestureClick | 32 | + | 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 ActionHandler | 42 | 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 | return | 86 | return | ||
| 83 | } | 87 | } | ||
| 88 | + | h.detachExistingAttachment() | |||
| 84 | 89 | ||||
| 85 | // Create click gesture that listens to all buttons | 90 | // 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 widget | 111 | // 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 = nil | 197 | h.clickGesture = nil | ||
| 198 | + | h.pressedHandlerID = 0 | |||
| 199 | + | h.destroyHandlerID = 0 | |||
| 168 | h.pressedCb = nil | 200 | 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 ( | |||
| 19 | 19 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | |
| 20 | 20 | // to intercept shortcuts before they reach the WebView. | |
| 21 | 21 | 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 | |
| 32 | 33 | // generation is mutated only from the GTK main thread alongside controller replacement. | |
| 33 | 34 | generation uint64 | |
| 34 | 35 | ||
| @@ -71,57 +72,8 @@ func NewGlobalShortcutHandler( | |||
| 71 | 72 | return nil | |
| 72 | 73 | } | |
| 73 | 74 | ||
| 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) | |
| 125 | 77 | ||
| 126 | 78 | if workspace != nil { | |
| 127 | 79 | actionMap := globalShortcutActionMap() | |
| @@ -181,6 +133,62 @@ func NewGlobalShortcutHandler( | |||
| 181 | 133 | 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 | 192 | // registerShortcut creates and registers a single shortcut with the controller. | |
| 185 | 193 | func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool { | |
| 186 | 194 | binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask} | |
| @@ -461,18 +469,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 461 | 469 | h.lastDispatchAt = make(map[Action]time.Time) | |
| 462 | 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) | |
| 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) | |
| 476 | 473 | ||
| 477 | 474 | // Re-register config-driven shortcuts | |
| 478 | 475 | if workspace != nil { | |
| @@ -529,12 +526,16 @@ func (h *GlobalShortcutHandler) Detach() { | |||
| 529 | 526 | if h.window != nil && h.controller != nil { | |
| 530 | 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 | 532 | if h.window != nil && h.releaseController != nil { | |
| 533 | 533 | h.window.RemoveController(&h.releaseController.EventController) | |
| 534 | 534 | } | |
| 535 | 535 | h.controller = nil | |
| 536 | 536 | h.releaseController = nil | |
| 537 | 537 | h.keyReleasedCb = nil | |
| 538 | + | h.keyReleasedHandlerID = 0 | |
| 538 | 539 | h.callbacks = nil | |
| 539 | 540 | h.registered = nil | |
| 540 | 541 | h.lastDispatchAt = nil | |
| @@ -19,16 +19,17 @@ import ( | |||||
| 19 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | 19 | // 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.ShortcutController | 22 | + | controller *gtk.ShortcutController |
| 23 | - | releaseController *gtk.EventControllerKey | 23 | + | releaseController *gtk.EventControllerKey |
| 24 | - | window *gtk.ApplicationWindow | 24 | + | window *gtk.ApplicationWindow |
| 25 | - | kbHandler *KeyboardHandler | 25 | + | kbHandler *KeyboardHandler |
| 26 | - | onAction ActionHandler | 26 | + | onAction ActionHandler |
| 27 | - | ctx context.Context | 27 | + | ctx context.Context |
| 28 | - | registered map[KeyBinding]Action | 28 | + | registered map[KeyBinding]Action |
| 29 | - | lastDispatchAt map[Action]time.Time | 29 | + | lastDispatchAt map[Action]time.Time |
| 30 | - | heldShortcuts map[globalShortcutHoldKey]time.Time | 30 | + | 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 uint64 | 34 | generation uint64 | ||
| 34 | 35 | ||||
| @@ -71,57 +72,8 @@ func NewGlobalShortcutHandler( | |||||
| 71 | return nil | 72 | return nil | ||
| 72 | } | 73 | } | ||
| 73 | 74 | ||||
| 74 | - | // Set global scope - this is the key to making shortcuts work | 75 | + | h.configureGlobalControllers() |
| 75 | - | // even when WebView has focus | 76 | + | 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 h | 133 | 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 shortcuts | 474 | // 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 = nil | 535 | h.controller = nil | ||
| 536 | h.releaseController = nil | 536 | h.releaseController = nil | ||
| 537 | h.keyReleasedCb = nil | 537 | h.keyReleasedCb = nil | ||
| 538 | + | h.keyReleasedHandlerID = 0 | |||
| 538 | h.callbacks = nil | 539 | h.callbacks = nil | ||
| 539 | h.registered = nil | 540 | h.registered = nil | ||
| 540 | h.lastDispatchAt = nil | 541 | h.lastDispatchAt = nil | ||
M internal/ui/input/global_shortcuts_test.go +1 -1
| @@ -200,7 +200,7 @@ func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testin | |||
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | 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 | 204 | t.Fatal("unrelated key release re-armed held global shortcut") | |
| 205 | 205 | } | |
| 206 | 206 | ||
| @@ -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) { | |||
| 243 | 243 | // Note: GTK handles cleanup when the widget is destroyed, | |
| 244 | 244 | // but we clear our reference here. | |
| 245 | 245 | func (h *KeyboardHandler) Detach() { | |
| 246 | + | h.mu.Lock() | |
| 247 | + | defer h.mu.Unlock() | |
| 246 | 248 | h.controller = nil | |
| 247 | 249 | h.keyPressedCb = nil | |
| 248 | 250 | 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 = nil | 248 | h.controller = nil | ||
| 247 | h.keyPressedCb = nil | 249 | h.keyPressedCb = nil | ||
| 248 | h.keyReleasedCb = nil | 250 | h.keyReleasedCb = nil | ||