detached ca9c392

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +74 -19
M internal/ui/input/hover.go +74 -19
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
1010 "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gobject"
1112 "github.com/bnema/puregotk/v4/gtk"
1213 )
1314
@@ -25,22 +26,28 @@ type MotionCallback func()
2526 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.
2627 // It uses a debounce timer to avoid rapid focus switches.
2728 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
3237
3338 // Callback retention: must stay reachable by Go GC.
3439 enterCb func(gtk.EventControllerMotion, float64, float64)
3540 leaveCb func(gtk.EventControllerMotion)
3641 motionCb func(gtk.EventControllerMotion, float64, float64)
3742
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)
4451
4552 ctx context.Context
4653 }
@@ -51,10 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
5158 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
5259
5360 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,
5867 }
5968 }
6069
@@ -79,6 +88,15 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
7988 return
8089 }
8190
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+
82100 h.motionCtrl = gtk.NewEventControllerMotion()
83101 if h.motionCtrl == nil {
84102 log.Error().Msg("failed to create motion controller")
@@ -89,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
89107 h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
90108 h.handleEnter()
91109 }
92- h.motionCtrl.ConnectEnter(&h.enterCb)
110+ h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb)
93111
94112 // Connect leave handler to cancel pending focus
95113 h.leaveCb = func(_ gtk.EventControllerMotion) {
96114 h.handleLeave()
97115 }
98- h.motionCtrl.ConnectLeave(&h.leaveCb)
116+ h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb)
99117
100118 // Connect motion handler to detect intentional mouse movement.
101119 // Unlike enter/leave, motion only fires when the cursor physically moves
@@ -105,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
105123 h.onMotion()
106124 }
107125 }
108- h.motionCtrl.ConnectMotion(&h.motionCb)
126+ h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb)
109127
110128 // Add controller to widget
111129 widget.AddController(&h.motionCtrl.EventController)
@@ -183,10 +201,47 @@ func (h *HoverHandler) Detach() {
183201 h.cancelSourceLocked()
184202 h.onEnter = nil
185203 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
188210 h.motionCtrl = nil
211+ h.enterSignalID = 0
212+ h.leaveSignalID = 0
213+ h.motionSignalID = 0
189214 h.enterCb = nil
190215 h.leaveCb = nil
191216 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+ }
192247 }
@@ -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.EventControllerMotion29+ attachedWidget *gtk.Widget
29- paneID entity.PaneID30+ motionCtrl *gtk.EventControllerMotion
30- onEnter HoverCallback31+ enterSignalID uint
31- onMotion MotionCallback32+ 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.Mutex43+ timerMu sync.Mutex
39- sourceID uint44+ sourceID uint
40- generation uint6445+ generation uint64
41- detached bool46+ detached bool
42- schedule func(uint, func(uintptr) bool) uint47+ schedule func(uint, func(uintptr) bool) uint
43- removeSource func(uint) bool48+ removeSource func(uint) bool
49+ disconnectSignal func(*gtk.EventControllerMotion, uint)
50+ removeController func(*gtk.Widget, *gtk.EventController)
44 51
45 ctx context.Context52 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 return88 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 focus112 // 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 moves119 // 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 widget128 // 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 = nil202 h.onEnter = nil
185 h.onMotion = nil203 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 = nil210 h.motionCtrl = nil
211+ h.enterSignalID = 0
212+ h.leaveSignalID = 0
213+ h.motionSignalID = 0
189 h.enterCb = nil214 h.enterCb = nil
190 h.leaveCb = nil215 h.leaveCb = nil
191 h.motionCb = nil216 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 }

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