diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go index 9c73da1e9..f6a14f620 100644 --- a/pkg/gocui/double_click_test.go +++ b/pkg/gocui/double_click_test.go @@ -32,3 +32,37 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) { assert.Equal(t, []bool{false, true}, doubleClicks) } + +func TestASwallowedClickIsNoHalfOfADoubleClick(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + g := newTestGui(t) + view, _ := g.SetView("list", 0, 0, 20, 10, 0) + doubleClicks := []bool{} + g.SetViewClickBinding(&ViewMouseBinding{ + ViewName: "list", + Key: MouseLeft, + Handler: func(opts ViewMouseBindingOpts) error { + doubleClicks = append(doubleClicks, opts.IsDoubleClick) + return nil + }, + }) + + press := gocuiEventFromTcellEvent( + tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)) + release := gocuiEventFromTcellEvent( + tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonNone, tcell.ModNone)) + + // A click the client rejects, as it does for one landing behind a popup panel. + g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false } + assert.NoError(t, g.onKey(&press)) + assert.NoError(t, g.onKey(&release)) + assert.Empty(t, doubleClicks) + + // The same spot clicked again once clicks are accepted. It is a click of its + // own, not the second half of the one that nothing acted on. + g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return true } + assert.NoError(t, g.onKey(&press)) + + assert.Equal(t, []bool{false}, doubleClicks) +} diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index eeaa87c45..9f46887f3 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -91,6 +91,14 @@ type ViewMouseBinding struct { // must be a mouse key Key KeyName + + // If true, this binding is dispatched before ShouldHandleMouseEvent is + // consulted, so it fires even when a popup panel is focused and the click + // lands on a view other than that panel (which is normally swallowed). This + // is the same early phase that hyperlink clicks are handled in; use it for + // clicks that must stay live behind a popup, e.g. opening a diff line in the + // editor from the main view behind the commit-message panel. + HandleWhenPopupPanelFocused bool } type ViewMouseBindingOpts struct { @@ -1813,6 +1821,25 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } } + var mouseOpts ViewMouseBindingOpts + if IsMouseKey(ev.Key) { + mouseOpts = ViewMouseBindingOpts{ + X: newX, Y: newY, Key: ev.Key.KeyName(), + IsDoubleClick: g.isDoubleClick(newX, newY, ev.Key.KeyName(), v), + } + + // Dispatch bindings that opt into firing while a popup panel is focused + // before the gate below gets a chance to reject the click. + matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true) + if err != nil { + return err + } + if matched { + g.recordClickInfo(newX, newY, ev.Key.KeyName(), v) + return nil + } + } + if g.ShouldHandleMouseEvent != nil { if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) { // Give clients a chance to reject clicks, for example clicks in inactive views @@ -1862,9 +1889,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } if IsMouseKey(ev.Key) { - isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v) - opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick} - matched, err := g.execMouseKeybindings(v, ev, opts) + g.recordClickInfo(newX, newY, ev.Key.KeyName(), v) + matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false) if err != nil { return err } @@ -1896,43 +1922,49 @@ func (g *Gui) onKey(ev *GocuiEvent) error { return nil } -// remember the information for this click, and return true if it was a double click -func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool { +// isDoubleClick reports whether this click follows one just like it, closely +// enough in time to count as a double click. +func (g *Gui) isDoubleClick(x, y int, key KeyName, v *View) bool { + return g.lastClick != nil && + !IsMouseScrollKey(key) && + key != MouseRelease && + x == g.lastClick.x && + y == g.lastClick.y && + key == g.lastClick.key && + v.Name() == g.lastClick.viewName && + time.Now().Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD)) +} + +// recordClickInfo remembers this click as the one a following click is compared +// against. Only the clicks that reach a binding are recorded, so a click the +// client rejects leaves double-click detection where it was. +func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) { if IsMouseScrollKey(key) { g.lastClick = nil - return false + return } // A release ends a gesture but is not a click of its own; it must leave // the click info of the press that started it alone, or no double click // could ever be detected. if key == MouseRelease { - return false + return } - clickInfo := &clickInfo{ + g.lastClick = &clickInfo{ x: x, y: y, key: key, viewName: v.Name(), time: time.Now(), } - - isDoubleClick := g.lastClick != nil && - clickInfo.x == g.lastClick.x && - clickInfo.y == g.lastClick.y && - clickInfo.key == g.lastClick.key && - clickInfo.viewName == g.lastClick.viewName && - clickInfo.time.Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD)) - - g.lastClick = clickInfo - return isDoubleClick } -func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) { +func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) { isMatch := func(binding *ViewMouseBinding) bool { return binding.ViewName == view.Name() && ev.Key.KeyName() == binding.Key && - ev.Key.Mod() == binding.Modifier + ev.Key.Mod() == binding.Modifier && + binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused } // first pass looks for ones that match the focused view diff --git a/pkg/gocui/mouse_binding_test.go b/pkg/gocui/mouse_binding_test.go new file mode 100644 index 000000000..7b522559a --- /dev/null +++ b/pkg/gocui/mouse_binding_test.go @@ -0,0 +1,49 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOnlyBindingsThatOptedInFireBehindAFocusedPopup(t *testing.T) { + g := newTestGui(t) + _, _ = g.SetView("main", 0, 0, 20, 10, 0) + + fired := []string{} + g.SetViewClickBinding(&ViewMouseBinding{ + ViewName: "main", + Key: MouseLeft, + Modifier: ModAlt, + HandleWhenPopupPanelFocused: true, + Handler: func(ViewMouseBindingOpts) error { + fired = append(fired, "opted in") + return nil + }, + }) + g.SetViewClickBinding(&ViewMouseBinding{ + ViewName: "main", + Key: MouseLeft, + Handler: func(ViewMouseBindingOpts) error { + fired = append(fired, "ordinary") + return nil + }, + }) + + // This is how a client reports that a popup panel has the focus and the click + // landed on a view behind it. + g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false } + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, MouseX: 3, MouseY: 4, + Key: NewKey(MouseLeft, "", ModAlt), + })) + assert.Equal(t, []string{"opted in"}, fired) + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, MouseX: 3, MouseY: 4, + Key: NewKey(MouseLeft, "", ModNone), + })) + assert.Equal(t, []string{"opted in"}, fired, + "a binding that didn't opt in must still be swallowed") +}