diff --git a/widget/list.go b/widget/list.go new file mode 100644 index 00000000..1d0c2a66 --- /dev/null +++ b/widget/list.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package widget + +import ( + "image" + + "gioui.org/gesture" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" +) + +// Scrollbar holds the persistent state for an area that can +// display a scrollbar. In particular, it tracks the position of a +// viewport along a one-dimensional region of content. The viewport's +// position can be adjusted by drag operations along the display area, +// or by clicks within the display area. +// +// Scrollbar additionally detects when a scroll indicator region is +// hovered. +type Scrollbar struct { + track, indicator gesture.Click + drag gesture.Drag + delta float32 +} + +// Layout updates the internal state of the scrollbar based on events +// since the previous call to Layout. The provided axis will be used to +// normalize input event coordinates and constraints into an axis- +// independent format. viewportStart is the position of the beginning +// of the scrollable viewport relative to the underlying content expressed +// as a value in the range [0,1]. viewportEnd is the position of the end +// of the viewport relative to the underlying content, also expressed +// as a value in the range [0,1]. For example, if viewportStart is 0.25 +// and viewportEnd is .5, the viewport described by the scrollbar is +// currently showing the second quarter of the underlying content. +func (s *Scrollbar) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions { + // Calculate the length of the major axis of the scrollbar. This is + // the length of the track within which pointer events occur, and is + // used to scale those interactions. + trackHeight := float32(axis.Convert(gtx.Constraints.Max).X) + s.delta = 0 + + // Jump to a click in the track. + for _, event := range s.track.Events(gtx) { + if event.Type != gesture.TypeClick || + event.Modifiers != key.Modifiers(0) || + event.NumClicks > 1 { + continue + } + pos := axis.Convert(image.Point{ + X: int(event.Position.X), + Y: int(event.Position.Y), + }) + normalizedPos := float32(pos.X) / trackHeight + s.delta += normalizedPos - viewportStart + } + + // Offset to account for any drags. + for _, event := range s.drag.Events(gtx.Metric, gtx, gesture.Axis(axis)) { + if event.Type != pointer.Drag { + continue + } + dragOffset := axis.FConvert(event.Position).X + normalizedDragOffset := dragOffset / trackHeight + s.delta += normalizedDragOffset - viewportStart + } + + // Process events from the indicator so that hover is + // detected properly. + _ = s.indicator.Events(gtx) + + return layout.Dimensions{} +} + +// AddTrack configures the track click listener for the scrollbar to use +// the most recently added pointer.AreaOp. +func (s *Scrollbar) AddTrack(ops *op.Ops) { + s.track.Add(ops) +} + +// AddIndicator configures the indicator click listener for the scrollbar to use +// the most recently added pointer.AreaOp. +func (s *Scrollbar) AddIndicator(ops *op.Ops) { + s.indicator.Add(ops) +} + +// AddDrag configures the drag listener for the scrollbar to use +// the most recently added pointer.AreaOp. +func (s *Scrollbar) AddDrag(ops *op.Ops) { + s.drag.Add(ops) +} + +// IndicatorHovered returns whether the scroll indicator is currently being +// hovered by the pointer. +func (s *Scrollbar) IndicatorHovered() bool { + return s.indicator.Hovered() +} + +// ScrollDistance returns the normalized distance that the scrollbar +// moved during the last call to Layout as a value in the range [-1,1]. +func (s *Scrollbar) ScrollDistance() float32 { + return s.delta +} + +// List holds the persistent state for a layout.List that has a +// scrollbar attached. +type List struct { + Scrollbar + layout.List +} diff --git a/widget/material/list.go b/widget/material/list.go new file mode 100644 index 00000000..71bfaabc --- /dev/null +++ b/widget/material/list.go @@ -0,0 +1,285 @@ +// 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, 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) + pointerArea.Add(gtx.Ops) + s.Scrollbar.AddDrag(gtx.Ops) + + // Stack a normal clickable area on top of the draggable area + // to capture non-dragging clicks. + saved := op.Save(gtx.Ops) + pointer.PassOp{Pass: true}.Add(gtx.Ops) + pointerArea.Add(gtx.Ops) + s.Scrollbar.AddTrack(gtx.Ops) + saved.Load() + + 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 := float32(gtx.Constraints.Min.X) + viewStart := viewportStart * trackLen + viewEnd := viewportEnd * trackLen + indicatorLen := unit.Max(gtx.Metric, unit.Px(viewEnd-viewStart), s.Indicator.MajorMinLen) + indicatorDims := axis.Convert(image.Point{ + X: gtx.Px(indicatorLen), + Y: gtx.Px(s.Indicator.MinorWidth), + }) + indicatorDimsF := layout.FPt(indicatorDims) + radius := float32(gtx.Px(s.Indicator.CornerRadius)) + + // Lay out the indicator. + defer op.Save(gtx.Ops).Load() + offset := axis.Convert(image.Pt(int(viewStart), 0)) + op.Offset(layout.FPt(offset)).Add(gtx.Ops) + 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. + pointer.PassOp{Pass: true}.Add(gtx.Ops) + pointer.Rect(image.Rectangle{Max: indicatorDims}).Add(gtx.Ops) + 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 + + if l.AnchorStrategy == Occupy { + // Determine how much space the scrollbar occupies. + barWidth := gtx.Px(l.Width(gtx.Metric)) + + // 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 { + 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. + deltaPx := int(math.Round(float64(float32(l.state.Position.Length) * delta))) + l.state.List.Position.Offset += deltaPx + } + + return listDims +}