mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 10:13:41 -05:00
Add DragAutoScroller helper
It can be used to auto-scroll a view during drag gestures when the cursor reaches the edge of the view.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user