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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +55 -32
M internal/ui/input/hover.go +55 -32
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |
| 10 | 11 | "github.com/bnema/puregotk/v4/gtk" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -34,9 +35,12 @@ type HoverHandler struct { | |||
| 34 | 35 | leaveCb func(gtk.EventControllerMotion) | |
| 35 | 36 | motionCb func(gtk.EventControllerMotion, float64, float64) | |
| 36 | 37 | ||
| 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 | |
| 40 | 44 | ||
| 41 | 45 | ctx context.Context | |
| 42 | 46 | } | |
| @@ -47,8 +51,10 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler { | |||
| 47 | 51 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | |
| 48 | 52 | ||
| 49 | 53 | return &HoverHandler{ | |
| 50 | - | ctx: ctx, | |
| 51 | - | paneID: paneID, | |
| 54 | + | ctx: ctx, | |
| 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 | 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 | 123 | // handleEnter processes the mouse enter event with debouncing. | |
| 111 | 124 | func (h *HoverHandler) handleEnter() { | |
| 112 | 125 | 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 | |
| 118 | 129 | } | |
| 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 { | |
| 124 | 135 | 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 | |
| 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 { | |
| 133 | - | h.onEnter(h.paneID) | |
| 145 | + | if callback != nil { | |
| 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 | 161 | // handleLeave cancels any pending focus switch. | |
| @@ -146,20 +169,20 @@ func (h *HoverHandler) Cancel() { | |||
| 146 | 169 | h.timerMu.Lock() | |
| 147 | 170 | defer h.timerMu.Unlock() | |
| 148 | 171 | ||
| 149 | - | h.canceled = true | |
| 150 | - | if h.timer != nil { | |
| 151 | - | h.timer.Stop() | |
| 152 | - | h.timer = nil | |
| 153 | - | } | |
| 172 | + | h.generation++ | |
| 173 | + | h.cancelSourceLocked() | |
| 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 | 179 | func (h *HoverHandler) Detach() { | |
| 158 | 180 | 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 | |
| 163 | 186 | h.timerMu.Unlock() | |
| 164 | 187 | ||
| 165 | 188 | 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.Timer | 38 | + | timerMu sync.Mutex |
| 38 | - | timerMu sync.Mutex | 39 | + | sourceID uint |
| 39 | - | canceled bool | 40 | + | generation uint64 |
| 41 | + | detached bool | |||
| 42 | + | schedule func(uint, func(uintptr) bool) uint | |||
| 43 | + | removeSource func(uint) bool | |||
| 40 | 44 | ||||
| 41 | ctx context.Context | 45 | 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 timer | 128 | + | return |
| 116 | - | if h.timer != nil { | |||
| 117 | - | h.timer.Stop() | |||
| 118 | } | 129 | } | ||
| 119 | - | 130 | + | h.cancelSourceLocked() | |
| 120 | - | h.canceled = false | 131 | + | h.generation++ |
| 121 | - | 132 | + | generation := h.generation | |
| 122 | - | // Start new debounce timer | 133 | + | 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.canceled | 136 | + | 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 = true | 172 | + | 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 = nil | 183 | + | h.cancelSourceLocked() |
| 162 | - | } | 184 | + | h.onEnter = nil |
| 185 | + | h.onMotion = nil | |||
| 163 | h.timerMu.Unlock() | 186 | h.timerMu.Unlock() | ||
| 164 | 187 | ||||
| 165 | h.motionCtrl = nil | 188 | h.motionCtrl = nil | ||