From f5a069342b9748f294df2bd9d7ba3c398996377e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:17:19 +0200 Subject: [PATCH] Add DragAutoScroller helper It can be used to auto-scroll a view during drag gestures when the cursor reaches the edge of the view. --- .../controllers/helpers/drag_autoscroller.go | 154 ++++++++++++++++++ .../helpers/drag_autoscroller_test.go | 62 +++++++ 2 files changed, 216 insertions(+) create mode 100644 pkg/gui/controllers/helpers/drag_autoscroller.go create mode 100644 pkg/gui/controllers/helpers/drag_autoscroller_test.go diff --git a/pkg/gui/controllers/helpers/drag_autoscroller.go b/pkg/gui/controllers/helpers/drag_autoscroller.go new file mode 100644 index 000000000..7ec6a9c76 --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller.go @@ -0,0 +1,154 @@ +package helpers + +import ( + "time" + + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +const ( + dragAutoscrollInitialDelay = 300 * time.Millisecond + dragAutoscrollSlowInterval = 250 * time.Millisecond + dragAutoscrollFastInterval = 100 * time.Millisecond + dragAutoscrollVeryFastInterval = 50 * time.Millisecond +) + +// All state is UI-thread-owned. Timer goroutines only enqueue tick back onto +// the UI thread, where generation changes and scroll callbacks are serialized +// with mouse handlers and focus changes. +type DragAutoscroller struct { + c *HelperCommon + context types.Context + + canScroll func(direction int) bool + onScroll func(viewIndex int) bool + + // Incremented whenever the scroll direction changes or the autoscroller + // is canceled. A scheduled tick carries the generation it was created + // for, so stale ticks can be told apart from the one that is current. + generation uint64 + direction int + interval time.Duration + // Last known pointer position relative to the viewport; used by ticks to + // compute which line ends up under the pointer after scrolling. + pointerViewportY int +} + +func NewDragAutoscroller( + c *HelperCommon, + context types.Context, + canScroll func(direction int) bool, + onScroll func(viewIndex int) bool, +) *DragAutoscroller { + return &DragAutoscroller{ + c: c, + context: context, + canScroll: canScroll, + onScroll: onScroll, + } +} + +// Update is called with the pointer position of every drag event. Entering a +// scroll zone arms a timer (with an initial delay, so that merely passing +// through the zone doesn't scroll); once armed, scrolling continues on its +// own until the pointer leaves the zone, the drag ends, or a callback stops +// it. +func (self *DragAutoscroller) Update(pointerViewportY int) { + _, viewportHeight := self.context.GetViewTrait().ViewPortYBounds() + direction, interval := dragAutoscrollZone(viewportHeight, pointerViewportY) + if direction != 0 && self.canScroll != nil && !self.canScroll(direction) { + direction = 0 + interval = 0 + } + + self.pointerViewportY = pointerViewportY + generation, schedule := self.updateState(direction, interval) + if schedule { + self.schedule(generation, dragAutoscrollInitialDelay) + } +} + +func (self *DragAutoscroller) Direction() int { + return self.direction +} + +func (self *DragAutoscroller) updateState(direction int, interval time.Duration) (uint64, bool) { + if direction == self.direction { + self.interval = interval + return self.generation, false + } + + self.generation++ + self.direction = direction + self.interval = interval + return self.generation, direction != 0 +} + +func (self *DragAutoscroller) Cancel() { + self.generation++ + self.direction = 0 + self.interval = 0 +} + +func (self *DragAutoscroller) schedule(generation uint64, delay time.Duration) { + time.AfterFunc(delay, func() { + self.c.OnUIThreadBackground(func() error { + self.tick(generation) + return nil + }) + }) +} + +func (self *DragAutoscroller) tick(generation uint64) { + if generation != self.generation { + return + } + if self.direction == 0 || + self.canScroll != nil && !self.canScroll(self.direction) { + self.Cancel() + return + } + + view := self.context.GetViewTrait() + oldOriginY, _ := view.ViewPortYBounds() + if self.direction < 0 { + view.ScrollUp(1) + } else { + view.ScrollDown(1) + } + newOriginY, _ := view.ViewPortYBounds() + if newOriginY == oldOriginY { + self.Cancel() + return + } + + if !self.onScroll(newOriginY + self.pointerViewportY) { + self.Cancel() + return + } + + self.schedule(generation, self.interval) +} + +// dragAutoscrollZone returns the scroll direction and tick interval for a +// pointer position: anything beyond the view scrolls very fast, the outermost +// viewport row scrolls fast, the row just inside it scrolls slowly, and anything +// further inside doesn't scroll at all. +func dragAutoscrollZone(viewportHeight int, pointerViewportY int) (int, time.Duration) { + switch { + case pointerViewportY < 0: + return -1, dragAutoscrollVeryFastInterval + case pointerViewportY == 0: + return -1, dragAutoscrollFastInterval + case pointerViewportY == 1: + return -1, dragAutoscrollSlowInterval + case pointerViewportY > viewportHeight-1: + return 1, dragAutoscrollVeryFastInterval + case pointerViewportY == viewportHeight-1: + return 1, dragAutoscrollFastInterval + case pointerViewportY == viewportHeight-2: + return 1, dragAutoscrollSlowInterval + default: + return 0, 0 + } +} diff --git a/pkg/gui/controllers/helpers/drag_autoscroller_test.go b/pkg/gui/controllers/helpers/drag_autoscroller_test.go new file mode 100644 index 000000000..40faa789e --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller_test.go @@ -0,0 +1,62 @@ +package helpers + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDragAutoscrollZone(t *testing.T) { + testCases := []struct { + name string + pointerViewportY int + expectedDirection int + expectedInterval time.Duration + }{ + {name: "above view", pointerViewportY: -1, expectedDirection: -1, expectedInterval: dragAutoscrollVeryFastInterval}, + {name: "top outer row", pointerViewportY: 0, expectedDirection: -1, expectedInterval: dragAutoscrollFastInterval}, + {name: "top inner row", pointerViewportY: 1, expectedDirection: -1, expectedInterval: dragAutoscrollSlowInterval}, + {name: "middle", pointerViewportY: 5}, + {name: "bottom inner row", pointerViewportY: 8, expectedDirection: 1, expectedInterval: dragAutoscrollSlowInterval}, + {name: "bottom outer row", pointerViewportY: 9, expectedDirection: 1, expectedInterval: dragAutoscrollFastInterval}, + {name: "below view", pointerViewportY: 10, expectedDirection: 1, expectedInterval: dragAutoscrollVeryFastInterval}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + direction, interval := dragAutoscrollZone(10, testCase.pointerViewportY) + + assert.Equal(t, testCase.expectedDirection, direction) + assert.Equal(t, testCase.expectedInterval, interval) + }) + } +} + +func TestDragAutoscrollerDoesNotRestartWhenMovingToOuterEdge(t *testing.T) { + self := &DragAutoscroller{ + generation: 1, + direction: 1, + interval: dragAutoscrollSlowInterval, + } + + generation, schedule := self.updateState(1, dragAutoscrollFastInterval) + + assert.Equal(t, uint64(1), generation) + assert.False(t, schedule) + assert.Equal(t, dragAutoscrollFastInterval, self.interval) +} + +func TestStaleDragAutoscrollTickDoesNotCancelCurrentGeneration(t *testing.T) { + self := &DragAutoscroller{ + generation: 4, + direction: 1, + interval: dragAutoscrollFastInterval, + } + + self.tick(2) + + assert.Equal(t, uint64(4), self.generation) + assert.Equal(t, 1, self.direction) + assert.Equal(t, dragAutoscrollFastInterval, self.interval) +}