github.com/utopiagio/gio@v0.0.8/widget/material/list.go (about)

     1  // SPDX-License-Identifier: Unlicense OR MIT
     2  
     3  package material
     4  
     5  import (
     6  	"image"
     7  	"image/color"
     8  	"math"
     9  
    10  	"github.com/utopiagio/gio/io/pointer"
    11  	"github.com/utopiagio/gio/layout"
    12  	"github.com/utopiagio/gio/op"
    13  	"github.com/utopiagio/gio/op/clip"
    14  	"github.com/utopiagio/gio/op/paint"
    15  	"github.com/utopiagio/gio/unit"
    16  	"github.com/utopiagio/gio/widget"
    17  )
    18  
    19  // fromListPosition converts a layout.Position into two floats representing
    20  // the location of the viewport on the underlying content. It needs to know
    21  // the number of elements in the list and the major-axis size of the list
    22  // in order to do this. The returned values will be in the range [0,1], and
    23  // start will be less than or equal to end.
    24  func fromListPosition(lp layout.Position, elements int, majorAxisSize int) (start, end float32) {
    25  	// Approximate the size of the scrollable content.
    26  	lengthEstPx := float32(lp.Length)
    27  	elementLenEstPx := lengthEstPx / float32(elements)
    28  
    29  	// Determine how much of the content is visible.
    30  	listOffsetF := float32(lp.Offset)
    31  	listOffsetL := float32(lp.OffsetLast)
    32  
    33  	// Compute the location of the beginning of the viewport using estimated element size and known
    34  	// pixel offsets.
    35  	viewportStart := clamp1((float32(lp.First)*elementLenEstPx + listOffsetF) / lengthEstPx)
    36  	viewportEnd := clamp1((float32(lp.First+lp.Count)*elementLenEstPx + listOffsetL) / lengthEstPx)
    37  	viewportFraction := viewportEnd - viewportStart
    38  
    39  	// Compute the expected visible proportion of the list content based solely on the ratio
    40  	// of the visible size and the estimated total size.
    41  	visiblePx := float32(majorAxisSize)
    42  	visibleFraction := visiblePx / lengthEstPx
    43  
    44  	// Compute the error between the two methods of determining the viewport and diffuse the
    45  	// error on either end of the viewport based on how close we are to each end.
    46  	err := visibleFraction - viewportFraction
    47  	adjStart := viewportStart
    48  	adjEnd := viewportEnd
    49  	if viewportFraction < 1 {
    50  		startShare := viewportStart / (1 - viewportFraction)
    51  		endShare := (1 - viewportEnd) / (1 - viewportFraction)
    52  		startErr := startShare * err
    53  		endErr := endShare * err
    54  
    55  		adjStart -= startErr
    56  		adjEnd += endErr
    57  	}
    58  	return adjStart, adjEnd
    59  }
    60  
    61  // rangeIsScrollable returns whether the viewport described by start and end
    62  // is smaller than the underlying content (such that it can be scrolled).
    63  // start and end are expected to each be in the range [0,1], and start
    64  // must be less than or equal to end.
    65  func rangeIsScrollable(start, end float32) bool {
    66  	return end-start < 1
    67  }
    68  
    69  // ScrollTrackStyle configures the presentation of a track for a scroll area.
    70  type ScrollTrackStyle struct {
    71  	// MajorPadding and MinorPadding along the major and minor axis of the
    72  	// scrollbar's track. This is used to keep the scrollbar from touching
    73  	// the edges of the content area.
    74  	MajorPadding, MinorPadding unit.Dp
    75  	// Color of the track background.
    76  	Color color.NRGBA
    77  }
    78  
    79  // ScrollIndicatorStyle configures the presentation of a scroll indicator.
    80  type ScrollIndicatorStyle struct {
    81  	// MajorMinLen is the smallest that the scroll indicator is allowed to
    82  	// be along the major axis.
    83  	MajorMinLen unit.Dp
    84  	// MinorWidth is the width of the scroll indicator across the minor axis.
    85  	MinorWidth unit.Dp
    86  	// Color and HoverColor are the normal and hovered colors of the scroll
    87  	// indicator.
    88  	Color, HoverColor color.NRGBA
    89  	// CornerRadius is the corner radius of the rectangular indicator. 0
    90  	// will produce square corners. 0.5*MinorWidth will produce perfectly
    91  	// round corners.
    92  	CornerRadius unit.Dp
    93  }
    94  
    95  // ScrollbarStyle configures the presentation of a scrollbar.
    96  type ScrollbarStyle struct {
    97  	Scrollbar *widget.Scrollbar
    98  	Track     ScrollTrackStyle
    99  	Indicator ScrollIndicatorStyle
   100  }
   101  
   102  // Scrollbar configures the presentation of a scrollbar using the provided
   103  // theme and state.
   104  func Scrollbar(th *Theme, state *widget.Scrollbar) ScrollbarStyle {
   105  	lightFg := th.Palette.Fg
   106  	lightFg.A = 150
   107  	darkFg := lightFg
   108  	darkFg.A = 200
   109  
   110  	return ScrollbarStyle{
   111  		Scrollbar: state,
   112  		Track: ScrollTrackStyle{
   113  			MajorPadding: 2,
   114  			MinorPadding: 2,
   115  		},
   116  		Indicator: ScrollIndicatorStyle{
   117  			MajorMinLen:  th.FingerSize,
   118  			MinorWidth:   6,
   119  			CornerRadius: 3,
   120  			Color:        lightFg,
   121  			HoverColor:   darkFg,
   122  		},
   123  	}
   124  }
   125  
   126  // Width returns the minor axis width of the scrollbar in its current
   127  // configuration (taking padding for the scroll track into account).
   128  func (s ScrollbarStyle) Width() unit.Dp {
   129  	return s.Indicator.MinorWidth + s.Track.MinorPadding + s.Track.MinorPadding
   130  }
   131  
   132  // Layout the scrollbar.
   133  func (s ScrollbarStyle) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
   134  	if !rangeIsScrollable(viewportStart, viewportEnd) {
   135  		return layout.Dimensions{}
   136  	}
   137  
   138  	// Set minimum constraints in an axis-independent way, then convert to
   139  	// the correct representation for the current axis.
   140  	convert := axis.Convert
   141  	maxMajorAxis := convert(gtx.Constraints.Max).X
   142  	gtx.Constraints.Min.X = maxMajorAxis
   143  	gtx.Constraints.Min.Y = gtx.Dp(s.Width())
   144  	gtx.Constraints.Min = convert(gtx.Constraints.Min)
   145  	gtx.Constraints.Max = gtx.Constraints.Min
   146  
   147  	s.Scrollbar.Update(gtx, axis, viewportStart, viewportEnd)
   148  
   149  	// Darken indicator if hovered.
   150  	if s.Scrollbar.IndicatorHovered() {
   151  		s.Indicator.Color = s.Indicator.HoverColor
   152  	}
   153  
   154  	return s.layout(gtx, axis, viewportStart, viewportEnd)
   155  }
   156  
   157  // layout the scroll track and indicator.
   158  func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
   159  	inset := layout.Inset{
   160  		Top:    s.Track.MajorPadding,
   161  		Bottom: s.Track.MajorPadding,
   162  		Left:   s.Track.MinorPadding,
   163  		Right:  s.Track.MinorPadding,
   164  	}
   165  	if axis == layout.Horizontal {
   166  		inset.Top, inset.Bottom, inset.Left, inset.Right = inset.Left, inset.Right, inset.Top, inset.Bottom
   167  	}
   168  
   169  	return layout.Background{}.Layout(gtx,
   170  		func(gtx layout.Context) layout.Dimensions {
   171  			// Lay out the draggable track underneath the scroll indicator.
   172  			area := image.Rectangle{
   173  				Max: gtx.Constraints.Min,
   174  			}
   175  			pointerArea := clip.Rect(area)
   176  			defer pointerArea.Push(gtx.Ops).Pop()
   177  			s.Scrollbar.AddDrag(gtx.Ops)
   178  
   179  			// Stack a normal clickable area on top of the draggable area
   180  			// to capture non-dragging clicks.
   181  			defer pointer.PassOp{}.Push(gtx.Ops).Pop()
   182  			defer pointerArea.Push(gtx.Ops).Pop()
   183  			s.Scrollbar.AddTrack(gtx.Ops)
   184  
   185  			paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op())
   186  			return layout.Dimensions{Size: gtx.Constraints.Min}
   187  		},
   188  		func(gtx layout.Context) layout.Dimensions {
   189  			return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
   190  				// Use axis-independent constraints.
   191  				gtx.Constraints.Min = axis.Convert(gtx.Constraints.Min)
   192  				gtx.Constraints.Max = axis.Convert(gtx.Constraints.Max)
   193  
   194  				// Compute the pixel size and position of the scroll indicator within
   195  				// the track.
   196  				trackLen := gtx.Constraints.Min.X
   197  				viewStart := int(math.Round(float64(viewportStart) * float64(trackLen)))
   198  				viewEnd := int(math.Round(float64(viewportEnd) * float64(trackLen)))
   199  				indicatorLen := max(viewEnd-viewStart, gtx.Dp(s.Indicator.MajorMinLen))
   200  				if viewStart+indicatorLen > trackLen {
   201  					viewStart = trackLen - indicatorLen
   202  				}
   203  				indicatorDims := axis.Convert(image.Point{
   204  					X: indicatorLen,
   205  					Y: gtx.Dp(s.Indicator.MinorWidth),
   206  				})
   207  				radius := gtx.Dp(s.Indicator.CornerRadius)
   208  
   209  				// Lay out the indicator.
   210  				offset := axis.Convert(image.Pt(viewStart, 0))
   211  				defer op.Offset(offset).Push(gtx.Ops).Pop()
   212  				paint.FillShape(gtx.Ops, s.Indicator.Color, clip.RRect{
   213  					Rect: image.Rectangle{
   214  						Max: indicatorDims,
   215  					},
   216  					SW: radius,
   217  					NW: radius,
   218  					NE: radius,
   219  					SE: radius,
   220  				}.Op(gtx.Ops))
   221  
   222  				// Add the indicator pointer hit area.
   223  				area := clip.Rect(image.Rectangle{Max: indicatorDims})
   224  				defer pointer.PassOp{}.Push(gtx.Ops).Pop()
   225  				defer area.Push(gtx.Ops).Pop()
   226  				s.Scrollbar.AddIndicator(gtx.Ops)
   227  
   228  				return layout.Dimensions{Size: axis.Convert(gtx.Constraints.Min)}
   229  			})
   230  		},
   231  	)
   232  }
   233  
   234  // AnchorStrategy defines a means of attaching a scrollbar to content.
   235  type AnchorStrategy uint8
   236  
   237  const (
   238  	// Occupy reserves space for the scrollbar, making the underlying
   239  	// content region smaller on one axis.
   240  	Occupy AnchorStrategy = iota
   241  	// Overlay causes the scrollbar to float atop the content without
   242  	// occupying any space. Content in the underlying area can be occluded
   243  	// by the scrollbar.
   244  	Overlay
   245  )
   246  
   247  // ListStyle configures the presentation of a layout.List with a scrollbar.
   248  type ListStyle struct {
   249  	state *widget.List
   250  	ScrollbarStyle
   251  	AnchorStrategy
   252  }
   253  
   254  // List constructs a ListStyle using the provided theme and state.
   255  func List(th *Theme, state *widget.List) ListStyle {
   256  	return ListStyle{
   257  		state:          state,
   258  		ScrollbarStyle: Scrollbar(th, &state.Scrollbar),
   259  	}
   260  }
   261  
   262  // Layout the list and its scrollbar.
   263  func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement) layout.Dimensions {
   264  	originalConstraints := gtx.Constraints
   265  
   266  	// Determine how much space the scrollbar occupies.
   267  	barWidth := gtx.Dp(l.Width())
   268  
   269  	if l.AnchorStrategy == Occupy {
   270  
   271  		// Reserve space for the scrollbar using the gtx constraints.
   272  		max := l.state.Axis.Convert(gtx.Constraints.Max)
   273  		min := l.state.Axis.Convert(gtx.Constraints.Min)
   274  		max.Y -= barWidth
   275  		if max.Y < 0 {
   276  			max.Y = 0
   277  		}
   278  		min.Y -= barWidth
   279  		if min.Y < 0 {
   280  			min.Y = 0
   281  		}
   282  		gtx.Constraints.Max = l.state.Axis.Convert(max)
   283  		gtx.Constraints.Min = l.state.Axis.Convert(min)
   284  	}
   285  
   286  	listDims := l.state.List.Layout(gtx, length, w)
   287  	gtx.Constraints = originalConstraints
   288  
   289  	// Draw the scrollbar.
   290  	anchoring := layout.E
   291  	if l.state.Axis == layout.Horizontal {
   292  		anchoring = layout.S
   293  	}
   294  	majorAxisSize := l.state.Axis.Convert(listDims.Size).X
   295  	start, end := fromListPosition(l.state.Position, length, majorAxisSize)
   296  	// layout.Direction respects the minimum, so ensure that the
   297  	// scrollbar will be drawn on the correct edge even if the provided
   298  	// layout.Context had a zero minimum constraint.
   299  	gtx.Constraints.Min = listDims.Size
   300  	if l.AnchorStrategy == Occupy {
   301  		min := l.state.Axis.Convert(gtx.Constraints.Min)
   302  		min.Y += barWidth
   303  		gtx.Constraints.Min = l.state.Axis.Convert(min)
   304  	}
   305  	anchoring.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
   306  		return l.ScrollbarStyle.Layout(gtx, l.state.Axis, start, end)
   307  	})
   308  
   309  	if delta := l.state.ScrollDistance(); delta != 0 {
   310  		// Handle any changes to the list position as a result of user interaction
   311  		// with the scrollbar.
   312  		l.state.List.ScrollBy(delta * float32(length))
   313  	}
   314  
   315  	if l.AnchorStrategy == Occupy {
   316  		// Increase the width to account for the space occupied by the scrollbar.
   317  		cross := l.state.Axis.Convert(listDims.Size)
   318  		cross.Y += barWidth
   319  		listDims.Size = l.state.Axis.Convert(cross)
   320  	}
   321  
   322  	return listDims
   323  }