1package input
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk/v4/glib"
11 "github.com/bnema/puregotk/v4/gobject"
12 "github.com/bnema/puregotk/v4/gtk"
13)
14
15const (
16 // HoverFocusDelay is the delay before switching focus on hover.
17 HoverFocusDelay = 150 * time.Millisecond
18)
19
20// HoverCallback is called when a pane should receive focus from hover.
21type HoverCallback func(paneID entity.PaneID)
22
23// MotionCallback is called when intentional mouse movement is detected.
24type MotionCallback func()
25
26// HoverHandler handles mouse hover events for focus-follows-mouse behavior.
27// It uses a debounce timer to avoid rapid focus switches.
28type HoverHandler struct {
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
37
38 // Callback retention: must stay reachable by Go GC.
39 enterCb func(gtk.EventControllerMotion, float64, float64)
40 leaveCb func(gtk.EventControllerMotion)
41 motionCb func(gtk.EventControllerMotion, float64, float64)
42
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)
51
52 ctx context.Context
53}
54
55// NewHoverHandler creates a new hover handler for a specific pane.
56func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
57 log := logging.FromContext(ctx)
58 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
59
60 return &HoverHandler{
61 ctx: ctx,
62 paneID: paneID,
63 schedule: scheduleHoverSource,
64 removeSource: glib.SourceRemove,
65 disconnectSignal: disconnectHoverSignal,
66 removeController: removeHoverController,
67 }
68}
69
70// SetOnEnter sets the callback for when the pane should receive focus.
71func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
72 h.onEnter = fn
73}
74
75// SetOnMotion sets the callback for intentional mouse movement.
76// This fires on actual mouse motion within the pane, not on synthetic
77// enter/leave events from GTK widget rearrangement.
78func (h *HoverHandler) SetOnMotion(fn MotionCallback) {
79 h.onMotion = fn
80}
81
82// AttachTo attaches the hover handler to a GTK widget.
83func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
84 log := logging.FromContext(h.ctx)
85
86 if widget == nil {
87 log.Error().Msg("cannot attach hover handler to nil widget")
88 return
89 }
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
100 h.motionCtrl = gtk.NewEventControllerMotion()
101 if h.motionCtrl == nil {
102 log.Error().Msg("failed to create motion controller")
103 return
104 }
105
106 // Connect enter handler with debounce (retain callbacks to prevent GC)
107 h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
108 h.handleEnter()
109 }
110 h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb)
111
112 // Connect leave handler to cancel pending focus
113 h.leaveCb = func(_ gtk.EventControllerMotion) {
114 h.handleLeave()
115 }
116 h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb)
117
118 // Connect motion handler to detect intentional mouse movement.
119 // Unlike enter/leave, motion only fires when the cursor physically moves
120 // inside the widget — not on synthetic events from widget rearrangement.
121 h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
122 if h.onMotion != nil {
123 h.onMotion()
124 }
125 }
126 h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb)
127
128 // Add controller to widget
129 widget.AddController(&h.motionCtrl.EventController)
130
131 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
132}
133
134// scheduleHoverSource schedules a one-shot callback on GTK's owning GLib main
135// context. GTK state must never be touched by a Go timer goroutine.
136func scheduleHoverSource(delay uint, fn func(uintptr) bool) uint {
137 cb := glib.SourceFunc(fn)
138 return glib.TimeoutAdd(delay, &cb, 0)
139}
140
141// handleEnter processes the mouse enter event with debouncing.
142func (h *HoverHandler) handleEnter() {
143 h.timerMu.Lock()
144 if h.detached {
145 h.timerMu.Unlock()
146 return
147 }
148 h.cancelSourceLocked()
149 h.generation++
150 generation := h.generation
151 schedule := h.schedule
152 h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool {
153 h.timerMu.Lock()
154 if h.detached || h.generation != generation {
155 h.timerMu.Unlock()
156 return false
157 }
158 h.sourceID = 0
159 callback := h.onEnter
160 paneID := h.paneID
161 h.timerMu.Unlock()
162
163 if callback != nil {
164 callback(paneID)
165 }
166 return false
167 })
168 h.timerMu.Unlock()
169}
170
171func (h *HoverHandler) cancelSourceLocked() {
172 if h.sourceID == 0 {
173 return
174 }
175 h.removeSource(h.sourceID)
176 h.sourceID = 0
177}
178
179// handleLeave cancels any pending focus switch.
180func (h *HoverHandler) handleLeave() {
181 h.Cancel()
182}
183
184// Cancel cancels any pending focus switch.
185// This can be called externally to cancel hover when keyboard navigation occurs.
186func (h *HoverHandler) Cancel() {
187 h.timerMu.Lock()
188 defer h.timerMu.Unlock()
189
190 h.generation++
191 h.cancelSourceLocked()
192}
193
194// Detach removes the hover handler and invalidates callbacks already queued on
195// the GLib main context. The generation check is required because removing a
196// source can race with dispatch.
197func (h *HoverHandler) Detach() {
198 h.timerMu.Lock()
199 h.detached = true
200 h.generation++
201 h.cancelSourceLocked()
202 h.onEnter = nil
203 h.onMotion = nil
204 widget := h.attachedWidget
205 controller := h.motionCtrl
206 enterID := h.enterSignalID
207 leaveID := h.leaveSignalID
208 motionID := h.motionSignalID
209 h.attachedWidget = nil
210 h.motionCtrl = nil
211 h.enterSignalID = 0
212 h.leaveSignalID = 0
213 h.motionSignalID = 0
214 h.enterCb = nil
215 h.leaveCb = 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
236func 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
243func removeHoverController(widget *gtk.Widget, controller *gtk.EventController) {
244 if widget != nil && controller != nil {
245 widget.RemoveController(controller)
246 }
247}