From 9e76ed7c03b1a26db57bed70a299b0bdc8ee86bc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 14 Oct 2026 14:12:19 +0200 Subject: [PATCH 1/4] Send the two clicks of a double click in tests without waiting between The test enter_and_double_click_focus_file_diff fails occasionally on CI. After it double-clicks a file, the files panel still has the focus instead of the main view. The test double-clicks by calling Click twice. Click waits for lazygit to become idle after the press and again after the release, and the first wait includes the git diff that the click starts for the main view. gocui only counts the second click as a double click if it handles it within 500ms of the first one. On a busy CI runner, where many integration tests run in parallel, the waits can take longer than that, and the second click counts as a single click. Add DoubleClick to the test driver. It replays both clicks without waiting in between, as a terminal reports a double click, and waits for lazygit to become idle only after the second one. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/gui_driver.go | 10 ++++++++++ pkg/integration/components/test_driver.go | 6 ++++++ pkg/integration/components/test_test.go | 9 +++++++-- pkg/integration/components/view_driver.go | 8 ++++++++ .../enter_and_double_click_focus_file_diff.go | 6 ++---- pkg/integration/types/types.go | 4 ++++ 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 5db3d9d92..badc36283 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -63,6 +63,16 @@ func (self *GuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) { self.replayMouseEvent(x, y, tcell.ButtonNone, modifier) } +func (self *GuiDriver) DoubleClick(x, y int) { + self.CheckAllToastsAcknowledged() + + for range 2 { + self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonPrimary, gocui.ModNone) + self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone, gocui.ModNone) + } + self.waitTillIdle() +} + func (self *GuiDriver) ClickAndHold(x, y int) { self.CheckAllToastsAcknowledged() self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone) diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 825ce5688..e924a956e 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -61,6 +61,12 @@ func (self *TestDriver) click(x, y int) { self.Wait(self.inputDelay) } +func (self *TestDriver) doubleClick(x, y int) { + self.SetCaption(fmt.Sprintf("Double-clicking %d, %d", x, y)) + self.gui.DoubleClick(x, y) + self.Wait(self.inputDelay) +} + func (self *TestDriver) clickWithModifier(x, y int, modifier gocui.Modifier, what string) { self.SetCaption(fmt.Sprintf("%s-clicking %d, %d", what, x, y)) self.gui.ClickWithModifier(x, y, modifier) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index ef77ddaec..7a69c07ad 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -51,6 +51,10 @@ func (self *fakeGuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) DoubleClick(x, y int) { + self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) +} + func (self *fakeGuiDriver) ClickAndHold(x, y int) { self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y}) } @@ -207,6 +211,7 @@ func TestViewDriverPointerCoordinates(t *testing.T) { AltClick(2, 3). ShiftClick(4, 5). FocusInAndClick(3, 4). + DoubleClick(6, 7). ClickAndHold(5, 6). MouseMove(7, 8). MouseMoveToBottom(9). @@ -214,12 +219,12 @@ func TestViewDriverPointerCoordinates(t *testing.T) { ScrollWheelDown() assert.Equal(t, - []coordinate{{12, 23}, {13, 24}, {15, 26}, {14, 25}}, + []coordinate{{12, 23}, {13, 24}, {15, 26}, {14, 25}, {17, 28}}, guiDriver.clickedCoordinates) assert.Equal(t, []coordinate{{16, 27}}, guiDriver.heldCoordinates) assert.Equal(t, []coordinate{{18, 29}, {20, 30}, {51, 62}}, guiDriver.movedCoordinates) assert.Equal(t, []coordinate{{11, 21}}, guiDriver.scrolledCoordinates) - assert.Equal(t, 9, guiDriver.onUIThreadCallCount) + assert.Equal(t, 10, guiDriver.onUIThreadCallCount) } func TestFailingFixture(t *testing.T) { diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index d1b65a0d9..6fae04a86 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -735,6 +735,14 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver { return self } +func (self *ViewDriver) DoubleClick(x, y int) *ViewDriver { + offsetX, offsetY, _ := self.viewGeometry() + + self.t.doubleClick(offsetX+1+x, offsetY+1+y) + + return self +} + // AltClick and ShiftClick click with a modifier held down. Both modifiers are // bound to the same gestures, because no single one of them reaches lazygit in // every terminal. diff --git a/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go b/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go index a9be802b9..cfcebfa57 100644 --- a/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go +++ b/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go @@ -32,8 +32,7 @@ var EnterAndDoubleClickFocusFileDiff = NewIntegrationTest(NewIntegrationTestArgs t.Views().Files(). IsFocused(). - Click(3, 0). - Click(3, 0) + DoubleClick(3, 0) t.Views().Main(). IsFocused(). SelectedLines(Contains("-base")). @@ -60,8 +59,7 @@ var EnterAndDoubleClickFocusFileDiff = NewIntegrationTest(NewIntegrationTestArgs t.Views().CommitFiles(). IsFocused(). - Click(3, 0). - Click(3, 0) + DoubleClick(3, 0) t.Views().Main(). IsFocused(). SelectedLines(Contains("+base")) diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index b6655aebc..1d24fd6ad 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,10 @@ type GuiDriver interface { // Click with a keyboard modifier held down, for the gestures that only exist // as a modified click. ClickWithModifier(int, int, gocui.Modifier) + // Two clicks in immediate succession, waiting for lazygit to become idle only + // after the second one. If it waited after the first one too, the second click + // could come too late to count as a double click. + DoubleClick(int, int) ClickAndHold(int, int) MouseMove(int, int) MouseRelease(int, int) From 0855e981cf279cdb0a986fc629e09662c641a187 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 14 Oct 2026 14:14:08 +0200 Subject: [PATCH 2/4] Remove the unused Timestamp of replayed input events Every caller passes 0, and nothing reads it. The next commit makes replayed mouse events carry the time they were created; a second, unused timestamp next to it would only confuse. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/tcell_driver.go | 40 ++++++++++++++++----------------------- pkg/gui/gui_driver.go | 6 ------ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index d8e97b9b6..b64088550 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -217,20 +217,18 @@ var ( // this wrapper struct has public keys so we can easily serialize/deserialize to JSON type TcellKeyEventWrapper struct { - Timestamp int64 - Mod tcell.ModMask - Key tcell.Key - Ch string + Mod tcell.ModMask + Key tcell.Key + Ch string task Task // see GocuiEvent.task } -func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper { +func NewTcellKeyEventWrapper(event *tcell.EventKey) *TcellKeyEventWrapper { return &TcellKeyEventWrapper{ - Timestamp: timestamp, - Mod: event.Modifiers(), - Key: event.Key(), - Ch: event.Str(), + Mod: event.Modifiers(), + Key: event.Key(), + Ch: event.Str(), } } @@ -239,7 +237,6 @@ func (wrapper TcellKeyEventWrapper) toTcellEvent() tcell.Event { } type TcellMouseEventWrapper struct { - Timestamp int64 X int Y int ButtonMask tcell.ButtonMask @@ -248,10 +245,9 @@ type TcellMouseEventWrapper struct { task Task // see GocuiEvent.task } -func NewTcellMouseEventWrapper(event *tcell.EventMouse, timestamp int64) *TcellMouseEventWrapper { +func NewTcellMouseEventWrapper(event *tcell.EventMouse) *TcellMouseEventWrapper { x, y := event.Position() return &TcellMouseEventWrapper{ - Timestamp: timestamp, X: x, Y: y, ButtonMask: event.Buttons(), @@ -264,18 +260,16 @@ func (wrapper TcellMouseEventWrapper) toTcellEvent() tcell.Event { } type TcellResizeEventWrapper struct { - Timestamp int64 - Width int - Height int + Width int + Height int } -func NewTcellResizeEventWrapper(event *tcell.EventResize, timestamp int64) *TcellResizeEventWrapper { +func NewTcellResizeEventWrapper(event *tcell.EventResize) *TcellResizeEventWrapper { w, h := event.Size() return &TcellResizeEventWrapper{ - Timestamp: timestamp, - Width: w, - Height: h, + Width: w, + Height: h, } } @@ -284,16 +278,14 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event { } type TcellFocusEventWrapper struct { - Timestamp int64 - Focused bool + Focused bool task Task // see GocuiEvent.task } -func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper { +func NewTcellFocusEventWrapper(event *tcell.EventFocus) *TcellFocusEventWrapper { return &TcellFocusEventWrapper{ - Timestamp: timestamp, - Focused: event.Focused, + Focused: event.Focused, } } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index badc36283..f66bcd020 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -43,7 +43,6 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) { self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), - 0, )) } @@ -115,7 +114,6 @@ func (self *GuiDriver) replayMouseEventWithoutWaiting( ) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, buttons, tcell.ModMask(modifier)), - 0, )) } @@ -125,11 +123,9 @@ func (self *GuiDriver) replayMouseEventWithoutWaiting( func (self *GuiDriver) replayFocusIn() { self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( tcell.NewEventFocus(false), - 0, )) self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( tcell.NewEventFocus(true), - 0, )) } @@ -148,12 +144,10 @@ func (self *GuiDriver) FocusInAndClick(x, y int) { self.replayFocusIn() self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), - 0, )) self.waitTillIdle() self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), - 0, )) self.waitTillIdle() } From 5bc855415a47b8d6d25d8c4820483c8caed2e525 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 14 Oct 2026 14:15:25 +0200 Subject: [PATCH 3/4] Add a test for clicks that wait in the event queue If the main loop is busy, clicks wait in the event queue and are then handled right after each other. gocui decides whether two clicks form a double click by the time it handles them, so two clicks read more than 500ms apart count as a double click. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/double_click_test.go | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go index f6a14f620..ec8cb26d3 100644 --- a/pkg/gocui/double_click_test.go +++ b/pkg/gocui/double_click_test.go @@ -2,6 +2,7 @@ package gocui import ( "testing" + "time" "github.com/gdamore/tcell/v3" "github.com/stretchr/testify/assert" @@ -66,3 +67,42 @@ func TestASwallowedClickIsNoHalfOfADoubleClick(t *testing.T) { assert.Equal(t, []bool{false}, doubleClicks) } + +func TestDoubleClicksAreTimedByWhenTheClicksWereRead(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 + }, + }) + + click := func(readAt time.Time) { + for _, buttons := range []tcell.ButtonMask{tcell.ButtonPrimary, tcell.ButtonNone} { + tev := tcell.NewEventMouse(view.x0+1, view.y0+1, buttons, tcell.ModNone) + tev.SetEventTime(readAt) + event := gocuiEventFromTcellEvent(tev) + assert.NoError(t, g.onKey(&event)) + } + } + + // The clicks are handled right after each other, long after they were read, + // as they are when they wait in the queue while the main loop is busy. + readAt := time.Now().Add(-time.Minute) + click(readAt) + click(readAt.Add(DOUBLE_CLICK_THRESHOLD + time.Millisecond)) + click(readAt.Add(DOUBLE_CLICK_THRESHOLD + 100*time.Millisecond)) + + assert.Equal(t, + /* EXPECTED: + []bool{false, false, true}, + ACTUAL: */ + []bool{false, true, true}, + doubleClicks) +} From 94a3070f0224c6084b2392d6532ad2e9c618c06a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 14 Oct 2026 14:17:47 +0200 Subject: [PATCH 4/4] 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{