From 94a3070f0224c6084b2392d6532ad2e9c618c06a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 14 Oct 2026 14:17:47 +0200 Subject: [PATCH] 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) --- pkg/gocui/double_click_test.go | 3 --- pkg/gocui/gui.go | 18 ++++++++++-------- pkg/gocui/tcell_driver.go | 12 +++++++++++- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go index ec8cb26d3..134bc798b 100644 --- a/pkg/gocui/double_click_test.go +++ b/pkg/gocui/double_click_test.go @@ -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) } diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index adaa1f650..cf0ded407 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -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, } } diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index b64088550..61a3f0e89 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -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{