Route mouse events to their originating view during a drag gesture

Route all mouse events to the view that was under the pointer when the
left button was pressed, until the button is released. Previously each
event went to whatever view was under the pointer at the time, so a
drag that left the view's bounds started acting on neighboring views.

Since events can now carry positions outside the view, clamp the view
cursor to the view's bounds in that case (handlers still receive the
unclamped position), and require an actual click for tab activation so
that a captured drag crossing the tab row doesn't switch tabs.
This commit is contained in:
Stefan Haller
2026-07-31 08:22:35 +02:00
parent 44a2bbeb7c
commit 4609985029
2 changed files with 279 additions and 7 deletions
+63 -7
View File
@@ -208,7 +208,9 @@ type Gui struct {
// busy?" doesn't count itself.
currentTask Task
lastHoverView *View
lastHoverView *View
mouseCapture *View
mouseGestureCanceled bool
// uiThreadID is the goroutine id of the main event loop, recorded when
// MainLoop starts. IsUIThread compares against it. Written once, read from
@@ -597,6 +599,12 @@ func (g *Gui) DeleteView(name string) error {
for i, v := range g.views {
if v.name == name {
if g.mouseCapture == v {
g.CancelMouseCapture()
}
if g.lastHoverView == v {
g.lastHoverView = nil
}
g.views = append(g.views[:i], g.views[i+1:]...)
return nil
}
@@ -666,6 +674,24 @@ func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
return nil
}
// captureMouse routes subsequent mouse events to view until the mouse button is
// released or CancelMouseCapture is called.
func (g *Gui) captureMouse(view *View) {
g.mouseCapture = view
g.mouseGestureCanceled = false
}
func (g *Gui) releaseMouseCapture() {
g.mouseCapture = nil
}
// CancelMouseCapture releases capture and ignores the rest of the physical
// gesture until the mouse button is released.
func (g *Gui) CancelMouseCapture() {
g.releaseMouseCapture()
g.mouseGestureCanceled = true
}
func (g *Gui) SetFocusHandler(handler func(bool) error) {
g.focusHandler = handler
}
@@ -1658,9 +1684,26 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
case eventMouse:
mx, my := ev.MouseX, ev.MouseY
v, err := g.VisibleViewByPosition(mx, my)
if err != nil {
break
if g.mouseGestureCanceled {
if ev.Key.KeyName() == MouseRelease {
g.mouseGestureCanceled = false
}
return nil
}
// While the mouse is captured, all mouse events go to the view that
// was under the pointer when the button was pressed, even if the
// pointer has since left it; this is what lets drag gestures keep
// acting on the view they started in.
v := g.mouseCapture
if v == nil {
var err error
v, err = g.VisibleViewByPosition(mx, my)
if err != nil {
break
}
}
if ev.Key.KeyName() == MouseRelease {
g.releaseMouseCapture()
}
// newCx and newCy are relative to the view port, i.e. to the visible area of the view
@@ -1704,9 +1747,20 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
break
}
}
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 {
g.captureMouse(v)
}
if !IsMouseScrollKey(ev.Key.KeyName()) {
v.SetCursor(newCx, newCy)
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease {
cursorX, cursorY := newCx, newCy
// A captured drag can report positions outside the view; keep the
// view cursor inside its bounds in that case. Handlers still get
// the unclamped position through the binding opts.
if g.mouseCapture != nil {
cursorX = max(0, min(cursorX, v.InnerWidth()-1))
cursorY = max(0, min(cursorY, v.InnerHeight()-1))
}
v.SetCursor(cursorX, cursorY)
if v.Editable {
v.TextArea.SetCursor2D(newX, newY)
@@ -1718,7 +1772,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
}
if v.Frame && my == v.y0 {
// Only an actual click may activate tabs; a captured drag that
// crosses the tab row must not switch tabs.
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 && v.Frame && my == v.y0 {
if len(v.Tabs) > 0 {
tabIndex := v.GetClickedTabIndex(mx - v.x0)
+216
View File
@@ -0,0 +1,216 @@
package gocui
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
g := newTestGui(t)
view, err := g.SetView("captured", 10, 5, 30, 15, 0)
if err != nil && !errors.Is(err, ErrUnknownView) {
assert.NoError(t, err)
return
}
received := []ViewMouseBindingOpts{}
for _, binding := range []*ViewMouseBinding{
{
ViewName: "captured",
Key: MouseLeft,
Modifier: ModMotion,
Handler: func(opts ViewMouseBindingOpts) error {
received = append(received, opts)
return nil
},
},
{
ViewName: "captured",
Key: MouseRelease,
Handler: func(opts ViewMouseBindingOpts) error {
assert.Nil(t, g.mouseCapture)
received = append(received, opts)
return nil
},
},
} {
assert.NoError(t, g.SetViewClickBinding(binding))
}
g.captureMouse(view)
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 0,
MouseY: 0,
Key: NewKey(MouseLeft, "", ModMotion),
}))
assert.Equal(t, ViewMouseBindingOpts{X: -11, Y: -6, Key: MouseLeft}, received[0])
assert.Equal(t, 0, view.CursorX())
assert.Equal(t, 0, view.CursorY())
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 79,
MouseY: 23,
Key: NewKeyName(MouseRelease),
}))
assert.Equal(t, ViewMouseBindingOpts{X: 68, Y: 17, Key: MouseRelease}, received[1])
assert.Equal(t, 0, view.CursorX())
assert.Equal(t, 0, view.CursorY())
assert.Nil(t, g.mouseCapture)
}
func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
g := newTestGui(t)
left, _ := g.SetView("left", 0, 0, 20, 10, 0)
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
receivedBy := ""
for _, viewName := range []string{"left", "right"} {
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
ViewName: viewName,
Key: MouseLeft,
Modifier: ModMotion,
Handler: func(ViewMouseBindingOpts) error {
receivedBy = viewName
return nil
},
}))
}
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: left.x0 + 1,
MouseY: left.y0 + 1,
Key: NewKeyName(MouseLeft),
}))
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 22,
MouseY: 1,
Key: NewKey(MouseLeft, "", ModMotion),
}))
assert.Equal(t, "left", receivedBy)
}
func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
g := newTestGui(t)
view, _ := g.SetView("tabs", 0, 0, 40, 10, 0)
view.Tabs = []string{"first", "second"}
clickedTabs := []int{}
assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
clickedTabs = append(clickedTabs, tabIndex)
return nil
}))
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: view.x0 + 1,
MouseY: view.y0 + 1,
Key: NewKeyName(MouseLeft),
}))
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: view.x0 + 3,
MouseY: view.y0,
Key: NewKey(MouseLeft, "", ModMotion),
}))
assert.Empty(t, clickedTabs)
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: view.x0 + 3,
MouseY: view.y0,
Key: NewKeyName(MouseRelease),
}))
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: view.x0 + 3,
MouseY: view.y0,
Key: NewKeyName(MouseLeft),
}))
assert.Equal(t, []int{0}, clickedTabs)
}
func TestRejectedMouseReleaseClearsCapture(t *testing.T) {
g := newTestGui(t)
view, _ := g.SetView("captured", 0, 0, 20, 10, 0)
g.captureMouse(view)
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: view.x0 + 1,
MouseY: view.y0 + 1,
Key: NewKeyName(MouseRelease),
}))
assert.Nil(t, g.mouseCapture)
}
func TestDeleteViewClearsMouseState(t *testing.T) {
g := newTestGui(t)
view, _ := g.SetView("temporary", 0, 0, 20, 10, 0)
g.captureMouse(view)
g.lastHoverView = view
assert.NoError(t, g.DeleteView("temporary"))
assert.Nil(t, g.mouseCapture)
assert.True(t, g.mouseGestureCanceled)
assert.Nil(t, g.lastHoverView)
}
func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
g := newTestGui(t)
left, _ := g.SetView("left", 0, 0, 20, 10, 0)
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
receivedBy := ""
for _, viewName := range []string{"left", "right"} {
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
ViewName: viewName,
Key: MouseLeft,
Modifier: ModMotion,
Handler: func(ViewMouseBindingOpts) error {
receivedBy = viewName
return nil
},
}))
}
g.captureMouse(left)
g.CancelMouseCapture()
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 22,
MouseY: 1,
Key: NewKey(MouseLeft, "", ModMotion),
}))
assert.Empty(t, receivedBy)
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 22,
MouseY: 1,
Key: NewKeyName(MouseRelease),
}))
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 22,
MouseY: 1,
Key: NewKeyName(MouseLeft),
}))
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse,
MouseX: 23,
MouseY: 1,
Key: NewKey(MouseLeft, "", ModMotion),
}))
assert.Equal(t, "right", receivedBy)
}