detached 9c48930

fix(hover): schedule debounced focus on GLib

commit
9c48930ac93503e4bbadfd20a70f96c56119b93a
author
brice · · 2026-07-14 06:16 UTC
committer
brice · · 2026-07-14 07:06 UTC
parent
3737c79
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +55 -32
M internal/ui/input/hover.go +55 -32
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk/v4/glib"
1011 "github.com/bnema/puregotk/v4/gtk"
1112 )
1213
@@ -34,9 +35,12 @@ type HoverHandler struct {
3435 leaveCb func(gtk.EventControllerMotion)
3536 motionCb func(gtk.EventControllerMotion, float64, float64)
3637
37- timer *time.Timer
38- timerMu sync.Mutex
39- canceled bool
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
4044
4145 ctx context.Context
4246 }
@@ -47,8 +51,10 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
4751 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
4852
4953 return &HoverHandler{
50- ctx: ctx,
51- paneID: paneID,
54+ ctx: ctx,
55+ paneID: paneID,
56+ schedule: scheduleHoverSource,
57+ removeSource: glib.SourceRemove,
5258 }
5359 }
5460
@@ -107,32 +113,49 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
107113 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
108114 }
109115
116+// scheduleHoverSource schedules a one-shot callback on GTK's owning GLib main
117+// context. GTK state must never be touched by a Go timer goroutine.
118+func scheduleHoverSource(delay uint, fn func(uintptr) bool) uint {
119+ cb := glib.SourceFunc(fn)
120+ return glib.TimeoutAdd(delay, &cb, 0)
121+}
122+
110123 // handleEnter processes the mouse enter event with debouncing.
111124 func (h *HoverHandler) handleEnter() {
112125 h.timerMu.Lock()
113- defer h.timerMu.Unlock()
114-
115- // Cancel any existing timer
116- if h.timer != nil {
117- h.timer.Stop()
126+ if h.detached {
127+ h.timerMu.Unlock()
128+ return
118129 }
119-
120- h.canceled = false
121-
122- // Start new debounce timer
123- h.timer = time.AfterFunc(HoverFocusDelay, func() {
130+ h.cancelSourceLocked()
131+ h.generation++
132+ generation := h.generation
133+ schedule := h.schedule
134+ h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool {
124135 h.timerMu.Lock()
125- canceled := h.canceled
126- h.timerMu.Unlock()
127-
128- if canceled {
129- return
136+ if h.detached || h.generation != generation {
137+ h.timerMu.Unlock()
138+ return false
130139 }
140+ h.sourceID = 0
141+ callback := h.onEnter
142+ paneID := h.paneID
143+ h.timerMu.Unlock()
131144
132- if h.onEnter != nil {
133- h.onEnter(h.paneID)
145+ if callback != nil {
146+ callback(paneID)
134147 }
148+ return false
135149 })
150+ h.timerMu.Unlock()
151+}
152+
153+func (h *HoverHandler) cancelSourceLocked() {
154+ if h.sourceID == 0 {
155+ return
156+ }
157+ h.removeSource(h.sourceID)
158+ h.sourceID = 0
136159 }
137160
138161 // handleLeave cancels any pending focus switch.
@@ -146,20 +169,20 @@ func (h *HoverHandler) Cancel() {
146169 h.timerMu.Lock()
147170 defer h.timerMu.Unlock()
148171
149- h.canceled = true
150- if h.timer != nil {
151- h.timer.Stop()
152- h.timer = nil
153- }
172+ h.generation++
173+ h.cancelSourceLocked()
154174 }
155175
156-// Detach removes the hover handler.
176+// Detach removes the hover handler and invalidates callbacks already queued on
177+// the GLib main context. The generation check is required because removing a
178+// source can race with dispatch.
157179 func (h *HoverHandler) Detach() {
158180 h.timerMu.Lock()
159- if h.timer != nil {
160- h.timer.Stop()
161- h.timer = nil
162- }
181+ h.detached = true
182+ h.generation++
183+ h.cancelSourceLocked()
184+ h.onEnter = nil
185+ h.onMotion = nil
163186 h.timerMu.Unlock()
164187
165188 h.motionCtrl = nil
@@ -7,6 +7,7 @@ import (
7 7
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/gtk"11 "github.com/bnema/puregotk/v4/gtk"
11 )12 )
12 13
@@ -34,9 +35,12 @@ type HoverHandler struct {
34 leaveCb func(gtk.EventControllerMotion)35 leaveCb func(gtk.EventControllerMotion)
35 motionCb func(gtk.EventControllerMotion, float64, float64)36 motionCb func(gtk.EventControllerMotion, float64, float64)
36 37
37- timer *time.Timer38+ timerMu sync.Mutex
38- timerMu sync.Mutex39+ sourceID uint
39- canceled bool40+ generation uint64
41+ detached bool
42+ schedule func(uint, func(uintptr) bool) uint
43+ removeSource func(uint) bool
40 44
41 ctx context.Context45 ctx context.Context
42 }46 }
@@ -47,8 +51,10 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
47 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")51 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
48 52
49 return &HoverHandler{53 return &HoverHandler{
50- ctx: ctx,54+ ctx: ctx,
51- paneID: paneID,55+ paneID: paneID,
56+ schedule: scheduleHoverSource,
57+ removeSource: glib.SourceRemove,
52 }58 }
53 }59 }
54 60
@@ -107,32 +113,49 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
107 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")113 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
108 }114 }
109 115
116+// scheduleHoverSource schedules a one-shot callback on GTK's owning GLib main
117+// context. GTK state must never be touched by a Go timer goroutine.
118+func scheduleHoverSource(delay uint, fn func(uintptr) bool) uint {
119+ cb := glib.SourceFunc(fn)
120+ return glib.TimeoutAdd(delay, &cb, 0)
121+}
122+
110 // handleEnter processes the mouse enter event with debouncing.123 // handleEnter processes the mouse enter event with debouncing.
111 func (h *HoverHandler) handleEnter() {124 func (h *HoverHandler) handleEnter() {
112 h.timerMu.Lock()125 h.timerMu.Lock()
113- defer h.timerMu.Unlock()126+ if h.detached {
114-127+ h.timerMu.Unlock()
115- // Cancel any existing timer128+ return
116- if h.timer != nil {
117- h.timer.Stop()
118 }129 }
119-130+ h.cancelSourceLocked()
120- h.canceled = false131+ h.generation++
121-132+ generation := h.generation
122- // Start new debounce timer133+ schedule := h.schedule
123- h.timer = time.AfterFunc(HoverFocusDelay, func() {134+ h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool {
124 h.timerMu.Lock()135 h.timerMu.Lock()
125- canceled := h.canceled136+ if h.detached || h.generation != generation {
126- h.timerMu.Unlock()137+ h.timerMu.Unlock()
127-138+ return false
128- if canceled {
129- return
130 }139 }
140+ h.sourceID = 0
141+ callback := h.onEnter
142+ paneID := h.paneID
143+ h.timerMu.Unlock()
131 144
132- if h.onEnter != nil {145+ if callback != nil {
133- h.onEnter(h.paneID)146+ callback(paneID)
134 }147 }
148+ return false
135 })149 })
150+ h.timerMu.Unlock()
151+}
152+
153+func (h *HoverHandler) cancelSourceLocked() {
154+ if h.sourceID == 0 {
155+ return
156+ }
157+ h.removeSource(h.sourceID)
158+ h.sourceID = 0
136 }159 }
137 160
138 // handleLeave cancels any pending focus switch.161 // handleLeave cancels any pending focus switch.
@@ -146,20 +169,20 @@ func (h *HoverHandler) Cancel() {
146 h.timerMu.Lock()169 h.timerMu.Lock()
147 defer h.timerMu.Unlock()170 defer h.timerMu.Unlock()
148 171
149- h.canceled = true172+ h.generation++
150- if h.timer != nil {173+ h.cancelSourceLocked()
151- h.timer.Stop()
152- h.timer = nil
153- }
154 }174 }
155 175
156-// Detach removes the hover handler.176+// Detach removes the hover handler and invalidates callbacks already queued on
177+// the GLib main context. The generation check is required because removing a
178+// source can race with dispatch.
157 func (h *HoverHandler) Detach() {179 func (h *HoverHandler) Detach() {
158 h.timerMu.Lock()180 h.timerMu.Lock()
159- if h.timer != nil {181+ h.detached = true
160- h.timer.Stop()182+ h.generation++
161- h.timer = nil183+ h.cancelSourceLocked()
162- }184+ h.onEnter = nil
185+ h.onMotion = nil
163 h.timerMu.Unlock()186 h.timerMu.Unlock()
164 187
165 h.motionCtrl = nil188 h.motionCtrl = 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