mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 10:13:41 -05:00
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:
+63
-7
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user