fix: hover focus no longer steals from newly created split panes
New pane creation sets a motion-based focus lock (newPaneFocusLock) that blocks hover-follows-mouse until the user intentionally moves the mouse. This replaces the time-based suppression that could expire before GTK layout completes, allowing synthetic enter events from widget rearrangement to steal focus from the new pane. - HoverHandler: add ConnectMotion callback for real movement detection - PaneView: add SetOnMouseMotion to wire motion → lock clear - WorkspaceView: newPaneFocusLock (atomic.Bool) set on new pane, cleared on first real mouse motion
- commit
- d3d4981fa0f92924c5718d1cc05197276a0244e5
- author
- brice · · 2026-02-07 11:39 UTC
- parent
- 3eb245f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +77 -7
M CHANGELOG.md +1 -0
| @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. | |||
| 23 | 23 | ||
| 24 | 24 | ### Fixed | |
| 25 | 25 | ||
| 26 | + | - **Hover focus stealing from new split panes**: New pane creation now locks hover focus until the user intentionally moves the mouse, replacing the 300ms time-based suppression that could expire before GTK layout completes. Synthetic enter events from widget rearrangement no longer steal focus from freshly created panes. | |
| 26 | 27 | - **Stacked pane title desync on navigation**: Titles in the stacked title bar now sync immediately on `LoadCommitted` and when switching panes, preventing stale titles after background navigation. | |
| 27 | 28 | - **Session marker file accumulation**: `writeShutdownMarker` now removes the matching startup marker (embedding its timestamp for classification), and a new `sweepPairedMarkers` sweep in background cleanup removes old marker trios past the configured age. | |
| 28 | 29 | - **Retry delay exceeding max**: `retryDelayForAttempt` now clamps the final delay (base + jitter) to `retryMaxDelay` so backoff never overshoots the hard cap. | |
| @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. | |||||
| 23 | 23 | ||||
| 24 | ### Fixed | 24 | ### Fixed | ||
| 25 | 25 | ||||
| 26 | + | - **Hover focus stealing from new split panes**: New pane creation now locks hover focus until the user intentionally moves the mouse, replacing the 300ms time-based suppression that could expire before GTK layout completes. Synthetic enter events from widget rearrangement no longer steal focus from freshly created panes. | |||
| 26 | - **Stacked pane title desync on navigation**: Titles in the stacked title bar now sync immediately on `LoadCommitted` and when switching panes, preventing stale titles after background navigation. | 27 | - **Stacked pane title desync on navigation**: Titles in the stacked title bar now sync immediately on `LoadCommitted` and when switching panes, preventing stale titles after background navigation. | ||
| 27 | - **Session marker file accumulation**: `writeShutdownMarker` now removes the matching startup marker (embedding its timestamp for classification), and a new `sweepPairedMarkers` sweep in background cleanup removes old marker trios past the configured age. | 28 | - **Session marker file accumulation**: `writeShutdownMarker` now removes the matching startup marker (embedding its timestamp for classification), and a new `sweepPairedMarkers` sweep in background cleanup removes old marker trios past the configured age. | ||
| 28 | - **Retry delay exceeding max**: `retryDelayForAttempt` now clamps the final delay (base + jitter) to `retryMaxDelay` so backoff never overshoots the hard cap. | 29 | - **Retry delay exceeding max**: `retryDelayForAttempt` now clamps the final delay (base + jitter) to `retryMaxDelay` so backoff never overshoots the hard cap. | ||
M internal/ui/component/pane_view.go +25 -4
| @@ -29,9 +29,10 @@ type PaneView struct { | |||
| 29 | 29 | paneID entity.PaneID | |
| 30 | 30 | isActive bool | |
| 31 | 31 | ||
| 32 | - | onFocusIn func(paneID entity.PaneID) | |
| 33 | - | onFocusOut func(paneID entity.PaneID) | |
| 34 | - | onHover func(paneID entity.PaneID) | |
| 32 | + | onFocusIn func(paneID entity.PaneID) | |
| 33 | + | onFocusOut func(paneID entity.PaneID) | |
| 34 | + | onHover func(paneID entity.PaneID) | |
| 35 | + | onMouseMotion func() | |
| 35 | 36 | ||
| 36 | 37 | hoverHandler *input.HoverHandler | |
| 37 | 38 | ||
| @@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) { | |||
| 218 | 219 | pv.onHover = fn | |
| 219 | 220 | } | |
| 220 | 221 | ||
| 222 | + | // SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane. | |
| 223 | + | // Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement. | |
| 224 | + | func (pv *PaneView) SetOnMouseMotion(fn func()) { | |
| 225 | + | pv.mu.Lock() | |
| 226 | + | defer pv.mu.Unlock() | |
| 227 | + | ||
| 228 | + | pv.onMouseMotion = fn | |
| 229 | + | } | |
| 230 | + | ||
| 221 | 231 | // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior. | |
| 222 | 232 | func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |
| 223 | 233 | pv.mu.Lock() | |
| @@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||
| 226 | 236 | // Create hover handler | |
| 227 | 237 | pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID) | |
| 228 | 238 | ||
| 229 | - | // Wire up callback | |
| 239 | + | // Wire up hover enter callback | |
| 230 | 240 | pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) { | |
| 231 | 241 | pv.mu.RLock() | |
| 232 | 242 | callback := pv.onHover | |
| @@ -237,6 +247,17 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||
| 237 | 247 | } | |
| 238 | 248 | }) | |
| 239 | 249 | ||
| 250 | + | // Wire up mouse motion callback (intentional movement detection) | |
| 251 | + | pv.hoverHandler.SetOnMotion(func() { | |
| 252 | + | pv.mu.RLock() | |
| 253 | + | callback := pv.onMouseMotion | |
| 254 | + | pv.mu.RUnlock() | |
| 255 | + | ||
| 256 | + | if callback != nil { | |
| 257 | + | callback() | |
| 258 | + | } | |
| 259 | + | }) | |
| 260 | + | ||
| 240 | 261 | // Attach to overlay widget | |
| 241 | 262 | gtkWidget := pv.overlay.GtkWidget() | |
| 242 | 263 | if gtkWidget != nil { | |
| @@ -29,9 +29,10 @@ type PaneView struct { | |||||
| 29 | paneID entity.PaneID | 29 | paneID entity.PaneID | ||
| 30 | isActive bool | 30 | isActive bool | ||
| 31 | 31 | ||||
| 32 | - | onFocusIn func(paneID entity.PaneID) | 32 | + | onFocusIn func(paneID entity.PaneID) |
| 33 | - | onFocusOut func(paneID entity.PaneID) | 33 | + | onFocusOut func(paneID entity.PaneID) |
| 34 | - | onHover func(paneID entity.PaneID) | 34 | + | onHover func(paneID entity.PaneID) |
| 35 | + | onMouseMotion func() | |||
| 35 | 36 | ||||
| 36 | hoverHandler *input.HoverHandler | 37 | hoverHandler *input.HoverHandler | ||
| 37 | 38 | ||||
| @@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) { | |||||
| 218 | pv.onHover = fn | 219 | pv.onHover = fn | ||
| 219 | } | 220 | } | ||
| 220 | 221 | ||||
| 222 | + | // SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane. | |||
| 223 | + | // Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement. | |||
| 224 | + | func (pv *PaneView) SetOnMouseMotion(fn func()) { | |||
| 225 | + | pv.mu.Lock() | |||
| 226 | + | defer pv.mu.Unlock() | |||
| 227 | + | ||||
| 228 | + | pv.onMouseMotion = fn | |||
| 229 | + | } | |||
| 230 | + | ||||
| 221 | // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior. | 231 | // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior. | ||
| 222 | func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | 232 | func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | ||
| 223 | pv.mu.Lock() | 233 | pv.mu.Lock() | ||
| @@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||||
| 226 | // Create hover handler | 236 | // Create hover handler | ||
| 227 | pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID) | 237 | pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID) | ||
| 228 | 238 | ||||
| 229 | - | // Wire up callback | 239 | + | // Wire up hover enter callback |
| 230 | pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) { | 240 | pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) { | ||
| 231 | pv.mu.RLock() | 241 | pv.mu.RLock() | ||
| 232 | callback := pv.onHover | 242 | callback := pv.onHover | ||
| @@ -237,6 +247,17 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||||
| 237 | } | 247 | } | ||
| 238 | }) | 248 | }) | ||
| 239 | 249 | ||||
| 250 | + | // Wire up mouse motion callback (intentional movement detection) | |||
| 251 | + | pv.hoverHandler.SetOnMotion(func() { | |||
| 252 | + | pv.mu.RLock() | |||
| 253 | + | callback := pv.onMouseMotion | |||
| 254 | + | pv.mu.RUnlock() | |||
| 255 | + | ||||
| 256 | + | if callback != nil { | |||
| 257 | + | callback() | |||
| 258 | + | } | |||
| 259 | + | }) | |||
| 260 | + | ||||
| 240 | // Attach to overlay widget | 261 | // Attach to overlay widget | ||
| 241 | gtkWidget := pv.overlay.GtkWidget() | 262 | gtkWidget := pv.overlay.GtkWidget() | ||
| 242 | if gtkWidget != nil { | 263 | if gtkWidget != nil { | ||
M internal/ui/component/workspace_view.go +26 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -56,6 +57,14 @@ type WorkspaceView struct { | |||
| 56 | 57 | // Prevents hover focus from overriding keyboard-initiated focus changes | |
| 57 | 58 | hoverSuppressedUntil time.Time | |
| 58 | 59 | ||
| 60 | + | // newPaneFocusLock suppresses hover focus after a new pane is created. | |
| 61 | + | // Rule: new pane creation always wins focus. Hover focus is blocked | |
| 62 | + | // until the user intentionally moves the mouse (motion event), at which | |
| 63 | + | // point normal hover-follows-mouse behavior resumes. This prevents | |
| 64 | + | // GTK's synthetic enter events (fired on widget rearrangement) from | |
| 65 | + | // stealing focus away from a freshly created pane. | |
| 66 | + | newPaneFocusLock atomic.Bool | |
| 67 | + | ||
| 59 | 68 | // Auto-open omnibox on new pane creation | |
| 60 | 69 | autoOpenOnNewPane bool | |
| 61 | 70 | ||
| @@ -93,6 +102,13 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||
| 93 | 102 | } | |
| 94 | 103 | }) | |
| 95 | 104 | ||
| 105 | + | // Clear new-pane focus lock on intentional mouse motion. | |
| 106 | + | // This allows hover-follows-mouse to resume after the user moves the | |
| 107 | + | // mouse, while keeping focus on the new pane until that happens. | |
| 108 | + | pv.SetOnMouseMotion(func() { | |
| 109 | + | a.wv.newPaneFocusLock.Store(false) | |
| 110 | + | }) | |
| 111 | + | ||
| 96 | 112 | // Set up hover callback for focus-follows-mouse | |
| 97 | 113 | pv.SetOnHover(func(paneID entity.PaneID) { | |
| 98 | 114 | // Skip if hover is suppressed (keyboard navigation just happened) | |
| @@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||
| 100 | 116 | return | |
| 101 | 117 | } | |
| 102 | 118 | ||
| 119 | + | // Skip if new pane was just created — focus stays on new pane | |
| 120 | + | // until the user intentionally moves the mouse (see SetOnMouseMotion). | |
| 121 | + | if a.wv.newPaneFocusLock.Load() { | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | ||
| 103 | 125 | // Skip if this pane is already active | |
| 104 | 126 | if a.wv.GetActivePaneID() == paneID { | |
| 105 | 127 | return | |
| @@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) { | |||
| 566 | 588 | // NotifyNewPaneCreated handles new pane creation events. | |
| 567 | 589 | // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox. | |
| 568 | 590 | func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) { | |
| 569 | - | // Suppress hover focus so mouse position doesn't steal focus from new pane | |
| 591 | + | // Lock hover focus until the user intentionally moves the mouse. | |
| 592 | + | // New pane creation always wins focus; hover-follows-mouse resumes | |
| 593 | + | // only after real mouse motion (cleared by PaneView.SetOnMouseMotion). | |
| 594 | + | wv.newPaneFocusLock.Store(true) | |
| 570 | 595 | wv.SuppressHover(KeyboardFocusSuppressDuration) | |
| 571 | 596 | wv.CancelAllPendingHovers() | |
| 572 | 597 | ||
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "sync/atomic" | |||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -56,6 +57,14 @@ type WorkspaceView struct { | |||||
| 56 | // Prevents hover focus from overriding keyboard-initiated focus changes | 57 | // Prevents hover focus from overriding keyboard-initiated focus changes | ||
| 57 | hoverSuppressedUntil time.Time | 58 | hoverSuppressedUntil time.Time | ||
| 58 | 59 | ||||
| 60 | + | // newPaneFocusLock suppresses hover focus after a new pane is created. | |||
| 61 | + | // Rule: new pane creation always wins focus. Hover focus is blocked | |||
| 62 | + | // until the user intentionally moves the mouse (motion event), at which | |||
| 63 | + | // point normal hover-follows-mouse behavior resumes. This prevents | |||
| 64 | + | // GTK's synthetic enter events (fired on widget rearrangement) from | |||
| 65 | + | // stealing focus away from a freshly created pane. | |||
| 66 | + | newPaneFocusLock atomic.Bool | |||
| 67 | + | ||||
| 59 | // Auto-open omnibox on new pane creation | 68 | // Auto-open omnibox on new pane creation | ||
| 60 | autoOpenOnNewPane bool | 69 | autoOpenOnNewPane bool | ||
| 61 | 70 | ||||
| @@ -93,6 +102,13 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||||
| 93 | } | 102 | } | ||
| 94 | }) | 103 | }) | ||
| 95 | 104 | ||||
| 105 | + | // Clear new-pane focus lock on intentional mouse motion. | |||
| 106 | + | // This allows hover-follows-mouse to resume after the user moves the | |||
| 107 | + | // mouse, while keeping focus on the new pane until that happens. | |||
| 108 | + | pv.SetOnMouseMotion(func() { | |||
| 109 | + | a.wv.newPaneFocusLock.Store(false) | |||
| 110 | + | }) | |||
| 111 | + | ||||
| 96 | // Set up hover callback for focus-follows-mouse | 112 | // Set up hover callback for focus-follows-mouse | ||
| 97 | pv.SetOnHover(func(paneID entity.PaneID) { | 113 | pv.SetOnHover(func(paneID entity.PaneID) { | ||
| 98 | // Skip if hover is suppressed (keyboard navigation just happened) | 114 | // Skip if hover is suppressed (keyboard navigation just happened) | ||
| @@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||||
| 100 | return | 116 | return | ||
| 101 | } | 117 | } | ||
| 102 | 118 | ||||
| 119 | + | // Skip if new pane was just created — focus stays on new pane | |||
| 120 | + | // until the user intentionally moves the mouse (see SetOnMouseMotion). | |||
| 121 | + | if a.wv.newPaneFocusLock.Load() { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | ||||
| 103 | // Skip if this pane is already active | 125 | // Skip if this pane is already active | ||
| 104 | if a.wv.GetActivePaneID() == paneID { | 126 | if a.wv.GetActivePaneID() == paneID { | ||
| 105 | return | 127 | return | ||
| @@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) { | |||||
| 566 | // NotifyNewPaneCreated handles new pane creation events. | 588 | // NotifyNewPaneCreated handles new pane creation events. | ||
| 567 | // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox. | 589 | // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox. | ||
| 568 | func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) { | 590 | func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) { | ||
| 569 | - | // Suppress hover focus so mouse position doesn't steal focus from new pane | 591 | + | // Lock hover focus until the user intentionally moves the mouse. |
| 592 | + | // New pane creation always wins focus; hover-follows-mouse resumes | |||
| 593 | + | // only after real mouse motion (cleared by PaneView.SetOnMouseMotion). | |||
| 594 | + | wv.newPaneFocusLock.Store(true) | |||
| 570 | wv.SuppressHover(KeyboardFocusSuppressDuration) | 595 | wv.SuppressHover(KeyboardFocusSuppressDuration) | ||
| 571 | wv.CancelAllPendingHovers() | 596 | wv.CancelAllPendingHovers() | ||
| 572 | 597 | ||||
M internal/ui/input/hover.go +25 -2
| @@ -18,16 +18,21 @@ const ( | |||
| 18 | 18 | // HoverCallback is called when a pane should receive focus from hover. | |
| 19 | 19 | type HoverCallback func(paneID entity.PaneID) | |
| 20 | 20 | ||
| 21 | + | // MotionCallback is called when intentional mouse movement is detected. | |
| 22 | + | type MotionCallback func() | |
| 23 | + | ||
| 21 | 24 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | |
| 22 | 25 | // It uses a debounce timer to avoid rapid focus switches. | |
| 23 | 26 | type HoverHandler struct { | |
| 24 | 27 | motionCtrl *gtk.EventControllerMotion | |
| 25 | 28 | paneID entity.PaneID | |
| 26 | 29 | onEnter HoverCallback | |
| 30 | + | onMotion MotionCallback | |
| 27 | 31 | ||
| 28 | 32 | // Callback retention: must stay reachable by Go GC. | |
| 29 | - | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 30 | - | leaveCb func(gtk.EventControllerMotion) | |
| 33 | + | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 34 | + | leaveCb func(gtk.EventControllerMotion) | |
| 35 | + | motionCb func(gtk.EventControllerMotion, float64, float64) | |
| 31 | 36 | ||
| 32 | 37 | timer *time.Timer | |
| 33 | 38 | timerMu sync.Mutex | |
| @@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) { | |||
| 52 | 57 | h.onEnter = fn | |
| 53 | 58 | } | |
| 54 | 59 | ||
| 60 | + | // SetOnMotion sets the callback for intentional mouse movement. | |
| 61 | + | // This fires on actual mouse motion within the pane, not on synthetic | |
| 62 | + | // enter/leave events from GTK widget rearrangement. | |
| 63 | + | func (h *HoverHandler) SetOnMotion(fn MotionCallback) { | |
| 64 | + | h.onMotion = fn | |
| 65 | + | } | |
| 66 | + | ||
| 55 | 67 | // AttachTo attaches the hover handler to a GTK widget. | |
| 56 | 68 | func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |
| 57 | 69 | log := logging.FromContext(h.ctx) | |
| @@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 79 | 91 | } | |
| 80 | 92 | h.motionCtrl.ConnectLeave(&h.leaveCb) | |
| 81 | 93 | ||
| 94 | + | // Connect motion handler to detect intentional mouse movement. | |
| 95 | + | // Unlike enter/leave, motion only fires when the cursor physically moves | |
| 96 | + | // inside the widget — not on synthetic events from widget rearrangement. | |
| 97 | + | h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |
| 98 | + | if h.onMotion != nil { | |
| 99 | + | h.onMotion() | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | h.motionCtrl.ConnectMotion(&h.motionCb) | |
| 103 | + | ||
| 82 | 104 | // Add controller to widget | |
| 83 | 105 | widget.AddController(&h.motionCtrl.EventController) | |
| 84 | 106 | ||
| @@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() { | |||
| 143 | 165 | h.motionCtrl = nil | |
| 144 | 166 | h.enterCb = nil | |
| 145 | 167 | h.leaveCb = nil | |
| 168 | + | h.motionCb = nil | |
| 146 | 169 | } | |
| @@ -18,16 +18,21 @@ const ( | |||||
| 18 | // HoverCallback is called when a pane should receive focus from hover. | 18 | // HoverCallback is called when a pane should receive focus from hover. | ||
| 19 | type HoverCallback func(paneID entity.PaneID) | 19 | type HoverCallback func(paneID entity.PaneID) | ||
| 20 | 20 | ||||
| 21 | + | // MotionCallback is called when intentional mouse movement is detected. | |||
| 22 | + | type MotionCallback func() | |||
| 23 | + | ||||
| 21 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | 24 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | ||
| 22 | // It uses a debounce timer to avoid rapid focus switches. | 25 | // It uses a debounce timer to avoid rapid focus switches. | ||
| 23 | type HoverHandler struct { | 26 | type HoverHandler struct { | ||
| 24 | motionCtrl *gtk.EventControllerMotion | 27 | motionCtrl *gtk.EventControllerMotion | ||
| 25 | paneID entity.PaneID | 28 | paneID entity.PaneID | ||
| 26 | onEnter HoverCallback | 29 | onEnter HoverCallback | ||
| 30 | + | onMotion MotionCallback | |||
| 27 | 31 | ||||
| 28 | // Callback retention: must stay reachable by Go GC. | 32 | // Callback retention: must stay reachable by Go GC. | ||
| 29 | - | enterCb func(gtk.EventControllerMotion, float64, float64) | 33 | + | enterCb func(gtk.EventControllerMotion, float64, float64) |
| 30 | - | leaveCb func(gtk.EventControllerMotion) | 34 | + | leaveCb func(gtk.EventControllerMotion) |
| 35 | + | motionCb func(gtk.EventControllerMotion, float64, float64) | |||
| 31 | 36 | ||||
| 32 | timer *time.Timer | 37 | timer *time.Timer | ||
| 33 | timerMu sync.Mutex | 38 | timerMu sync.Mutex | ||
| @@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) { | |||||
| 52 | h.onEnter = fn | 57 | h.onEnter = fn | ||
| 53 | } | 58 | } | ||
| 54 | 59 | ||||
| 60 | + | // SetOnMotion sets the callback for intentional mouse movement. | |||
| 61 | + | // This fires on actual mouse motion within the pane, not on synthetic | |||
| 62 | + | // enter/leave events from GTK widget rearrangement. | |||
| 63 | + | func (h *HoverHandler) SetOnMotion(fn MotionCallback) { | |||
| 64 | + | h.onMotion = fn | |||
| 65 | + | } | |||
| 66 | + | ||||
| 55 | // AttachTo attaches the hover handler to a GTK widget. | 67 | // AttachTo attaches the hover handler to a GTK widget. | ||
| 56 | func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | 68 | func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | ||
| 57 | log := logging.FromContext(h.ctx) | 69 | log := logging.FromContext(h.ctx) | ||
| @@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 79 | } | 91 | } | ||
| 80 | h.motionCtrl.ConnectLeave(&h.leaveCb) | 92 | h.motionCtrl.ConnectLeave(&h.leaveCb) | ||
| 81 | 93 | ||||
| 94 | + | // Connect motion handler to detect intentional mouse movement. | |||
| 95 | + | // Unlike enter/leave, motion only fires when the cursor physically moves | |||
| 96 | + | // inside the widget — not on synthetic events from widget rearrangement. | |||
| 97 | + | h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |||
| 98 | + | if h.onMotion != nil { | |||
| 99 | + | h.onMotion() | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | h.motionCtrl.ConnectMotion(&h.motionCb) | |||
| 103 | + | ||||
| 82 | // Add controller to widget | 104 | // Add controller to widget | ||
| 83 | widget.AddController(&h.motionCtrl.EventController) | 105 | widget.AddController(&h.motionCtrl.EventController) | ||
| 84 | 106 | ||||
| @@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() { | |||||
| 143 | h.motionCtrl = nil | 165 | h.motionCtrl = nil | ||
| 144 | h.enterCb = nil | 166 | h.enterCb = nil | ||
| 145 | h.leaveCb = nil | 167 | h.leaveCb = nil | ||
| 168 | + | h.motionCb = nil | |||
| 146 | } | 169 | } | ||