detached d3d4981

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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.
2323
2424 ### Fixed
2525
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.
2627 - **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.
2728 - **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.
2829 - **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 ### Fixed24 ### 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 {
2929 paneID entity.PaneID
3030 isActive bool
3131
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()
3536
3637 hoverHandler *input.HoverHandler
3738
@@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
218219 pv.onHover = fn
219220 }
220221
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+
221231 // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
222232 func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
223233 pv.mu.Lock()
@@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
226236 // Create hover handler
227237 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
228238
229- // Wire up callback
239+ // Wire up hover enter callback
230240 pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
231241 pv.mu.RLock()
232242 callback := pv.onHover
@@ -237,6 +247,17 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
237247 }
238248 })
239249
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+
240261 // Attach to overlay widget
241262 gtkWidget := pv.overlay.GtkWidget()
242263 if gtkWidget != nil {
@@ -29,9 +29,10 @@ type PaneView struct {
29 paneID entity.PaneID29 paneID entity.PaneID
30 isActive bool30 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.HoverHandler37 hoverHandler *input.HoverHandler
37 38
@@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
218 pv.onHover = fn219 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 handler236 // Create hover handler
227 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)237 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
228 238
229- // Wire up callback239+ // 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.onHover242 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 widget261 // 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 (
66 "errors"
77 "fmt"
88 "sync"
9+ "sync/atomic"
910 "time"
1011
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -56,6 +57,14 @@ type WorkspaceView struct {
5657 // Prevents hover focus from overriding keyboard-initiated focus changes
5758 hoverSuppressedUntil time.Time
5859
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+
5968 // Auto-open omnibox on new pane creation
6069 autoOpenOnNewPane bool
6170
@@ -93,6 +102,13 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
93102 }
94103 })
95104
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+
96112 // Set up hover callback for focus-follows-mouse
97113 pv.SetOnHover(func(paneID entity.PaneID) {
98114 // Skip if hover is suppressed (keyboard navigation just happened)
@@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
100116 return
101117 }
102118
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+
103125 // Skip if this pane is already active
104126 if a.wv.GetActivePaneID() == paneID {
105127 return
@@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
566588 // NotifyNewPaneCreated handles new pane creation events.
567589 // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
568590 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)
570595 wv.SuppressHover(KeyboardFocusSuppressDuration)
571596 wv.CancelAllPendingHovers()
572597
@@ -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 changes57 // Prevents hover focus from overriding keyboard-initiated focus changes
57 hoverSuppressedUntil time.Time58 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 creation68 // Auto-open omnibox on new pane creation
60 autoOpenOnNewPane bool69 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-mouse112 // 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 return116 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 active125 // Skip if this pane is already active
104 if a.wv.GetActivePaneID() == paneID {126 if a.wv.GetActivePaneID() == paneID {
105 return127 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 pane591+ // 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 (
1818 // HoverCallback is called when a pane should receive focus from hover.
1919 type HoverCallback func(paneID entity.PaneID)
2020
21+// MotionCallback is called when intentional mouse movement is detected.
22+type MotionCallback func()
23+
2124 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.
2225 // It uses a debounce timer to avoid rapid focus switches.
2326 type HoverHandler struct {
2427 motionCtrl *gtk.EventControllerMotion
2528 paneID entity.PaneID
2629 onEnter HoverCallback
30+ onMotion MotionCallback
2731
2832 // 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)
3136
3237 timer *time.Timer
3338 timerMu sync.Mutex
@@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
5257 h.onEnter = fn
5358 }
5459
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+
5567 // AttachTo attaches the hover handler to a GTK widget.
5668 func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
5769 log := logging.FromContext(h.ctx)
@@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
7991 }
8092 h.motionCtrl.ConnectLeave(&h.leaveCb)
8193
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+
82104 // Add controller to widget
83105 widget.AddController(&h.motionCtrl.EventController)
84106
@@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() {
143165 h.motionCtrl = nil
144166 h.enterCb = nil
145167 h.leaveCb = nil
168+ h.motionCb = nil
146169 }
@@ -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.EventControllerMotion27 motionCtrl *gtk.EventControllerMotion
25 paneID entity.PaneID28 paneID entity.PaneID
26 onEnter HoverCallback29 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.Timer37 timer *time.Timer
33 timerMu sync.Mutex38 timerMu sync.Mutex
@@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
52 h.onEnter = fn57 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 widget104 // 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 = nil165 h.motionCtrl = nil
144 h.enterCb = nil166 h.enterCb = nil
145 h.leaveCb = nil167 h.leaveCb = nil
168+ h.motionCb = nil
146 }169 }

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