Time double clicks by when the clicks were read

gocui decides whether two clicks form a double click by the time it
handles them. If handling the first click keeps the main loop busy for
more than 500ms, a quick second click doesn't count as a double click.
The other way round, two separate clicks that wait in the event queue
while the main loop is busy count as a double click.

Compare the times at which tcell read the clicks instead. For events
replayed by integration tests, this is the time the test created them.
This way, a DoubleClick in a test no longer depends on how fast lazygit
handles the first click.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-14 14:17:47 +02:00
co-authored by Claude Opus 5.5
parent 5bc855415a
commit 94a3070f02
3 changed files with 21 additions and 12 deletions
-3
View File
@@ -100,9 +100,6 @@ func TestDoubleClicksAreTimedByWhenTheClicksWereRead(t *testing.T) {
click(readAt.Add(DOUBLE_CLICK_THRESHOLD + 100*time.Millisecond))
assert.Equal(t,
/* EXPECTED:
[]bool{false, false, true},
ACTUAL: */
[]bool{false, true, true},
doubleClicks)
}
+10 -8
View File
@@ -1825,7 +1825,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if IsMouseKey(ev.Key) {
mouseOpts = ViewMouseBindingOpts{
X: newX, Y: newY, Key: ev.Key.KeyName(),
IsDoubleClick: g.isDoubleClick(newX, newY, ev.Key.KeyName(), v),
IsDoubleClick: g.isDoubleClick(newX, newY, ev.Key.KeyName(), v, ev.When),
}
// Dispatch bindings that opt into firing while a popup panel is focused
@@ -1835,7 +1835,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
return err
}
if matched {
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v, ev.When)
return nil
}
}
@@ -1899,7 +1899,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
if IsMouseKey(ev.Key) {
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v, ev.When)
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
if err != nil {
return err
@@ -1933,8 +1933,10 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
// 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 {
// enough in time to count as a double click. It goes by when the clicks were
// read. The second click may wait in the event queue while the first one is
// handled, and that wait doesn't count.
func (g *Gui) isDoubleClick(x, y int, key KeyName, v *View, when time.Time) bool {
return g.lastClick != nil &&
!IsMouseScrollKey(key) &&
key != MouseRelease &&
@@ -1942,13 +1944,13 @@ func (g *Gui) isDoubleClick(x, y int, key KeyName, v *View) bool {
y == g.lastClick.y &&
key == g.lastClick.key &&
v.Name() == g.lastClick.viewName &&
time.Now().Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
when.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) {
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View, when time.Time) {
if IsMouseScrollKey(key) {
g.lastClick = nil
return
@@ -1965,7 +1967,7 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) {
y: y,
key: key,
viewName: v.Name(),
time: time.Now(),
time: when,
}
}
+11 -1
View File
@@ -5,6 +5,8 @@
package gocui
import (
"time"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/vt"
)
@@ -163,6 +165,8 @@ type gocuiEventType uint8
//
// The 'Mod', 'Key' and 'Ch' fields are valid if 'Type' is 'eventKey'.
// The 'MouseX' and 'MouseY' fields are valid if 'Type' is 'eventMouse'.
// The 'When' field is valid if 'Type' is 'eventMouse'. It is the time the
// input was read, which can be well before the main loop handles the event.
// The 'Width' and 'Height' fields are valid if 'Type' is 'eventResize'.
// The 'Focused' field is valid if 'Type' is 'eventFocus'.
// The 'Start' field is valid if 'Type' is 'eventPaste'. It is true for the
@@ -176,6 +180,7 @@ type GocuiEvent struct {
Err error
MouseX int
MouseY int
When time.Time
Focused bool
Start bool
N int
@@ -241,6 +246,7 @@ type TcellMouseEventWrapper struct {
Y int
ButtonMask tcell.ButtonMask
ModMask tcell.ModMask
When time.Time
task Task // see GocuiEvent.task
}
@@ -252,11 +258,14 @@ func NewTcellMouseEventWrapper(event *tcell.EventMouse) *TcellMouseEventWrapper
Y: y,
ButtonMask: event.Buttons(),
ModMask: event.Modifiers(),
When: event.When(),
}
}
func (wrapper TcellMouseEventWrapper) toTcellEvent() tcell.Event {
return tcell.NewEventMouse(wrapper.X, wrapper.Y, wrapper.ButtonMask, wrapper.ModMask)
event := tcell.NewEventMouse(wrapper.X, wrapper.Y, wrapper.ButtonMask, wrapper.ModMask)
event.SetEventTime(wrapper.When)
return event
}
type TcellResizeEventWrapper struct {
@@ -446,6 +455,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
MouseX: x,
MouseY: y,
Key: NewKey(mouseKey, "", mouseMod),
When: tev.When(),
}
case *tcell.EventFocus:
return GocuiEvent{