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 }