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 @@ | |||
| 2 | 2 | package component | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "sync" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/ui/input" | |
| 8 | 10 | "github.com/bnema/dumber/internal/ui/layout" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -25,6 +27,9 @@ type PaneView struct { | |||
| 25 | 27 | ||
| 26 | 28 | onFocusIn func(paneID entity.PaneID) | |
| 27 | 29 | onFocusOut func(paneID entity.PaneID) | |
| 30 | + | onHover func(paneID entity.PaneID) | |
| 31 | + | ||
| 32 | + | hoverHandler *input.HoverHandler | |
| 28 | 33 | ||
| 29 | 34 | mu sync.RWMutex | |
| 30 | 35 | } | |
| @@ -171,6 +176,40 @@ func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) { | |||
| 171 | 176 | 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 | 213 | // Widget returns the underlying overlay widget for embedding in containers. | |
| 175 | 214 | func (pv *PaneView) Widget() layout.Widget { | |
| 176 | 215 | return pv.overlay | |
| @@ -2,9 +2,11 @@ | |||||
| 2 | package component | 2 | 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.RWMutex | 34 | mu sync.RWMutex | ||
| 30 | } | 35 | } | ||
| @@ -171,6 +176,40 @@ func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) { | |||||
| 171 | pv.onFocusOut = fn | 176 | 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.overlay | 215 | return pv.overlay | ||
M internal/ui/component/workspace_view.go +19 -2
| @@ -49,7 +49,8 @@ type WorkspaceView struct { | |||
| 49 | 49 | // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface | |
| 50 | 50 | // so TreeRenderer can create PaneViews. | |
| 51 | 51 | type paneViewFactoryAdapter struct { | |
| 52 | - | wv *WorkspaceView | |
| 52 | + | wv *WorkspaceView | |
| 53 | + | ctx context.Context | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | 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 | 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 | 123 | // 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 | 126 | return wv | |
| 110 | 127 | } | |
| @@ -49,7 +49,8 @@ type WorkspaceView struct { | |||||
| 49 | // paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface | 49 | // 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 *WorkspaceView | 52 | + | 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 factory | 123 | // 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 wv | 126 | 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 | |||
| 58 | 58 | 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 | 78 | // Split splits the active pane in the given direction. | |
| 62 | 79 | func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error { | |
| 63 | 80 | log := logging.FromContext(ctx) | |
| @@ -190,6 +207,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 190 | 207 | ||
| 191 | 208 | // 3. Create new PaneView for the new pane (without WebView - will attach later) | |
| 192 | 209 | newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil) | |
| 210 | + | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 193 | 211 | ||
| 194 | 212 | // 4. Wrap the new PaneView in a StackedView | |
| 195 | 213 | newStackedView := layout.NewStackedView(factory) | |
| @@ -303,6 +321,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 303 | 321 | ||
| 304 | 322 | // 4. Create new PaneView for the new pane | |
| 305 | 323 | newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil) | |
| 324 | + | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 306 | 325 | ||
| 307 | 326 | // 5. Wrap the new PaneView in a StackedView | |
| 308 | 327 | newStackedView := layout.NewStackedView(factory) | |
| @@ -936,6 +955,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 936 | 955 | ||
| 937 | 956 | // Create PaneView for the new pane | |
| 938 | 957 | newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil) | |
| 958 | + | setupPaneViewHover(ctx, newPaneView, wsView) | |
| 939 | 959 | wsView.RegisterPaneView(newPaneID, newPaneView) | |
| 940 | 960 | ||
| 941 | 961 | // Add to the UI StackedView | |
| @@ -58,6 +58,23 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e | |||||
| 58 | c.onCloseLastPane = fn | 58 | 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 StackedView | 212 | // 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 pane | 322 | // 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 StackedView | 326 | // 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 pane | 956 | // 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 StackedView | 961 | // 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 | + | } | |||