fix(hover): fully detach native controller
- commit
- ca9c39224fac41de50bf8be92ca06529a0dcb1df
- author
- brice · · 2026-07-14 07:13 UTC
- committer
- brice · · 2026-07-14 09:32 UTC
- parent
- d1de38f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +74 -19
M internal/ui/input/hover.go +74 -19
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/bnema/puregotk/v4/glib" | |
| 11 | + | "github.com/bnema/puregotk/v4/gobject" | |
| 11 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -25,22 +26,28 @@ type MotionCallback func() | |||
| 25 | 26 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | |
| 26 | 27 | // It uses a debounce timer to avoid rapid focus switches. | |
| 27 | 28 | type HoverHandler struct { | |
| 28 | - | motionCtrl *gtk.EventControllerMotion | |
| 29 | - | paneID entity.PaneID | |
| 30 | - | onEnter HoverCallback | |
| 31 | - | onMotion MotionCallback | |
| 29 | + | attachedWidget *gtk.Widget | |
| 30 | + | motionCtrl *gtk.EventControllerMotion | |
| 31 | + | enterSignalID uint | |
| 32 | + | leaveSignalID uint | |
| 33 | + | motionSignalID uint | |
| 34 | + | paneID entity.PaneID | |
| 35 | + | onEnter HoverCallback | |
| 36 | + | onMotion MotionCallback | |
| 32 | 37 | ||
| 33 | 38 | // Callback retention: must stay reachable by Go GC. | |
| 34 | 39 | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 35 | 40 | leaveCb func(gtk.EventControllerMotion) | |
| 36 | 41 | motionCb func(gtk.EventControllerMotion, float64, float64) | |
| 37 | 42 | ||
| 38 | - | timerMu sync.Mutex | |
| 39 | - | sourceID uint | |
| 40 | - | generation uint64 | |
| 41 | - | detached bool | |
| 42 | - | schedule func(uint, func(uintptr) bool) uint | |
| 43 | - | removeSource func(uint) bool | |
| 43 | + | timerMu sync.Mutex | |
| 44 | + | sourceID uint | |
| 45 | + | generation uint64 | |
| 46 | + | detached bool | |
| 47 | + | schedule func(uint, func(uintptr) bool) uint | |
| 48 | + | removeSource func(uint) bool | |
| 49 | + | disconnectSignal func(*gtk.EventControllerMotion, uint) | |
| 50 | + | removeController func(*gtk.Widget, *gtk.EventController) | |
| 44 | 51 | ||
| 45 | 52 | ctx context.Context | |
| 46 | 53 | } | |
| @@ -51,10 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler { | |||
| 51 | 58 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | |
| 52 | 59 | ||
| 53 | 60 | return &HoverHandler{ | |
| 54 | - | ctx: ctx, | |
| 55 | - | paneID: paneID, | |
| 56 | - | schedule: scheduleHoverSource, | |
| 57 | - | removeSource: glib.SourceRemove, | |
| 61 | + | ctx: ctx, | |
| 62 | + | paneID: paneID, | |
| 63 | + | schedule: scheduleHoverSource, | |
| 64 | + | removeSource: glib.SourceRemove, | |
| 65 | + | disconnectSignal: disconnectHoverSignal, | |
| 66 | + | removeController: removeHoverController, | |
| 58 | 67 | } | |
| 59 | 68 | } | |
| 60 | 69 | ||
| @@ -79,6 +88,15 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 79 | 88 | return | |
| 80 | 89 | } | |
| 81 | 90 | ||
| 91 | + | // Reattachment must not leave a controller owned by the previous widget. | |
| 92 | + | if h.motionCtrl != nil || h.attachedWidget != nil { | |
| 93 | + | h.Detach() | |
| 94 | + | } | |
| 95 | + | h.timerMu.Lock() | |
| 96 | + | h.detached = false | |
| 97 | + | h.attachedWidget = widget | |
| 98 | + | h.timerMu.Unlock() | |
| 99 | + | ||
| 82 | 100 | h.motionCtrl = gtk.NewEventControllerMotion() | |
| 83 | 101 | if h.motionCtrl == nil { | |
| 84 | 102 | log.Error().Msg("failed to create motion controller") | |
| @@ -89,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 89 | 107 | h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |
| 90 | 108 | h.handleEnter() | |
| 91 | 109 | } | |
| 92 | - | h.motionCtrl.ConnectEnter(&h.enterCb) | |
| 110 | + | h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb) | |
| 93 | 111 | ||
| 94 | 112 | // Connect leave handler to cancel pending focus | |
| 95 | 113 | h.leaveCb = func(_ gtk.EventControllerMotion) { | |
| 96 | 114 | h.handleLeave() | |
| 97 | 115 | } | |
| 98 | - | h.motionCtrl.ConnectLeave(&h.leaveCb) | |
| 116 | + | h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb) | |
| 99 | 117 | ||
| 100 | 118 | // Connect motion handler to detect intentional mouse movement. | |
| 101 | 119 | // Unlike enter/leave, motion only fires when the cursor physically moves | |
| @@ -105,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 105 | 123 | h.onMotion() | |
| 106 | 124 | } | |
| 107 | 125 | } | |
| 108 | - | h.motionCtrl.ConnectMotion(&h.motionCb) | |
| 126 | + | h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb) | |
| 109 | 127 | ||
| 110 | 128 | // Add controller to widget | |
| 111 | 129 | widget.AddController(&h.motionCtrl.EventController) | |
| @@ -183,10 +201,47 @@ func (h *HoverHandler) Detach() { | |||
| 183 | 201 | h.cancelSourceLocked() | |
| 184 | 202 | h.onEnter = nil | |
| 185 | 203 | h.onMotion = nil | |
| 186 | - | h.timerMu.Unlock() | |
| 187 | - | ||
| 204 | + | widget := h.attachedWidget | |
| 205 | + | controller := h.motionCtrl | |
| 206 | + | enterID := h.enterSignalID | |
| 207 | + | leaveID := h.leaveSignalID | |
| 208 | + | motionID := h.motionSignalID | |
| 209 | + | h.attachedWidget = nil | |
| 188 | 210 | h.motionCtrl = nil | |
| 211 | + | h.enterSignalID = 0 | |
| 212 | + | h.leaveSignalID = 0 | |
| 213 | + | h.motionSignalID = 0 | |
| 189 | 214 | h.enterCb = nil | |
| 190 | 215 | h.leaveCb = nil | |
| 191 | 216 | h.motionCb = nil | |
| 217 | + | disconnectSignal := h.disconnectSignal | |
| 218 | + | removeController := h.removeController | |
| 219 | + | h.timerMu.Unlock() | |
| 220 | + | ||
| 221 | + | if controller == nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | if disconnectSignal != nil { | |
| 225 | + | for _, id := range []uint{enterID, leaveID, motionID} { | |
| 226 | + | if id != 0 { | |
| 227 | + | disconnectSignal(controller, id) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | if widget != nil && removeController != nil { | |
| 232 | + | removeController(widget, &controller.EventController) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func disconnectHoverSignal(controller *gtk.EventControllerMotion, id uint) { | |
| 237 | + | if controller == nil || id == 0 || controller.GoPointer() == 0 { | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), id) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func removeHoverController(widget *gtk.Widget, controller *gtk.EventController) { | |
| 244 | + | if widget != nil && controller != nil { | |
| 245 | + | widget.RemoveController(controller) | |
| 246 | + | } | |
| 192 | 247 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/puregotk/v4/glib" | 10 | "github.com/bnema/puregotk/v4/glib" | ||
| 11 | + | "github.com/bnema/puregotk/v4/gobject" | |||
| 11 | "github.com/bnema/puregotk/v4/gtk" | 12 | "github.com/bnema/puregotk/v4/gtk" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| @@ -25,22 +26,28 @@ type MotionCallback func() | |||||
| 25 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | 26 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | ||
| 26 | // It uses a debounce timer to avoid rapid focus switches. | 27 | // It uses a debounce timer to avoid rapid focus switches. | ||
| 27 | type HoverHandler struct { | 28 | type HoverHandler struct { | ||
| 28 | - | motionCtrl *gtk.EventControllerMotion | 29 | + | attachedWidget *gtk.Widget |
| 29 | - | paneID entity.PaneID | 30 | + | motionCtrl *gtk.EventControllerMotion |
| 30 | - | onEnter HoverCallback | 31 | + | enterSignalID uint |
| 31 | - | onMotion MotionCallback | 32 | + | leaveSignalID uint |
| 33 | + | motionSignalID uint | |||
| 34 | + | paneID entity.PaneID | |||
| 35 | + | onEnter HoverCallback | |||
| 36 | + | onMotion MotionCallback | |||
| 32 | 37 | ||||
| 33 | // Callback retention: must stay reachable by Go GC. | 38 | // Callback retention: must stay reachable by Go GC. | ||
| 34 | enterCb func(gtk.EventControllerMotion, float64, float64) | 39 | enterCb func(gtk.EventControllerMotion, float64, float64) | ||
| 35 | leaveCb func(gtk.EventControllerMotion) | 40 | leaveCb func(gtk.EventControllerMotion) | ||
| 36 | motionCb func(gtk.EventControllerMotion, float64, float64) | 41 | motionCb func(gtk.EventControllerMotion, float64, float64) | ||
| 37 | 42 | ||||
| 38 | - | timerMu sync.Mutex | 43 | + | timerMu sync.Mutex |
| 39 | - | sourceID uint | 44 | + | sourceID uint |
| 40 | - | generation uint64 | 45 | + | generation uint64 |
| 41 | - | detached bool | 46 | + | detached bool |
| 42 | - | schedule func(uint, func(uintptr) bool) uint | 47 | + | schedule func(uint, func(uintptr) bool) uint |
| 43 | - | removeSource func(uint) bool | 48 | + | removeSource func(uint) bool |
| 49 | + | disconnectSignal func(*gtk.EventControllerMotion, uint) | |||
| 50 | + | removeController func(*gtk.Widget, *gtk.EventController) | |||
| 44 | 51 | ||||
| 45 | ctx context.Context | 52 | ctx context.Context | ||
| 46 | } | 53 | } | ||
| @@ -51,10 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler { | |||||
| 51 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | 58 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | ||
| 52 | 59 | ||||
| 53 | return &HoverHandler{ | 60 | return &HoverHandler{ | ||
| 54 | - | ctx: ctx, | 61 | + | ctx: ctx, |
| 55 | - | paneID: paneID, | 62 | + | paneID: paneID, |
| 56 | - | schedule: scheduleHoverSource, | 63 | + | schedule: scheduleHoverSource, |
| 57 | - | removeSource: glib.SourceRemove, | 64 | + | removeSource: glib.SourceRemove, |
| 65 | + | disconnectSignal: disconnectHoverSignal, | |||
| 66 | + | removeController: removeHoverController, | |||
| 58 | } | 67 | } | ||
| 59 | } | 68 | } | ||
| 60 | 69 | ||||
| @@ -79,6 +88,15 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 79 | return | 88 | return | ||
| 80 | } | 89 | } | ||
| 81 | 90 | ||||
| 91 | + | // Reattachment must not leave a controller owned by the previous widget. | |||
| 92 | + | if h.motionCtrl != nil || h.attachedWidget != nil { | |||
| 93 | + | h.Detach() | |||
| 94 | + | } | |||
| 95 | + | h.timerMu.Lock() | |||
| 96 | + | h.detached = false | |||
| 97 | + | h.attachedWidget = widget | |||
| 98 | + | h.timerMu.Unlock() | |||
| 99 | + | ||||
| 82 | h.motionCtrl = gtk.NewEventControllerMotion() | 100 | h.motionCtrl = gtk.NewEventControllerMotion() | ||
| 83 | if h.motionCtrl == nil { | 101 | if h.motionCtrl == nil { | ||
| 84 | log.Error().Msg("failed to create motion controller") | 102 | log.Error().Msg("failed to create motion controller") | ||
| @@ -89,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 89 | h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | 107 | h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | ||
| 90 | h.handleEnter() | 108 | h.handleEnter() | ||
| 91 | } | 109 | } | ||
| 92 | - | h.motionCtrl.ConnectEnter(&h.enterCb) | 110 | + | h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb) |
| 93 | 111 | ||||
| 94 | // Connect leave handler to cancel pending focus | 112 | // Connect leave handler to cancel pending focus | ||
| 95 | h.leaveCb = func(_ gtk.EventControllerMotion) { | 113 | h.leaveCb = func(_ gtk.EventControllerMotion) { | ||
| 96 | h.handleLeave() | 114 | h.handleLeave() | ||
| 97 | } | 115 | } | ||
| 98 | - | h.motionCtrl.ConnectLeave(&h.leaveCb) | 116 | + | h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb) |
| 99 | 117 | ||||
| 100 | // Connect motion handler to detect intentional mouse movement. | 118 | // Connect motion handler to detect intentional mouse movement. | ||
| 101 | // Unlike enter/leave, motion only fires when the cursor physically moves | 119 | // Unlike enter/leave, motion only fires when the cursor physically moves | ||
| @@ -105,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 105 | h.onMotion() | 123 | h.onMotion() | ||
| 106 | } | 124 | } | ||
| 107 | } | 125 | } | ||
| 108 | - | h.motionCtrl.ConnectMotion(&h.motionCb) | 126 | + | h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb) |
| 109 | 127 | ||||
| 110 | // Add controller to widget | 128 | // Add controller to widget | ||
| 111 | widget.AddController(&h.motionCtrl.EventController) | 129 | widget.AddController(&h.motionCtrl.EventController) | ||
| @@ -183,10 +201,47 @@ func (h *HoverHandler) Detach() { | |||||
| 183 | h.cancelSourceLocked() | 201 | h.cancelSourceLocked() | ||
| 184 | h.onEnter = nil | 202 | h.onEnter = nil | ||
| 185 | h.onMotion = nil | 203 | h.onMotion = nil | ||
| 186 | - | h.timerMu.Unlock() | 204 | + | widget := h.attachedWidget |
| 187 | - | 205 | + | controller := h.motionCtrl | |
| 206 | + | enterID := h.enterSignalID | |||
| 207 | + | leaveID := h.leaveSignalID | |||
| 208 | + | motionID := h.motionSignalID | |||
| 209 | + | h.attachedWidget = nil | |||
| 188 | h.motionCtrl = nil | 210 | h.motionCtrl = nil | ||
| 211 | + | h.enterSignalID = 0 | |||
| 212 | + | h.leaveSignalID = 0 | |||
| 213 | + | h.motionSignalID = 0 | |||
| 189 | h.enterCb = nil | 214 | h.enterCb = nil | ||
| 190 | h.leaveCb = nil | 215 | h.leaveCb = nil | ||
| 191 | h.motionCb = nil | 216 | h.motionCb = nil | ||
| 217 | + | disconnectSignal := h.disconnectSignal | |||
| 218 | + | removeController := h.removeController | |||
| 219 | + | h.timerMu.Unlock() | |||
| 220 | + | ||||
| 221 | + | if controller == nil { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | if disconnectSignal != nil { | |||
| 225 | + | for _, id := range []uint{enterID, leaveID, motionID} { | |||
| 226 | + | if id != 0 { | |||
| 227 | + | disconnectSignal(controller, id) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | if widget != nil && removeController != nil { | |||
| 232 | + | removeController(widget, &controller.EventController) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func disconnectHoverSignal(controller *gtk.EventControllerMotion, id uint) { | |||
| 237 | + | if controller == nil || id == 0 || controller.GoPointer() == 0 { | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), id) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func removeHoverController(widget *gtk.Widget, controller *gtk.EventController) { | |||
| 244 | + | if widget != nil && controller != nil { | |||
| 245 | + | widget.RemoveController(controller) | |||
| 246 | + | } | |||
| 192 | } | 247 | } | ||