mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-06 05:57:38 -04:00
Let mouse bindings work behind focused popups
Mouse events on views behind a popup are normally swallowed before their bindings can run. Add an explicit early-dispatch opt-in for actions that should remain live there, matching the phase where hyperlink clicks already run.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+52
-20
@@ -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 {
|
||||
@@ -1807,6 +1815,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
|
||||
@@ -1856,9 +1883,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
|
||||
}
|
||||
@@ -1890,43 +1916,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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user