~eliasnaur/gio-patches

layout: add List.ScrollTo, ScrollPages, and 2 more v2 PROPOSED

Larry Clapp: 1
 layout: add List.ScrollTo, ScrollPages, and 2 more

 2 files changed, 311 insertions(+), 8 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~eliasnaur/gio-patches/patches/11668/mbox | git am -3
Learn more about email & git
View this thread in the archives

[PATCH v2] layout: add List.ScrollTo, ScrollPages, and 2 more Export this patch

Also add PagePrev & PageNext.

Include tests for all.
Signed-off-by: Larry Clapp <larry@theclapp.org>
---
 layout/list.go      | 118 ++++++++++++++++++++++++--
 layout/list_test.go | 201 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 311 insertions(+), 8 deletions(-)
 create mode 100644 layout/list_test.go

diff --git a/layout/list.go b/layout/list.go
index 2375fea..567ef4a 100644
--- a/layout/list.go
+++ b/layout/list.go
@@ -23,9 +23,14 @@ type List struct {
	Axis Axis
	// ScrollToEnd instructs the list to stay scrolled to the far end position
	// once reached. A List with ScrollToEnd == true and Position.BeforeEnd ==
	// false draws its content with the last item at the bottom of the list
	// area.
	// false draws its content with the last item at the end of the list area.
	ScrollToEnd bool
	// If fromEnd is true, then draw fromEndItem at the end of the list area
	// and go back from there, as if ScrollToEnd was true and this item was the
	// last item in the list. If fromEnd is false, fromEndItem is ignored.
	fromEnd     bool
	fromEndItem int

	// Alignment is the cross axis alignment of list elements.
	Alignment Alignment

@@ -36,7 +41,7 @@ type List struct {
	// Position is updated during Layout. To save the list scroll position,
	// just save Position after Layout finishes. To scroll the list
	// programatically, update Position (e.g. restore it from a saved value)
	// before calling Layout.
	// before calling Layout, or use ScrollTo and related functions.
	Position Position

	len int
@@ -45,6 +50,10 @@ type List struct {
	maxSize  int
	children []scrollChild
	dir      iterationDir

	// size is the width or height, in pixels, at the last layout, used in
	// ScrollPages.
	size int
}

// ListElement is a function that computes the dimensions of
@@ -66,8 +75,10 @@ type Position struct {
	BeforeEnd bool
	// First is the index of the first visible child.
	First int
	// last is the index of the last visible child.
	last int
	// Offset is the distance in pixels from the top edge to the child at index
	// First.
	// First. Positive offsets are before (above or left of) the window edge.
	Offset int
}

@@ -89,7 +100,7 @@ func (l *List) init(gtx Context, len int) {
	l.children = l.children[:0]
	l.len = len
	l.update()
	if l.scrollToEnd() || l.Position.First > len {
	if (!l.fromEnd && l.scrollToEnd()) || l.Position.First > len {
		l.Position.Offset = 0
		l.Position.First = len
	}
@@ -111,7 +122,7 @@ func (l *List) Layout(gtx Context, len int, w ListElement) Dimensions {
}

func (l *List) scrollToEnd() bool {
	return l.ScrollToEnd && !l.Position.BeforeEnd
	return l.fromEnd || (l.ScrollToEnd && !l.Position.BeforeEnd)
}

// Dragging reports whether the List is being dragged.
@@ -119,6 +130,80 @@ func (l *List) Dragging() bool {
	return l.scroll.State() == gesture.StateDragging
}

// ScrollTo makes sure list index item i is in view.
//
// If it's above the top, it becomes the top item. If it's below the bottom,
// it becomes the bottom item, with said item drawn starting at the end of the
// item. (This means that if the item is taller/longer than the list area, the
// beginning of the item will be out of view.)
//
// If i < 0, uses 0.
//
// If you ScrollTo(n) and then layout a list shorter than n, Layout scrolls to
// the end of the list.
func (l *List) ScrollTo(item int) {
	if item < 0 {
		item = 0
	} else if item >= l.len {
		if l.len > 0 {
			item = l.len - 1
		} else {
			item = 0
		}
	}

	// Set default.
	l.fromEnd = false

	// If item is already entirely in view, do nothing.
	if l.Position.First < item && item < l.Position.last {
		return
	}

	if (l.Position.First > 0 || l.Position.Offset > 0) && item <= l.Position.First {
		// Item is before, or equal to, the first item drawn. Draw item at
		// offset 0, at the beginning of the list, and go forward.
		l.Position.First = item
		l.Position.Offset = 0
		l.Position.BeforeEnd = true
	} else if item >= l.Position.last {
		// Item is after the last item drawn. Draw the end of item at the end of
		// the list, and go backward.
		l.Position.First = item + 1
		l.fromEnd = true
		l.fromEndItem = item
	}
}

// ScrollPages scrolls a number of pages. n < 0 is up or left, n > 0 is down
// or right. n == 0 is a no-op.
//
// The "page size" is the size of the major axis of the list at its last
// layout. Thus, ScrollPages only works if you've laid out the list at least
// once.
func (l *List) ScrollPages(n int) {
	// If going nowhere, or going backward and we're already at the beginning,
	// or going forward and we're already at the end, do nothing.
	if n == 0 ||
		(n < 0 && l.Position.BeforeEnd && l.Position.First == 0 && l.Position.Offset == 0) ||
		(n > 0 && !l.Position.BeforeEnd) {
		return
	}

	l.Position.Offset += (l.size * n)
	// If you don't do this and l.ScrollToEnd == true, Position.Offset is
	// ignored, so you couldn't ScrollPages(-1) from the end of the list.
	l.Position.BeforeEnd = true
}

func (l *List) PagePrev() {
	l.ScrollPages(-1)
}

func (l *List) PageNext() {
	l.ScrollPages(1)
}

func (l *List) update() {
	d := l.scroll.Scroll(l.ctx.Metric, l.ctx, l.ctx.Now, gesture.Axis(l.Axis))
	l.scrollDelta = d
@@ -158,7 +243,8 @@ func (l *List) nextDir() iterationDir {
	_, vsize := axisMainConstraint(l.Axis, l.ctx.Constraints)
	last := l.Position.First + len(l.children)
	// Clamp offset.
	if l.maxSize-l.Position.Offset < vsize && last == l.len {
	if l.maxSize-l.Position.Offset < vsize &&
		(last == l.len || (l.fromEnd && last == l.fromEndItem+1)) {
		l.Position.Offset = l.maxSize - vsize
	}
	if l.Position.Offset < 0 && l.Position.First == 0 {
@@ -204,7 +290,7 @@ func (l *List) layout(macro op.MacroOp) Dimensions {
	for len(children) > 0 {
		sz := children[0].size
		mainSize := axisMain(l.Axis, sz)
		if l.Position.Offset <= mainSize {
		if l.Position.Offset < mainSize {
			break
		}
		l.Position.First++
@@ -230,6 +316,11 @@ func (l *List) layout(macro op.MacroOp) Dimensions {
	if space := mainMax - size; l.ScrollToEnd && space > 0 {
		pos += space
	}
	if len(children) == 0 {
		l.Position.last = 0
	} else {
		l.Position.last = l.Position.First + len(children) - 1
	}
	for _, child := range children {
		sz := child.size
		var cross int
@@ -277,5 +368,16 @@ func (l *List) layout(macro op.MacroOp) Dimensions {
	pointer.Rect(image.Rectangle{Max: dims}).Add(ops)
	l.scroll.Add(ops)
	call.Add(ops)
	l.fromEnd = false
	l.size = axisMain(l.Axis, dims)
	return Dimensions{Size: dims}
}

// FirstItem returns the index of the first displayed item in the list. In
// the event of large items, FirstItem and LastItem can be the same. FirstItem
// is a convenience function for l.Position.First.
func (l *List) FirstItem() int { return l.Position.First }

// LastItem returns the index of the last displayed item in the list. In the
// event of large items, FirstItem and LastItem can be the same.
func (l *List) LastItem() int { return l.Position.last }
diff --git a/layout/list_test.go b/layout/list_test.go
new file mode 100644
index 0000000..4a2f492
--- /dev/null
+++ b/layout/list_test.go
@@ -0,0 +1,201 @@
package layout

import (
	"image"
	"testing"

	"gioui.org/op"
)

func TestScrollFunctions(t *testing.T) {
	gtx := Context{
		Ops: new(op.Ops),
		Constraints: Constraints{
			Max: image.Pt(1000, 1000),
		},
	}

	l := List{Axis: Vertical}
	listLen := 1000
	layoutList := func(gtx Context) Dimensions {
		return l.Layout(gtx, listLen, func(gtx Context, i int) Dimensions {
			var dims Dimensions
			switch i {
			case 24:
				// Item is really tall: 3x the window size
				dims.Size = image.Pt(1000, 3000)
			default:
				dims.Size = image.Pt(1000, 100)
			}
			return dims
		})
	}
	checkFirstLast := func(first, last int) {
		t.Helper()
		check(t, first, l.FirstItem())
		check(t, last, l.LastItem())
	}

	t.Run("ScrollTo", func(t *testing.T) {
		dims := layoutList(gtx)
		check(t, image.Pt(1000, 1000), dims.Size)
		checkFirstLast(0, 9)

		// ScrollTo an item that's already in view
		l.ScrollTo(1)
		layoutList(gtx)
		checkFirstLast(0, 9)

		// ScrollTo an item that's not in view -- in this case, should shift down
		// one item.
		l.ScrollTo(10)
		layoutList(gtx)
		checkFirstLast(1, 10)

		l.ScrollTo(25)
		layoutList(gtx)
		checkFirstLast(24, 25)
	})

	t.Run("ScrollPage", func(t *testing.T) {
		// Set top of list to item 1
		l.Position = Position{First: 1, BeforeEnd: true}

		l.PageNext()
		layoutList(gtx)
		checkFirstLast(11, 20)

		l.PagePrev()
		layoutList(gtx)
		checkFirstLast(1, 10)

		// ScrollPage -1 with item 1 displayed first
		l.ScrollPages(-1)
		layoutList(gtx)
		checkFirstLast(0, 9)
	})

	t.Run("Scroll to large item", func(t *testing.T) {
		// Item 24 is 3x as tall as the window: show its bottom.
		l.Position.First = 0
		l.Position.Offset = 0
		layoutList(gtx)
		l.ScrollTo(24)
		layoutList(gtx)
		checkFirstLast(24, 24)
		check(t, 2000, l.Position.Offset)

		// Go there again to show its top. (Could also just set Position.First = 24
		// & Position.Offset = 0.)
		l.ScrollTo(24)
		layoutList(gtx)
		checkFirstLast(24, 24)
		check(t, 0, l.Position.Offset)

		// Scroll 2 pages from top. Item 24 is very tall, so it takes up the rest
		// of the window.
		l.ScrollTo(0)
		layoutList(gtx)
		l.ScrollPages(2)
		layoutList(gtx)
		checkFirstLast(20, 24)

		// Starting from the end of the list, scroll back to item 24: make sure
		// we're at the beginning of the item.
		l.ScrollTo(1000)
		layoutList(gtx)
		l.ScrollTo(24)
		layoutList(gtx)
		checkFirstLast(24, 24)
		check(t, 0, l.Position.Offset)

		// PagePrev works
		l.ScrollTo(1000)
		layoutList(gtx)
		checkFirstLast(990, 999)
		l.PagePrev()
		layoutList(gtx)
		checkFirstLast(980, 989)
	})

	t.Run("ScrollToEnd", func(t *testing.T) {
		l.ScrollToEnd = true
		l.Position.BeforeEnd = false

		// Draw from the end and go back.
		layoutList(gtx)
		checkFirstLast(990, 999)

		// Add an item: still draws at end.
		listLen++
		layoutList(gtx)
		checkFirstLast(991, 1000)

		// Remove the item: still at end.
		listLen--
		layoutList(gtx)
		checkFirstLast(990, 999)

		// PagePrev from end of list works.
		l.PagePrev()
		layoutList(gtx)
		checkFirstLast(980, 989)
		check(t, true, l.Position.BeforeEnd)
	})

	t.Run("Small list", func(t *testing.T) {
		l.ScrollToEnd = false

		t.Run("len=0", func(t *testing.T) {
			listLen = 0
			layoutList(gtx)
			checkFirstLast(0, 0)

			l.ScrollTo(1)
			layoutList(gtx)
			checkFirstLast(0, 0)
		})
		t.Run("len=1", func(t *testing.T) {
			listLen = 1
			layoutList(gtx)
			checkFirstLast(0, 0)

			l.ScrollTo(2)
			layoutList(gtx)
			checkFirstLast(0, 0)
		})
		t.Run("len=5", func(t *testing.T) {
			listLen = 5
			l.ScrollTo(0)
			layoutList(gtx)
			checkFirstLast(0, 4)

			l.ScrollTo(2)
			layoutList(gtx)
			checkFirstLast(0, 4)

			t.Run("ScrollToEnd", func(t *testing.T) {
				l.ScrollToEnd = true
				l.Position.BeforeEnd = false

				layoutList(gtx)
				checkFirstLast(0, 4)

				l.ScrollTo(2)
				layoutList(gtx)
				checkFirstLast(0, 4)

				l.ScrollTo(10)
				layoutList(gtx)
				checkFirstLast(0, 4)
			})
		})
	})
}

func check(t *testing.T, exp, got interface{}) {
	t.Helper()
	if exp != got {
		t.Errorf("Expected %v, got %v", exp, got)
	}
}
-- 
2.26.2
I apologize for the delay; I've been away and List isn't subtle enough
to deal with in the small time slots I had available.

On Mon Jul 20, 2020 at 11:08 AM CEST, Larry Clapp wrote: