// SPDX-License-Identifier: Unlicense OR MIT package material import ( "image" "image/color" "math" "gioui.org/f32" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" ) // fromListPosition converts a layout.Position into two floats representing // the location of the viewport on the underlying content. It needs to know // the number of elements in the list and the major-axis size of the list // in order to do this. The returned values will be in the range [0,1], and // start will be less than or equal to end. func fromListPosition(lp layout.Position, elements int, majorAxisSize int) (start, end float32) { // Approximate the size of the scrollable content. lengthPx := float32(lp.Length) meanElementHeight := lengthPx / float32(elements) // Determine how much of the content is visible. listOffsetF := float32(lp.Offset) visiblePx := float32(majorAxisSize) visibleFraction := visiblePx / lengthPx // Compute the location of the beginning of the viewport. viewportStart := (float32(lp.First)*meanElementHeight + listOffsetF) / lengthPx return viewportStart, clamp1(viewportStart + visibleFraction) } // rangeIsScrollable returns whether the viewport described by start and end // is smaller than the underlying content (such that it can be scrolled). // start and end are expected to each be in the range [0,1], and start // must be less than or equal to end. func rangeIsScrollable(start, end float32) bool { return end-start < 1 } // ScrollTrackStyle configures the presentation of a track for a scroll area. type ScrollTrackStyle struct { // MajorPadding and MinorPadding along the major and minor axis of the // scrollbar's track. This is used to keep the scrollbar from touching // the edges of the content area. MajorPadding, MinorPadding unit.Value // Color of the track background. Color color.NRGBA } // ScrollIndicatorStyle configures the presentation of a scroll indicator. type ScrollIndicatorStyle struct { // MajorMinLen is the smallest that the scroll indicator is allowed to // be along the major axis. MajorMinLen unit.Value // MinorWidth is the width of the scroll indicator across the minor axis. MinorWidth unit.Value // Color and HoverColor are the normal and hovered colors of the scroll // indicator. Color, HoverColor color.NRGBA // CornerRadius is the corner radius of the rectangular indicator. 0 // will produce square corners. 0.5*MinorWidth will produce perfectly // round corners. CornerRadius unit.Value } // ScrollbarStyle configures the presentation of a scrollbar. type ScrollbarStyle struct { Scrollbar *widget.Scrollbar Track ScrollTrackStyle Indicator ScrollIndicatorStyle } // Scrollbar configures the presentation of a scrollbar using the provided // theme and state. func Scrollbar(th *Theme, state *widget.Scrollbar) ScrollbarStyle { lightFg := th.Palette.Fg lightFg.A = 150 darkFg := lightFg darkFg.A = 200 return ScrollbarStyle{ Scrollbar: state, Track: ScrollTrackStyle{ MajorPadding: unit.Dp(2), MinorPadding: unit.Dp(2), }, Indicator: ScrollIndicatorStyle{ MajorMinLen: unit.Dp(8), MinorWidth: unit.Dp(6), CornerRadius: unit.Dp(3), Color: lightFg, HoverColor: darkFg, }, } } // Width returns the minor axis width of the scrollbar in its current // configuration (taking padding for the scroll track into account). func (s ScrollbarStyle) Width(metric unit.Metric) unit.Value { return unit.Add(metric, s.Indicator.MinorWidth, s.Track.MinorPadding, s.Track.MinorPadding) } // Layout the scrollbar. func (s ScrollbarStyle) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions { if !rangeIsScrollable(viewportStart, viewportEnd) { return layout.Dimensions{} } // Set minimum constraints in an axis-independent way, then convert to // the correct representation for the current axis. convert := axis.Convert maxMajorAxis := convert(gtx.Constraints.Max).X gtx.Constraints.Min.X = maxMajorAxis gtx.Constraints.Min.Y = gtx.Px(s.Width(gtx.Metric)) gtx.Constraints.Min = convert(gtx.Constraints.Min) gtx.Constraints.Max = gtx.Constraints.Min s.Scrollbar.Layout(gtx, axis, viewportStart, viewportEnd) // Darken indicator if hovered. if s.Scrollbar.IndicatorHovered() { s.Indicator.Color = s.Indicator.HoverColor } return s.layout(gtx, axis, viewportStart, viewportEnd) } // layout the scroll track and indicator. func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions { inset := layout.Inset{ Top: s.Track.MajorPadding, Bottom: s.Track.MajorPadding, Left: s.Track.MinorPadding, Right: s.Track.MinorPadding, } if axis == layout.Horizontal { inset.Top, inset.Bottom, inset.Left, inset.Right = inset.Left, inset.Right, inset.Top, inset.Bottom } // Capture the outer constraints because layout.Stack will reset // the minimum to zero. outerConstraints := gtx.Constraints return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { // Lay out the draggable track underneath the scroll indicator. area := image.Rectangle{ Max: gtx.Constraints.Min, } pointerArea := pointer.Rect(area) defer pointerArea.Push(gtx.Ops).Pop() s.Scrollbar.AddDrag(gtx.Ops) // Stack a normal clickable area on top of the draggable area // to capture non-dragging clicks. pointerArea.PassThrough = true defer pointerArea.Push(gtx.Ops).Pop() s.Scrollbar.AddTrack(gtx.Ops) paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op()) return layout.Dimensions{} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { gtx.Constraints = outerConstraints return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { // Use axis-independent constraints. gtx.Constraints.Min = axis.Convert(gtx.Constraints.Min) gtx.Constraints.Max = axis.Convert(gtx.Constraints.Max) // Compute the pixel size and position of the scroll indicator within // the track. trackLen := gtx.Constraints.Min.X viewStart := int(math.Round(float64(viewportStart) * float64(trackLen))) viewEnd := int(math.Round(float64(viewportEnd) * float64(trackLen))) indicatorLen := max(viewEnd-viewStart, gtx.Px(s.Indicator.MajorMinLen)) if viewStart+indicatorLen > trackLen { viewStart = trackLen - indicatorLen } indicatorDims := axis.Convert(image.Point{ X: indicatorLen, Y: gtx.Px(s.Indicator.MinorWidth), }) indicatorDimsF := layout.FPt(indicatorDims) radius := float32(gtx.Px(s.Indicator.CornerRadius)) // Lay out the indicator. offset := axis.Convert(image.Pt(viewStart, 0)) defer op.Offset(layout.FPt(offset)).Push(gtx.Ops).Pop() paint.FillShape(gtx.Ops, s.Indicator.Color, clip.RRect{ Rect: f32.Rectangle{ Max: indicatorDimsF, }, SW: radius, NW: radius, NE: radius, SE: radius, }.Op(gtx.Ops)) // Add the indicator pointer hit area. area := pointer.Rect(image.Rectangle{Max: indicatorDims}) area.PassThrough = true defer area.Push(gtx.Ops).Pop() s.Scrollbar.AddIndicator(gtx.Ops) return layout.Dimensions{Size: axis.Convert(gtx.Constraints.Min)} }) }), ) } // AnchorStrategy defines a means of attaching a scrollbar to content. type AnchorStrategy uint8 const ( // Occupy reserves space for the scrollbar, making the underlying // content region smaller on one axis. Occupy AnchorStrategy = iota // Overlay causes the scrollbar to float atop the content without // occupying any space. Content in the underlying area can be occluded // by the scrollbar. Overlay ) // ListStyle configures the presentation of a layout.List with a scrollbar. type ListStyle struct { state *widget.List ScrollbarStyle AnchorStrategy } // List constructs a ListStyle using the provided theme and state. func List(th *Theme, state *widget.List) ListStyle { return ListStyle{ state: state, ScrollbarStyle: Scrollbar(th, &state.Scrollbar), } } // Layout the list and its scrollbar. func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement) layout.Dimensions { originalConstraints := gtx.Constraints // Determine how much space the scrollbar occupies. barWidth := gtx.Px(l.Width(gtx.Metric)) if l.AnchorStrategy == Occupy { // Reserve space for the scrollbar using the gtx constraints. max := l.state.Axis.Convert(gtx.Constraints.Max) min := l.state.Axis.Convert(gtx.Constraints.Min) max.Y -= barWidth min.Y -= barWidth gtx.Constraints.Max = l.state.Axis.Convert(max) gtx.Constraints.Min = l.state.Axis.Convert(min) } listDims := l.state.List.Layout(gtx, length, w) gtx.Constraints = originalConstraints // Draw the scrollbar. anchoring := layout.E if l.state.Axis == layout.Horizontal { anchoring = layout.S } majorAxisSize := l.state.Axis.Convert(listDims.Size).X start, end := fromListPosition(l.state.Position, length, majorAxisSize) anchoring.Layout(gtx, func(gtx layout.Context) layout.Dimensions { // layout.Dimension respects the minimum, so ensure that the // scrollbar will be drawn on the correct edge even if the provided // layout.Context had a zero minimum constraint. gtx.Constraints.Min = gtx.Constraints.Max return l.ScrollbarStyle.Layout(gtx, l.state.Axis, start, end) }) if delta := l.state.ScrollDistance(); delta != 0 { // Handle any changes to the list position as a result of user interaction // with the scrollbar. l.state.List.Position.Offset += int(math.Round(float64(float32(l.state.Position.Length) * delta))) // Ensure that the list pays attention to the Offset field when the scrollbar drag // is started while the bar is at the end of the list. Without this, the scrollbar // cannot be dragged away from the end. l.state.List.Position.BeforeEnd = true } if l.AnchorStrategy == Occupy { // Increase the width to account for the space occupied by the scrollbar. cross := l.state.Axis.Convert(listDims.Size) cross.Y += barWidth listDims.Size = l.state.Axis.Convert(cross) } return listDims }