detached dfddb9f

feat(input): add focus-follows-mouse with 150ms debounce for pane hover

commit
dfddb9f923c96d22431c6ba93fbcfcc268abf126
author
brice · · 2025-12-17 17:15 UTC
parent
ffd97bd
signature
unsigned
4 files · +212 -2
M internal/ui/component/pane_view.go +39 -0
@@ -2,9 +2,11 @@
22 package component
33
44 import (
5+ "context"
56 "sync"
67
78 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/input"
810 "github.com/bnema/dumber/internal/ui/layout"
911 )
1012
@@ -25,6 +27,9 @@ type PaneView struct {
2527
2628 onFocusIn func(paneID entity.PaneID)
2729 onFocusOut func(paneID entity.PaneID)
30+ onHover func(paneID entity.PaneID)
31+
32+ hoverHandler *input.HoverHandler
2833
2934 mu sync.RWMutex
3035 }
@@ -171,6 +176,40 @@ func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
171176 pv.onFocusOut = fn
172177 }
173178
179+// SetOnHover sets the callback for when the pane is hovered.
180+func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
181+ pv.mu.Lock()
182+ defer pv.mu.Unlock()
183+
184+ pv.onHover = fn
185+}
186+
187+// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
188+func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
189+ pv.mu.Lock()
190+ defer pv.mu.Unlock()
191+
192+ // Create hover handler
193+ pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
194+
195+ // Wire up callback
196+ pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
197+ pv.mu.RLock()
198+ callback := pv.onHover
199+ pv.mu.RUnlock()
200+
201+ if callback != nil {
202+ callback(paneID)
203+ }
204+ })
205+
206+ // Attach to overlay widget
207+ gtkWidget := pv.overlay.GtkWidget()
208+ if gtkWidget != nil {
209+ pv.hoverHandler.AttachTo(gtkWidget)
210+ }
211+}
212+
174213 // Widget returns the underlying overlay widget for embedding in containers.
175214 func (pv *PaneView) Widget() layout.Widget {
176215 return pv.overlay
@@ -2,9 +2,11 @@
2 package component2 package component
3 3
4 import (4 import (
5+ "context"
5 "sync"6 "sync"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/input"
8 "github.com/bnema/dumber/internal/ui/layout"10 "github.com/bnema/dumber/internal/ui/layout"
9 )11 )
10 12
@@ -25,6 +27,9 @@ type PaneView struct {
25 27
26 onFocusIn func(paneID entity.PaneID)28 onFocusIn func(paneID entity.PaneID)
27 onFocusOut func(paneID entity.PaneID)29 onFocusOut func(paneID entity.PaneID)
30+ onHover func(paneID entity.PaneID)
31+
32+ hoverHandler *input.HoverHandler
28 33
29 mu sync.RWMutex34 mu sync.RWMutex
30 }35 }
@@ -171,6 +176,40 @@ func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
171 pv.onFocusOut = fn176 pv.onFocusOut = fn
172 }177 }
173 178
179+// SetOnHover sets the callback for when the pane is hovered.
180+func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
181+ pv.mu.Lock()
182+ defer pv.mu.Unlock()
183+
184+ pv.onHover = fn
185+}
186+
187+// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
188+func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
189+ pv.mu.Lock()
190+ defer pv.mu.Unlock()
191+
192+ // Create hover handler
193+ pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
194+
195+ // Wire up callback
196+ pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
197+ pv.mu.RLock()
198+ callback := pv.onHover
199+ pv.mu.RUnlock()
200+
201+ if callback != nil {
202+ callback(paneID)
203+ }
204+ })
205+
206+ // Attach to overlay widget
207+ gtkWidget := pv.overlay.GtkWidget()
208+ if gtkWidget != nil {
209+ pv.hoverHandler.AttachTo(gtkWidget)
210+ }
211+}
212+
174 // Widget returns the underlying overlay widget for embedding in containers.213 // Widget returns the underlying overlay widget for embedding in containers.
175 func (pv *PaneView) Widget() layout.Widget {214 func (pv *PaneView) Widget() layout.Widget {
176 return pv.overlay215 return pv.overlay
M internal/ui/component/workspace_view.go +19 -2
@@ -49,7 +49,8 @@ type WorkspaceView struct {
4949 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
5050 // so TreeRenderer can create PaneViews.
5151 type paneViewFactoryAdapter struct {
52- wv *WorkspaceView
52+ wv *WorkspaceView
53+ ctx context.Context
5354 }
5455
5556 func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
@@ -76,6 +77,22 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
7677 }
7778 })
7879
80+ // Set up hover callback for focus-follows-mouse
81+ pv.SetOnHover(func(paneID entity.PaneID) {
82+ // Skip if this pane is already active
83+ if a.wv.GetActivePaneID() == paneID {
84+ return
85+ }
86+
87+ // Activate the hovered pane and grab focus
88+ if err := a.wv.SetActivePaneID(paneID); err == nil {
89+ a.wv.FocusPane(paneID)
90+ }
91+ })
92+
93+ // Attach hover handler with debouncing
94+ pv.AttachHoverHandler(a.ctx)
95+
7996 return pv.Widget()
8097 }
8198
@@ -104,7 +121,7 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
104121 }
105122
106123 // Create tree renderer with our adapter as the pane view factory
107- wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv})
124+ wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
108125
109126 return wv
110127 }
@@ -49,7 +49,8 @@ type WorkspaceView struct {
49 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface49 // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
50 // so TreeRenderer can create PaneViews.50 // so TreeRenderer can create PaneViews.
51 type paneViewFactoryAdapter struct {51 type paneViewFactoryAdapter struct {
52- wv *WorkspaceView52+ wv *WorkspaceView
53+ ctx context.Context
53 }54 }
54 55
55 func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {56 func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
@@ -76,6 +77,22 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
76 }77 }
77 })78 })
78 79
80+ // Set up hover callback for focus-follows-mouse
81+ pv.SetOnHover(func(paneID entity.PaneID) {
82+ // Skip if this pane is already active
83+ if a.wv.GetActivePaneID() == paneID {
84+ return
85+ }
86+
87+ // Activate the hovered pane and grab focus
88+ if err := a.wv.SetActivePaneID(paneID); err == nil {
89+ a.wv.FocusPane(paneID)
90+ }
91+ })
92+
93+ // Attach hover handler with debouncing
94+ pv.AttachHoverHandler(a.ctx)
95+
79 return pv.Widget()96 return pv.Widget()
80 }97 }
81 98
@@ -104,7 +121,7 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
104 }121 }
105 122
106 // Create tree renderer with our adapter as the pane view factory123 // Create tree renderer with our adapter as the pane view factory
107- wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv})124+ wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
108 125
109 return wv126 return wv
110 }127 }
M internal/ui/coordinator/workspace.go +20 -0
@@ -58,6 +58,23 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e
5858 c.onCloseLastPane = fn
5959 }
6060
61+// setupPaneViewHover configures hover-to-focus behavior on a PaneView.
62+func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
63+ pv.SetOnHover(func(paneID entity.PaneID) {
64+ // Skip if this pane is already active
65+ if wsView.GetActivePaneID() == paneID {
66+ return
67+ }
68+
69+ // Activate the hovered pane and grab focus
70+ if err := wsView.SetActivePaneID(paneID); err == nil {
71+ wsView.FocusPane(paneID)
72+ }
73+ })
74+
75+ pv.AttachHoverHandler(ctx)
76+}
77+
6178 // Split splits the active pane in the given direction.
6279 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
6380 log := logging.FromContext(ctx)
@@ -190,6 +207,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
190207
191208 // 3. Create new PaneView for the new pane (without WebView - will attach later)
192209 newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
210+ setupPaneViewHover(ctx, newPaneView, wsView)
193211
194212 // 4. Wrap the new PaneView in a StackedView
195213 newStackedView := layout.NewStackedView(factory)
@@ -303,6 +321,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
303321
304322 // 4. Create new PaneView for the new pane
305323 newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
324+ setupPaneViewHover(ctx, newPaneView, wsView)
306325
307326 // 5. Wrap the new PaneView in a StackedView
308327 newStackedView := layout.NewStackedView(factory)
@@ -936,6 +955,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
936955
937956 // Create PaneView for the new pane
938957 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
958+ setupPaneViewHover(ctx, newPaneView, wsView)
939959 wsView.RegisterPaneView(newPaneID, newPaneView)
940960
941961 // Add to the UI StackedView
@@ -58,6 +58,23 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e
58 c.onCloseLastPane = fn58 c.onCloseLastPane = fn
59 }59 }
60 60
61+// setupPaneViewHover configures hover-to-focus behavior on a PaneView.
62+func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
63+ pv.SetOnHover(func(paneID entity.PaneID) {
64+ // Skip if this pane is already active
65+ if wsView.GetActivePaneID() == paneID {
66+ return
67+ }
68+
69+ // Activate the hovered pane and grab focus
70+ if err := wsView.SetActivePaneID(paneID); err == nil {
71+ wsView.FocusPane(paneID)
72+ }
73+ })
74+
75+ pv.AttachHoverHandler(ctx)
76+}
77+
61 // Split splits the active pane in the given direction.78 // Split splits the active pane in the given direction.
62 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {79 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
63 log := logging.FromContext(ctx)80 log := logging.FromContext(ctx)
@@ -190,6 +207,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
190 207
191 // 3. Create new PaneView for the new pane (without WebView - will attach later)208 // 3. Create new PaneView for the new pane (without WebView - will attach later)
192 newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)209 newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
210+ setupPaneViewHover(ctx, newPaneView, wsView)
193 211
194 // 4. Wrap the new PaneView in a StackedView212 // 4. Wrap the new PaneView in a StackedView
195 newStackedView := layout.NewStackedView(factory)213 newStackedView := layout.NewStackedView(factory)
@@ -303,6 +321,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
303 321
304 // 4. Create new PaneView for the new pane322 // 4. Create new PaneView for the new pane
305 newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)323 newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
324+ setupPaneViewHover(ctx, newPaneView, wsView)
306 325
307 // 5. Wrap the new PaneView in a StackedView326 // 5. Wrap the new PaneView in a StackedView
308 newStackedView := layout.NewStackedView(factory)327 newStackedView := layout.NewStackedView(factory)
@@ -936,6 +955,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
936 955
937 // Create PaneView for the new pane956 // Create PaneView for the new pane
938 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)957 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
958+ setupPaneViewHover(ctx, newPaneView, wsView)
939 wsView.RegisterPaneView(newPaneID, newPaneView)959 wsView.RegisterPaneView(newPaneID, newPaneView)
940 960
941 // Add to the UI StackedView961 // Add to the UI StackedView
A internal/ui/input/hover.go +134 -0
@@ -0,0 +1,134 @@
1+package input
2+
3+import (
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/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+const (
14+ // HoverFocusDelay is the delay before switching focus on hover.
15+ HoverFocusDelay = 150 * time.Millisecond
16+)
17+
18+// HoverCallback is called when a pane should receive focus from hover.
19+type HoverCallback func(paneID entity.PaneID)
20+
21+// HoverHandler handles mouse hover events for focus-follows-mouse behavior.
22+// It uses a debounce timer to avoid rapid focus switches.
23+type HoverHandler struct {
24+ motionCtrl *gtk.EventControllerMotion
25+ paneID entity.PaneID
26+ onEnter HoverCallback
27+
28+ timer *time.Timer
29+ timerMu sync.Mutex
30+ canceled bool
31+
32+ ctx context.Context
33+}
34+
35+// NewHoverHandler creates a new hover handler for a specific pane.
36+func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
37+ log := logging.FromContext(ctx)
38+ log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
39+
40+ return &HoverHandler{
41+ ctx: ctx,
42+ paneID: paneID,
43+ }
44+}
45+
46+// SetOnEnter sets the callback for when the pane should receive focus.
47+func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
48+ h.onEnter = fn
49+}
50+
51+// AttachTo attaches the hover handler to a GTK widget.
52+func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
53+ log := logging.FromContext(h.ctx)
54+
55+ if widget == nil {
56+ log.Error().Msg("cannot attach hover handler to nil widget")
57+ return
58+ }
59+
60+ h.motionCtrl = gtk.NewEventControllerMotion()
61+ if h.motionCtrl == nil {
62+ log.Error().Msg("failed to create motion controller")
63+ return
64+ }
65+
66+ // Connect enter handler with debounce
67+ enterCb := func(ctrl gtk.EventControllerMotion, x, y float64) {
68+ h.handleEnter()
69+ }
70+ h.motionCtrl.ConnectEnter(&enterCb)
71+
72+ // Connect leave handler to cancel pending focus
73+ leaveCb := func(ctrl gtk.EventControllerMotion) {
74+ h.handleLeave()
75+ }
76+ h.motionCtrl.ConnectLeave(&leaveCb)
77+
78+ // Add controller to widget
79+ widget.AddController(&h.motionCtrl.EventController)
80+
81+ log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
82+}
83+
84+// handleEnter processes the mouse enter event with debouncing.
85+func (h *HoverHandler) handleEnter() {
86+ h.timerMu.Lock()
87+ defer h.timerMu.Unlock()
88+
89+ // Cancel any existing timer
90+ if h.timer != nil {
91+ h.timer.Stop()
92+ }
93+
94+ h.canceled = false
95+
96+ // Start new debounce timer
97+ h.timer = time.AfterFunc(HoverFocusDelay, func() {
98+ h.timerMu.Lock()
99+ canceled := h.canceled
100+ h.timerMu.Unlock()
101+
102+ if canceled {
103+ return
104+ }
105+
106+ if h.onEnter != nil {
107+ h.onEnter(h.paneID)
108+ }
109+ })
110+}
111+
112+// handleLeave cancels any pending focus switch.
113+func (h *HoverHandler) handleLeave() {
114+ h.timerMu.Lock()
115+ defer h.timerMu.Unlock()
116+
117+ h.canceled = true
118+ if h.timer != nil {
119+ h.timer.Stop()
120+ h.timer = nil
121+ }
122+}
123+
124+// Detach removes the hover handler.
125+func (h *HoverHandler) Detach() {
126+ h.timerMu.Lock()
127+ if h.timer != nil {
128+ h.timer.Stop()
129+ h.timer = nil
130+ }
131+ h.timerMu.Unlock()
132+
133+ h.motionCtrl = nil
134+}
@@ -0,0 +1,134 @@
1+package input
2+
3+import (
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/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+const (
14+ // HoverFocusDelay is the delay before switching focus on hover.
15+ HoverFocusDelay = 150 * time.Millisecond
16+)
17+
18+// HoverCallback is called when a pane should receive focus from hover.
19+type HoverCallback func(paneID entity.PaneID)
20+
21+// HoverHandler handles mouse hover events for focus-follows-mouse behavior.
22+// It uses a debounce timer to avoid rapid focus switches.
23+type HoverHandler struct {
24+ motionCtrl *gtk.EventControllerMotion
25+ paneID entity.PaneID
26+ onEnter HoverCallback
27+
28+ timer *time.Timer
29+ timerMu sync.Mutex
30+ canceled bool
31+
32+ ctx context.Context
33+}
34+
35+// NewHoverHandler creates a new hover handler for a specific pane.
36+func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
37+ log := logging.FromContext(ctx)
38+ log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
39+
40+ return &HoverHandler{
41+ ctx: ctx,
42+ paneID: paneID,
43+ }
44+}
45+
46+// SetOnEnter sets the callback for when the pane should receive focus.
47+func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
48+ h.onEnter = fn
49+}
50+
51+// AttachTo attaches the hover handler to a GTK widget.
52+func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
53+ log := logging.FromContext(h.ctx)
54+
55+ if widget == nil {
56+ log.Error().Msg("cannot attach hover handler to nil widget")
57+ return
58+ }
59+
60+ h.motionCtrl = gtk.NewEventControllerMotion()
61+ if h.motionCtrl == nil {
62+ log.Error().Msg("failed to create motion controller")
63+ return
64+ }
65+
66+ // Connect enter handler with debounce
67+ enterCb := func(ctrl gtk.EventControllerMotion, x, y float64) {
68+ h.handleEnter()
69+ }
70+ h.motionCtrl.ConnectEnter(&enterCb)
71+
72+ // Connect leave handler to cancel pending focus
73+ leaveCb := func(ctrl gtk.EventControllerMotion) {
74+ h.handleLeave()
75+ }
76+ h.motionCtrl.ConnectLeave(&leaveCb)
77+
78+ // Add controller to widget
79+ widget.AddController(&h.motionCtrl.EventController)
80+
81+ log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
82+}
83+
84+// handleEnter processes the mouse enter event with debouncing.
85+func (h *HoverHandler) handleEnter() {
86+ h.timerMu.Lock()
87+ defer h.timerMu.Unlock()
88+
89+ // Cancel any existing timer
90+ if h.timer != nil {
91+ h.timer.Stop()
92+ }
93+
94+ h.canceled = false
95+
96+ // Start new debounce timer
97+ h.timer = time.AfterFunc(HoverFocusDelay, func() {
98+ h.timerMu.Lock()
99+ canceled := h.canceled
100+ h.timerMu.Unlock()
101+
102+ if canceled {
103+ return
104+ }
105+
106+ if h.onEnter != nil {
107+ h.onEnter(h.paneID)
108+ }
109+ })
110+}
111+
112+// handleLeave cancels any pending focus switch.
113+func (h *HoverHandler) handleLeave() {
114+ h.timerMu.Lock()
115+ defer h.timerMu.Unlock()
116+
117+ h.canceled = true
118+ if h.timer != nil {
119+ h.timer.Stop()
120+ h.timer = nil
121+ }
122+}
123+
124+// Detach removes the hover handler.
125+func (h *HoverHandler) Detach() {
126+ h.timerMu.Lock()
127+ if h.timer != nil {
128+ h.timer.Stop()
129+ h.timer = nil
130+ }
131+ h.timerMu.Unlock()
132+
133+ h.motionCtrl = nil
134+}

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