Files
prevara/list.go

716 lines
20 KiB
Go

package gel
import (
"image"
"time"
"gioui.org/gesture"
"gioui.org/io/pointer"
l "gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
)
type scrollChild struct {
size image.Point
call op.CallOp
}
// List displays a subsection of a potentially infinitely large underlying list. List accepts user input to scroll the
// subsection.
type List struct {
axis l.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.
scrollToEnd bool
// Alignment is the cross axis alignment of list elements.
alignment l.Alignment
scroll gesture.Scroll
scrollDelta int
// position is updated during Layout. To save the list scroll position, just save Position after Layout finishes. To
// scroll the list programmatically, update Position (e.g. restore it from a saved value) before calling Layout.
// nextUp, nextDown Position
position Position
Len int
// maxSize is the total size of visible children.
maxSize int
children []scrollChild
dir iterationDir
// all below are additional fields to implement the scrollbar
*Window
// we store the constraints here instead of in the `cs` field
ctx l.Context
sideScroll gesture.Scroll
disableScroll bool
drag gesture.Drag
recentPageClick time.Time
color string
active string
background string
currentColor string
scrollWidth int
setScrollWidth int
length int
prevLength int
w ListElement
pageUp, pageDown *Clickable
dims DimensionList
cross int
view, total, before int
top, middle, bottom int
lastWidth int
recalculateTime time.Time
recalculate bool
notFirst bool
leftSide bool
}
// List returns a new scrollable List widget
func (w *Window) List() (li *List) {
li = &List{
Window: w,
pageUp: w.WidgetPool.GetClickable(),
pageDown: w.WidgetPool.GetClickable(),
color: "DocText",
background: "Transparent",
active: "Primary",
scrollWidth: int(float32(w.TextSize) * 0.75),
setScrollWidth: int(float32(w.TextSize) * 0.75),
recalculateTime: time.Now().Add(-time.Second),
recalculate: true,
}
li.currentColor = li.color
return
}
// ListElement is a function that computes the dimensions of a list element.
type ListElement func(gtx l.Context, index int) l.Dimensions
type iterationDir uint8
// Position is a List scroll offset represented as an offset from the top edge of a child element.
type Position struct {
// BeforeEnd tracks whether the List position is before the very end. We use "before end" instead of "at end" so
// that the zero value of a Position struct is useful.
//
// When laying out a list, if ScrollToEnd is true and BeforeEnd is false, then First and Offset are ignored, and the
// list is drawn with the last item at the bottom. If ScrollToEnd is false then BeforeEnd is ignored.
BeforeEnd bool
// First is the index of the first visible child.
First int
// Offset is the distance in pixels from the top edge to the child at index First.
Offset int
// OffsetLast is the signed distance in pixels from the bottom edge to the
// bottom edge of the child at index First+Count.
OffsetLast int
// Count is the number of visible children.
Count int
}
const (
iterateNone iterationDir = iota
iterateForward
iterateBackward
)
// init prepares the list for iterating through its children with next.
func (li *List) init(gtx l.Context, length int) {
if li.more() {
panic("unfinished child")
}
li.ctx = gtx
li.maxSize = 0
li.children = li.children[:0]
li.Len = length
li.update()
if li.canScrollToEnd() || li.position.First > length {
li.position.Offset = 0
li.position.First = length
}
}
// Layout the List.
func (li *List) Layout(gtx l.Context, len int, w ListElement) l.Dimensions {
li.init(gtx, len)
crossMin, crossMax := axisCrossConstraint(li.axis, gtx.Constraints)
gtx.Constraints = axisConstraints(li.axis, 0, Inf, crossMin, crossMax)
macro := op.Record(gtx.Ops)
for li.next(); li.more(); li.next() {
child := op.Record(gtx.Ops)
dims := w(gtx, li.index())
call := child.Stop()
li.end(dims, call)
}
return li.layout(macro)
}
// canScrollToEnd returns true if there is room to scroll further towards the end
func (li *List) canScrollToEnd() bool {
return li.scrollToEnd && !li.position.BeforeEnd
}
// Dragging reports whether the List is being dragged.
func (li *List) Dragging() bool {
return li.scroll.State() == gesture.StateDragging ||
li.sideScroll.State() == gesture.StateDragging
}
// update the scrolling
func (li *List) update() {
// Create scroll ranges based on axis
var scrollX, scrollY pointer.ScrollRange
if li.axis == l.Horizontal {
scrollX = pointer.ScrollRange{Min: -Inf, Max: Inf}
} else {
scrollY = pointer.ScrollRange{Min: -Inf, Max: Inf}
}
d := li.scroll.Update(li.ctx.Metric, li.ctx.Source, li.ctx.Now, gesture.Axis(li.axis), scrollX, scrollY)
d += li.sideScroll.Update(li.ctx.Metric, li.ctx.Source, li.ctx.Now, gesture.Axis(li.axis), scrollX, scrollY)
li.scrollDelta = d
li.position.Offset += d
}
// next advances to the next child.
func (li *List) next() {
li.dir = li.nextDir()
// The user scroll offset is applied after scrolling to list end.
if li.canScrollToEnd() && !li.more() && li.scrollDelta < 0 {
li.position.BeforeEnd = true
li.position.Offset += li.scrollDelta
li.dir = li.nextDir()
}
}
// index is current child's position in the underlying list.
func (li *List) index() int {
switch li.dir {
case iterateBackward:
return li.position.First - 1
case iterateForward:
return li.position.First + len(li.children)
default:
panic("Index called before Next")
}
}
// more reports whether more children are needed.
func (li *List) more() bool {
return li.dir != iterateNone
}
func (li *List) nextDir() iterationDir {
_, vSize := axisMainConstraint(li.axis, li.ctx.Constraints)
last := li.position.First + len(li.children)
// Clamp offset.
if li.maxSize-li.position.Offset < vSize && last == li.Len {
li.position.Offset = li.maxSize - vSize
}
if li.position.Offset < 0 && li.position.First == 0 {
li.position.Offset = 0
}
switch {
case len(li.children) == li.Len:
return iterateNone
case li.maxSize-li.position.Offset < vSize:
return iterateForward
case li.position.Offset < 0:
return iterateBackward
}
return iterateNone
}
// End the current child by specifying its dimensions.
func (li *List) end(dims l.Dimensions, call op.CallOp) {
child := scrollChild{dims.Size, call}
mainSize := axisConvert(li.axis, child.size).X
li.maxSize += mainSize
switch li.dir {
case iterateForward:
li.children = append(li.children, child)
case iterateBackward:
li.children = append(li.children, scrollChild{})
copy(li.children[1:], li.children)
li.children[0] = child
li.position.First--
li.position.Offset += mainSize
default:
panic("call Next before End")
}
li.dir = iterateNone
}
// layout the List and return its dimensions.
func (li *List) layout(macro op.MacroOp) l.Dimensions {
if li.more() {
panic("unfinished child")
}
mainMin, mainMax := axisMainConstraint(li.axis, li.ctx.Constraints)
children := li.children
// Skip invisible children
for len(children) > 0 {
sz := children[0].size
mainSize := axisConvert(li.axis, sz).X
if li.position.Offset < mainSize {
// First child is partially visible.
break
}
li.position.First++
li.position.Offset -= mainSize
children = children[1:]
}
size := -li.position.Offset
var maxCross int
for i, child := range children {
sz := axisConvert(li.axis, child.size)
if c := sz.Y; c > maxCross {
maxCross = c
}
size += sz.X
if size >= mainMax {
children = children[:i+1]
break
}
}
li.position.Count = len(children)
li.position.OffsetLast = mainMax - size
ops := li.ctx.Ops
pos := -li.position.Offset
// ScrollToEnd lists are end aligned.
if space := li.position.OffsetLast; li.scrollToEnd && space > 0 {
pos += space
}
for _, child := range children {
sz := axisConvert(li.axis, child.size)
var cross int
switch li.alignment {
case l.End:
cross = maxCross - sz.Y
case l.Middle:
cross = (maxCross - sz.Y) / 2
}
childSize := sz.X
max := childSize + pos
if max > mainMax {
max = mainMax
}
min := pos
if min < 0 {
min = 0
}
r := image.Rectangle{
Min: axisConvert(li.axis, image.Pt(min, -Inf)),
Max: axisConvert(li.axis, image.Pt(max, Inf)),
}
stack := clip.Rect(r).Push(ops)
pt := axisConvert(li.axis, image.Pt(pos, cross))
offStack := op.Offset(pt).Push(ops)
child.call.Add(ops)
offStack.Pop()
stack.Pop()
pos += childSize
}
atStart := li.position.First == 0 && li.position.Offset <= 0
atEnd := li.position.First+len(children) == li.Len && mainMax >= pos
if atStart && li.scrollDelta < 0 || atEnd && li.scrollDelta > 0 {
li.scroll.Stop()
li.sideScroll.Stop()
}
li.position.BeforeEnd = !atEnd
if pos < mainMin {
pos = mainMin
}
if pos > mainMax {
pos = mainMax
}
dims := axisConvert(li.axis, image.Pt(pos, maxCross))
call := macro.Stop()
bounds := image.Rectangle{Max: dims}
defer clip.Rect(bounds).Push(ops).Pop()
li.scroll.Add(ops)
li.sideScroll.Add(ops)
call.Add(ops)
return l.Dimensions{Size: dims}
}
// Everything below is extensions on the original from git.mleku.dev/mleku/prevara/gio/layout
// Position returns the current position of the scroller
func (li *List) Position() Position {
return li.position
}
// SetPosition sets the position of the scroller
func (li *List) SetPosition(position Position) {
li.position = position
}
// JumpToStart moves the position to the start
func (li *List) JumpToStart() {
li.position = Position{}
}
// JumpToEnd moves the position to the end
func (li *List) JumpToEnd() {
li.position = Position{
BeforeEnd: false,
First: len(li.dims),
Offset: axisMain(li.axis, li.dims[len(li.dims)-1].Size),
}
}
// Vertical sets the axis to vertical (default implicit is horizontal)
func (li *List) Vertical() (out *List) {
li.axis = l.Vertical
return li
}
// Start sets the alignment to start
func (li *List) Start() *List {
li.alignment = l.Start
return li
}
// End sets the alignment to end
func (li *List) End() *List {
li.alignment = l.End
return li
}
// Middle sets the alignment to middle
func (li *List) Middle() *List {
li.alignment = l.Middle
return li
}
// Baseline sets the alignment to baseline
func (li *List) Baseline() *List {
li.alignment = l.Baseline
return li
}
// ScrollToEnd sets the List to add new items to the end and push older ones up/left and initial render has scroll
// to the end (or bottom) of the List
func (li *List) ScrollToEnd() (out *List) {
li.scrollToEnd = true
return li
}
// LeftSide sets the scroller to be on the opposite side from usual
func (li *List) LeftSide(b bool) (out *List) {
li.leftSide = b
return li
}
// Length sets the new length for the list
func (li *List) Length(length int) *List {
li.prevLength = li.length
li.length = length
return li
}
// DisableScroll turns off the scrollbar
func (li *List) DisableScroll(disable bool) *List {
li.disableScroll = disable
if disable {
li.scrollWidth = 0
} else {
li.scrollWidth = li.setScrollWidth
}
return li
}
// ListElement defines the function that returns list elements
func (li *List) ListElement(w ListElement) *List {
li.w = w
return li
}
// ScrollWidth sets the width of the scrollbar
func (li *List) ScrollWidth(width int) *List {
li.scrollWidth = width
li.setScrollWidth = width
return li
}
// Color sets the primary color of the scrollbar grabber
func (li *List) Color(color string) *List {
li.color = color
li.currentColor = li.color
return li
}
// Background sets the background color of the scrollbar
func (li *List) Background(color string) *List {
li.background = color
return li
}
// Active sets the color of the scrollbar grabber when it is being operated
func (li *List) Active(color string) *List {
li.active = color
return li
}
func (li *List) Slice(gtx l.Context, widgets ...l.Widget) l.Widget {
return li.Length(len(widgets)).Vertical().ListElement(func(gtx l.Context, index int) l.Dimensions {
return widgets[index](gtx)
},
).Fn
}
// Fn runs the layout in the configured context. The ListElement function returns the widget at the given index
func (li *List) Fn(gtx l.Context) l.Dimensions {
if li.length == 0 {
// if there is no children just return a big empty box
return EmptyFromSize(gtx.Constraints.Max)(gtx)
}
if li.disableScroll {
return li.embedWidget(0)(gtx)
}
if li.length != li.prevLength {
li.recalculate = true
li.recalculateTime = time.Now().Add(time.Millisecond * 100)
} else if li.lastWidth != gtx.Constraints.Max.X && li.notFirst {
li.recalculateTime = time.Now().Add(time.Millisecond * 100)
li.recalculate = true
}
if !li.notFirst {
li.recalculateTime = time.Now().Add(-time.Millisecond * 100)
li.notFirst = true
}
li.lastWidth = gtx.Constraints.Max.X
if li.recalculateTime.Sub(time.Now()) < 0 && li.recalculate {
li.scrollBarSize = li.scrollWidth // + li.scrollBarPad
gtx1 := CopyContextDimensionsWithMaxAxis(gtx, li.axis)
// generate the dimensions for all the list elements
li.dims = GetDimensionList(gtx1, li.length, li.w)
li.recalculateTime = time.Time{}
li.recalculate = false
}
_, li.view = axisMainConstraint(li.axis, gtx.Constraints)
_, li.cross = axisCrossConstraint(li.axis, gtx.Constraints)
li.total, li.before = li.dims.GetSizes(li.position, li.axis)
if li.total == 0 {
// if there is no children just return a big empty box
return EmptyFromSize(gtx.Constraints.Max)(gtx)
}
if li.total < li.view {
// if the contents fit the view, don't show the scrollbar
li.top, li.middle, li.bottom = 0, 0, 0
li.scrollWidth = 0
} else {
li.scrollWidth = li.setScrollWidth
li.top = li.before * (li.view - li.scrollWidth) / li.total
li.middle = li.view * (li.view - li.scrollWidth) / li.total
li.bottom = (li.total - li.before - li.view) * (li.view - li.scrollWidth) / li.total
if li.view < li.scrollWidth {
li.middle = li.view
li.top, li.bottom = 0, 0
} else {
li.middle += li.scrollWidth
}
}
// now lay it all out and draw the list and scrollbar
var container l.Widget
textSizeInt := int(li.TextSize)
textSizeF32 := float32(li.TextSize)
if li.axis == l.Horizontal {
containerFlex := li.Theme.VFlex()
if !li.leftSide {
containerFlex.Rigid(li.embedWidget(li.scrollWidth /* + textSizeInt/4)*/))
containerFlex.Rigid(EmptySpace(textSizeInt/4, textSizeInt/4))
}
containerFlex.Rigid(
li.VFlex().
Rigid(
func(gtx l.Context) l.Dimensions {
defer clip.Rect(image.Rectangle{Max: image.Point{X: gtx.Constraints.Max.X,
Y: gtx.Constraints.Max.Y,
},
}).Push(gtx.Ops).Pop()
li.drag.Add(gtx.Ops)
return li.Theme.Flex().
Rigid(li.pageUpDown(li.dims, li.view, li.total,
// li.scrollBarPad+
li.scrollWidth, li.top, false,
),
).
Rigid(li.grabber(li.dims, li.scrollWidth, li.middle,
li.view, gtx.Constraints.Max.X,
),
).
Rigid(li.pageUpDown(li.dims, li.view, li.total,
// li.scrollBarPad+
li.scrollWidth, li.bottom, true,
),
).
Fn(gtx)
},
).
Fn,
)
if li.leftSide {
containerFlex.Rigid(EmptySpace(textSizeInt/4, textSizeInt/4))
containerFlex.Rigid(li.embedWidget(li.scrollWidth)) // li.scrollWidth)) // + li.scrollBarPad))
}
container = containerFlex.Fn
} else {
containerFlex := li.Theme.Flex()
if !li.leftSide {
containerFlex.Rigid(li.embedWidget(li.scrollWidth + textSizeInt/2)) // + li.scrollBarPad))
containerFlex.Rigid(EmptySpace(textSizeInt/2, textSizeInt/2))
}
containerFlex.Rigid(
li.Fill(li.background, l.Center, textSizeF32/4, 0, li.Flex().
Rigid(
func(gtx l.Context) l.Dimensions {
defer clip.Rect(image.Rectangle{Max: image.Point{X: gtx.Constraints.Max.X,
Y: gtx.Constraints.Max.Y,
},
}).Push(gtx.Ops).Pop()
li.drag.Add(gtx.Ops)
return li.Theme.Flex().Vertical().
Rigid(li.pageUpDown(li.dims, li.view, li.total,
li.scrollWidth, li.top, false,
),
).
Rigid(li.grabber(li.dims,
li.scrollWidth, li.middle,
li.view, gtx.Constraints.Max.X,
),
).
Rigid(li.pageUpDown(li.dims, li.view, li.total,
li.scrollWidth, li.bottom, true,
),
).
Fn(gtx)
},
).
Fn,
).Fn,
)
if li.leftSide {
containerFlex.Rigid(EmptySpace(textSizeInt/2, textSizeInt/2))
containerFlex.Rigid(li.embedWidget(li.scrollWidth + textSizeInt/2))
}
container = li.Fill(li.background, l.Center, textSizeF32/4, 0, containerFlex.Fn).Fn
}
return container(gtx)
}
// EmbedWidget places the scrollable content
func (li *List) embedWidget(scrollWidth int) func(l.Context) l.Dimensions {
return func(gtx l.Context) l.Dimensions {
if li.axis == l.Horizontal {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y - scrollWidth
gtx.Constraints.Max.Y = gtx.Constraints.Min.Y
} else {
gtx.Constraints.Min.X = gtx.Constraints.Max.X - scrollWidth
gtx.Constraints.Max.X = gtx.Constraints.Min.X
}
return li.Layout(gtx, li.length, li.w)
}
}
// pageUpDown creates the clickable areas either side of the grabber that trigger a page up/page down action
func (li *List) pageUpDown(dims DimensionList, view, total, x, y int, down bool) func(l.Context) l.Dimensions {
button := li.pageUp
if down {
button = li.pageDown
}
return func(gtx l.Context) l.Dimensions {
bounds := image.Rectangle{Max: gtx.Constraints.Max}
defer clip.Rect(bounds).Push(gtx.Ops).Pop()
li.sideScroll.Add(gtx.Ops)
return li.ButtonLayout(button.SetClick(func() {
current := dims.PositionToCoordinate(li.position, li.axis)
var newPos int
if down {
if current+view > total {
newPos = total - view
} else {
newPos = current + view
}
} else {
newPos = current - view
if newPos < 0 {
newPos = 0
}
}
li.position = dims.CoordinateToPosition(newPos, li.axis)
},
).
SetPress(func() { li.recentPageClick = time.Now() }),
).Embed(
li.Flex().
Rigid(EmptySpace(x/4, y)).
Rigid(
li.Fill("scrim", l.Center, float32(li.TextSize)/4, 0, EmptySpace(x/2, y)).Fn,
).
Rigid(EmptySpace(x/4, y)).
Fn,
).Background("Transparent").CornerRadius(0).Fn(gtx)
}
}
// grabber renders the grabber
func (li *List) grabber(dims DimensionList, x, y, viewAxis, viewCross int) func(l.Context) l.Dimensions {
return func(gtx l.Context) l.Dimensions {
ax := gesture.Vertical
if li.axis == l.Horizontal {
ax = gesture.Horizontal
}
var de *pointer.Event
for {
ev, ok := li.drag.Update(gtx.Metric, gtx.Source, ax)
if !ok {
break
}
if ev.Kind == pointer.Press ||
ev.Kind == pointer.Release ||
ev.Kind == pointer.Drag {
de = &ev
}
}
if de != nil {
if de.Kind == pointer.Press { // || de.Kind == pointer.Drag {
}
if de.Kind == pointer.Release {
}
if de.Kind == pointer.Drag {
// D.Ln("drag position", de.Position)
if time.Now().Sub(li.recentPageClick) > time.Second/2 {
total := dims.GetTotal(li.axis)
var d int
if li.axis == l.Horizontal {
deltaX := int(de.Position.X)
if deltaX > 8 || deltaX < -8 {
d = deltaX * (total / viewAxis)
li.SetPosition(dims.CoordinateToPosition(d, li.axis))
}
} else {
deltaY := int(de.Position.Y)
if deltaY > 8 || deltaY < -8 {
d = deltaY * (total / viewAxis)
li.SetPosition(dims.CoordinateToPosition(d, li.axis))
}
}
}
li.Window.Invalidate()
}
}
bounds := image.Rectangle{Max: image.Point{X: x, Y: y}}
defer clip.Rect(bounds).Push(gtx.Ops).Pop()
li.sideScroll.Add(gtx.Ops)
return li.Flex().
Rigid(
li.Fill(li.currentColor, l.Center, 0, 0, EmptySpace(x, y)).
Fn,
).
Fn(gtx)
}
}