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) } }