mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
3e0b72304a
Converted with gofmt -w -r 'pointer.Rect(r) -> clip.Rect(r)' . gofmt -w -r 'pointer.Ellipse(r) -> clip.Ellipse(layout.FRect(r))' . combined with 'goimports -w .' to clean up imports. Signed-off-by: Elias Naur <mail@eliasnaur.com>
303 lines
10 KiB
Go
303 lines
10 KiB
Go
// 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 := clip.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.
|
|
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
|
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 := clip.Rect(image.Rectangle{Max: indicatorDims})
|
|
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
|
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)
|
|
// layout.Direction 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
|
|
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.
|
|
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
|
|
}
|