diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index e5526a109..be52c8584 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -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) diff --git a/pkg/gocui/mouse_capture_test.go b/pkg/gocui/mouse_capture_test.go new file mode 100644 index 000000000..eea1e3f9f --- /dev/null +++ b/pkg/gocui/mouse_capture_test.go @@ -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) +}