objects/scrollbar.go

426 lines
11 KiB
Go
Raw Normal View History

2023-09-14 12:48:08 -06:00
package objects
import "image"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/tomo/event"
var _ tomo.Object = new(Scrollbar)
2023-09-14 19:49:45 -06:00
// Scrollbar is a special type of slider that can control the scroll of any
// overflowing ContainerObject.
//
// Sub-components:
// - ScrollbarHandle is the grabbable handle of the scrollbar.
//
// Tags:
// - [vertical] The scrollbar is oriented vertically.
// - [horizontall] The scrollbar is oriented horizontally.
//
// ScrollbarHandle tags:
// - [vertical] The handle is oriented vertically.
// - [horizontall] The handle is oriented horizontally.
2023-09-14 12:48:08 -06:00
type Scrollbar struct {
box tomo.ContainerBox
2024-08-16 16:35:19 -06:00
handle *sliderHandle
2023-09-14 12:48:08 -06:00
layout scrollbarLayout
dragging bool
dragOffset image.Point
linkCookie event.Cookie
on struct {
valueChange event.FuncBroadcaster
}
}
func newScrollbar (orient string) *Scrollbar {
this := &Scrollbar {
box: tomo.NewContainerBox(),
2024-08-16 16:35:19 -06:00
handle: &sliderHandle {
2023-09-14 12:48:08 -06:00
Box: tomo.NewBox(),
},
layout: scrollbarLayout {
vertical: orient == "vertical",
},
}
this.box.Add(this.handle)
2023-09-14 15:03:19 -06:00
this.box.SetFocusable(true)
this.box.SetInputMask(true)
this.box.OnKeyUp(this.handleKeyUp)
this.box.OnKeyDown(this.handleKeyDown)
this.box.OnButtonDown(this.handleButtonDown)
this.box.OnButtonUp(this.handleButtonUp)
this.box.OnMouseMove(this.handleMouseMove)
this.box.OnScroll(this.handleScroll)
2024-06-03 19:13:18 -06:00
this.handle.SetRole(tomo.R("objects", "ScrollbarHandle"))
2024-07-21 09:48:28 -06:00
this.handle.SetTag(orient, true)
this.box.SetRole(tomo.R("objects", "Scrollbar"))
this.box.SetTag(orient, true)
2023-09-14 12:48:08 -06:00
return this
}
2023-09-14 19:49:45 -06:00
// NewVerticalScrollbar creates a new vertical scrollbar.
2023-09-14 12:48:08 -06:00
func NewVerticalScrollbar () *Scrollbar {
return newScrollbar("vertical")
}
2023-09-14 19:49:45 -06:00
// NewHorizontalScrollbar creates a new horizontal scrollbar.
2023-09-14 12:48:08 -06:00
func NewHorizontalScrollbar () *Scrollbar {
return newScrollbar("horizontal")
}
// GetBox returns the underlying box.
func (this *Scrollbar) GetBox () tomo.Box {
return this.box
}
// Link assigns this scrollbar to a ContentObject. Closing the returned cookie
// will unlink it.
func (this *Scrollbar) Link (box tomo.ContentObject) event.Cookie {
2023-09-14 12:48:08 -06:00
this.layout.linked = box
this.linkCookie = this.newLinkCookie (
box.OnContentBoundsChange(this.handleLinkedContentBoundsChange))
this.box.SetAttr(tomo.ALayout(this.layout))
2023-09-14 12:48:08 -06:00
return this.linkCookie
}
func (this *Scrollbar) handleLinkedContentBoundsChange () {
2023-09-14 19:04:59 -06:00
if this.layout.linked == nil { return }
2023-09-14 19:40:06 -06:00
previousValue := this.layout.value
2023-09-14 19:04:59 -06:00
trackLength := this.layout.contentLength() - this.layout.viewportLength()
if trackLength == 0 {
this.layout.value = 0
} else {
this.layout.value = this.layout.contentPos() / trackLength
}
this.box.SetAttr(tomo.ALayout(this.layout))
2023-09-14 19:40:06 -06:00
if this.layout.value != previousValue {
this.on.valueChange.Broadcast()
}
2023-09-14 12:48:08 -06:00
}
// Value returns the value of the scrollbar between 0 and 1 where 0 is scrolled
// all the way to the left/top, and 1 is scrolled all the way to the
// right/bottom.
func (this *Scrollbar) Value () float64 {
if this.layout.linked == nil { return 0 }
return this.layout.value
}
2023-09-14 12:48:08 -06:00
// SetValue sets the value of the scrollbar between 0 and 1, where 0 is scrolled
// all the way to the left/top, and 1 is scrolled all the way to the
// right/bottom.
func (this *Scrollbar) SetValue (value float64) {
2023-09-14 19:40:06 -06:00
if this.layout.linked == nil { return }
if value > 1 { value = 1 }
if value < 0 { value = 0 }
trackLength := this.layout.contentLength() - this.layout.viewportLength()
position := trackLength * value
point := this.layout.linked.ContentBounds().Min
if this.layout.vertical {
point.Y = -int(position)
2023-09-14 19:40:06 -06:00
} else {
point.X = -int(position)
2023-09-14 19:40:06 -06:00
}
this.layout.linked.ScrollTo(point)
2023-09-14 12:48:08 -06:00
}
// OnValueChange specifies a function to be called when the user changes the
// position of the scrollbar.
2023-09-14 18:34:56 -06:00
func (this *Scrollbar) OnValueChange (callback func ()) event.Cookie {
2023-09-14 15:03:19 -06:00
return this.on.valueChange.Connect(callback)
2023-09-14 12:48:08 -06:00
}
2024-07-21 09:48:28 -06:00
// PageSize returns the scroll distance of a page.
func (this *Scrollbar) PageSize () int {
if this.layout.linked == nil { return 0 }
viewport := this.layout.linked.GetBox().InnerBounds()
if this.layout.vertical {
return viewport.Dy()
} else {
return viewport.Dx()
}
}
// StepSize returns the scroll distance of a step.
func (this *Scrollbar) StepSize () int {
// FIXME: this should not be hardcoded, need to get base font metrics
// from tomo somehow. should be (emspace, lineheight)
return 16
}
2024-07-25 10:58:38 -06:00
func (this *Scrollbar) handleKeyUp (key input.Key, numpad bool) bool {
switch key {
case input.KeyUp, input.KeyLeft: return true
case input.KeyDown, input.KeyRight: return true
2024-07-26 22:54:40 -06:00
case input.KeyPageUp: return true
case input.KeyPageDown: return true
case input.KeyHome: return true
case input.KeyEnd: return true
2024-07-25 10:58:38 -06:00
}
return false
2024-07-21 09:48:28 -06:00
}
2024-07-25 10:58:38 -06:00
func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool {
modifiers := this.box.Window().Modifiers()
2024-07-21 09:48:28 -06:00
2023-09-14 12:48:08 -06:00
switch key {
case input.KeyUp, input.KeyLeft:
2024-09-12 00:34:28 -06:00
if modifiers.Alt() {
2023-09-14 12:48:08 -06:00
this.SetValue(0)
} else {
2024-07-26 22:54:40 -06:00
this.scrollBy(this.StepSize())
2023-09-14 12:48:08 -06:00
}
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
case input.KeyDown, input.KeyRight:
2024-09-12 00:34:28 -06:00
if modifiers.Alt() {
2023-09-14 12:48:08 -06:00
this.SetValue(1)
} else {
2024-07-26 22:54:40 -06:00
this.scrollBy(-this.StepSize())
2023-09-14 12:48:08 -06:00
}
2024-07-26 22:54:40 -06:00
case input.KeyPageUp:
this.scrollBy(this.PageSize())
return true
case input.KeyPageDown:
this.scrollBy(-this.PageSize())
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
case input.KeyHome:
this.SetValue(0)
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
case input.KeyEnd:
this.SetValue(1)
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
}
2024-07-25 10:58:38 -06:00
return false
2023-09-14 12:48:08 -06:00
}
2024-07-25 10:58:38 -06:00
func (this *Scrollbar) handleButtonDown (button input.Button) bool {
pointer := this.box.Window().MousePosition()
2023-09-14 12:48:08 -06:00
handle := this.handle.Bounds()
2023-09-14 19:40:06 -06:00
within := pointer.In(handle)
var above bool; if this.layout.vertical {
above = pointer.Y < handle.Min.Y + handle.Dy() / 2
2023-09-14 12:48:08 -06:00
} else {
2023-09-14 19:40:06 -06:00
above = pointer.X < handle.Min.X + handle.Dx() / 2
2023-09-14 12:48:08 -06:00
}
switch button {
case input.ButtonLeft:
if within {
this.dragging = true
this.dragOffset =
pointer.Sub(this.handle.Bounds().Min).
Add(this.box.InnerBounds().Min)
2023-09-14 12:48:08 -06:00
this.drag()
} else {
this.dragOffset = this.fallbackDragOffset()
this.dragging = true
this.drag()
}
case input.ButtonMiddle:
if above {
2024-07-21 09:48:28 -06:00
this.scrollBy(this.PageSize())
} else {
2024-07-21 09:48:28 -06:00
this.scrollBy(-this.PageSize())
2023-09-14 12:48:08 -06:00
}
case input.ButtonRight:
if above {
2024-07-21 09:48:28 -06:00
this.scrollBy(this.StepSize())
} else {
2024-07-21 09:48:28 -06:00
this.scrollBy(-this.StepSize())
2023-09-14 12:48:08 -06:00
}
}
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
}
2024-07-25 10:58:38 -06:00
func (this *Scrollbar) handleButtonUp (button input.Button) bool {
if button != input.ButtonLeft || !this.dragging { return true }
2023-09-14 12:48:08 -06:00
this.dragging = false
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
}
2024-07-25 10:58:38 -06:00
func (this *Scrollbar) handleMouseMove () bool {
if !this.dragging { return false }
2024-07-26 16:10:48 -06:00
this.drag()
2024-07-25 10:58:38 -06:00
return true
2023-09-14 12:48:08 -06:00
}
2024-07-25 10:58:38 -06:00
func (this *Scrollbar) handleScroll (x, y float64) bool {
if this.layout.linked == nil { return false }
delta := (x + y)
if this.layout.vertical {
x = 0
y = delta
} else {
x = delta
y = 0
}
2023-09-14 19:04:59 -06:00
this.layout.linked.ScrollTo (
this.layout.linked.ContentBounds().Min.
Sub(image.Pt(int(x), int(y))))
2024-07-25 10:58:38 -06:00
return true
2023-09-14 19:04:59 -06:00
}
2023-09-14 12:48:08 -06:00
func (this *Scrollbar) drag () {
pointer := this.box.Window().MousePosition().Sub(this.dragOffset)
gutter := this.box.InnerBounds()
2023-09-14 12:48:08 -06:00
handle := this.handle.Bounds()
if this.layout.vertical {
this.SetValue (
float64(pointer.Y) /
float64(gutter.Dy() - handle.Dy()))
} else {
this.SetValue (
float64(pointer.X) /
float64(gutter.Dx() - handle.Dx()))
}
}
func (this *Scrollbar) fallbackDragOffset () image.Point {
if this.layout.vertical {
return this.box.InnerBounds().Min.
2023-09-14 12:48:08 -06:00
Add(image.Pt(0, this.handle.Bounds().Dy() / 2))
} else {
return this.box.InnerBounds().Min.
2023-09-14 12:48:08 -06:00
Add(image.Pt(this.handle.Bounds().Dx() / 2, 0))
}
}
2023-09-15 14:11:59 -06:00
func (this *Scrollbar) scrollBy (distance int) {
if this.layout.linked == nil { return }
var vector image.Point; if this.layout.vertical {
vector.Y = distance
} else {
vector.X = distance
}
this.layout.linked.ScrollTo (
this.layout.linked.ContentBounds().Min.
Add(vector))
}
2023-09-14 12:48:08 -06:00
type scrollbarCookie struct {
owner *Scrollbar
subCookies []event.Cookie
}
func (this *Scrollbar) newLinkCookie (subCookies ...event.Cookie) *scrollbarCookie {
return &scrollbarCookie {
owner: this,
subCookies: subCookies,
}
}
2024-09-12 00:34:28 -06:00
func (this *scrollbarCookie) Close () error {
2023-09-14 12:48:08 -06:00
for _, cookie := range this.subCookies {
cookie.Close()
}
this.owner.layout.linked = nil
this.owner.box.SetAttr(tomo.ALayout(this.owner.layout))
2024-09-12 00:34:28 -06:00
return nil
2023-09-14 12:48:08 -06:00
}
type scrollbarLayout struct {
vertical bool
2023-09-14 18:34:56 -06:00
value float64
linked tomo.ContentObject
2023-09-14 12:48:08 -06:00
}
2024-07-21 09:48:28 -06:00
func (scrollbarLayout) MinimumSize (hints tomo.LayoutHints, boxes tomo.BoxQuerier) image.Point {
if boxes.Len() != 1 { return image.Pt(0, 0) }
return boxes.MinimumSize(0)
2023-09-14 12:48:08 -06:00
}
2024-07-21 09:48:28 -06:00
func (this scrollbarLayout) Arrange (hints tomo.LayoutHints, boxes tomo.BoxArranger) {
if boxes.Len() != 1 { return }
handle := image.Rectangle { Max: boxes.MinimumSize(0) }
2023-09-14 12:48:08 -06:00
gutter := hints.Bounds
2023-09-14 19:04:59 -06:00
var gutterLength float64;
var handleMin float64;
if this.vertical {
gutterLength = float64(gutter.Dy())
handleMin = float64(handle.Dy())
} else {
gutterLength = float64(gutter.Dx())
handleMin = float64(handle.Dx())
}
2023-09-14 18:34:56 -06:00
2023-09-14 19:04:59 -06:00
// calculate handle length
handleLength := gutterLength * this.viewportContentRatio()
if handleLength < handleMin { handleLength = handleMin }
2023-09-14 23:47:58 -06:00
if handleLength >= gutterLength {
2024-05-27 13:22:18 -06:00
// just hide the handle if it isn't needed. we are the layout
// and we shouldn't be adding and removing boxes, so this is
// really the only good way to hide things.
// TODO perhaps have a "Hidden" rectangle in the Tomo API?
2024-07-21 09:48:28 -06:00
boxes.SetBounds(0, image.Rect(-32, -32, -16, -16))
2023-09-14 23:47:58 -06:00
return
}
2023-09-14 19:04:59 -06:00
if this.vertical {
handle.Max.Y = int(handleLength)
} else {
handle.Max.X = int(handleLength)
}
// calculate handle position
handlePosition := (gutterLength - handleLength) * this.value
var handleOffset image.Point
if this.vertical {
handleOffset = image.Pt(0, int(handlePosition))
} else {
handleOffset = image.Pt(int(handlePosition), 0)
}
handle = handle.Sub(handleOffset).Add(gutter.Min)
2023-09-14 19:04:59 -06:00
// place handle
2024-07-21 09:48:28 -06:00
boxes.SetBounds(0, handle)
2023-09-14 19:04:59 -06:00
2023-09-14 12:48:08 -06:00
}
2023-09-14 18:34:56 -06:00
2024-07-21 09:48:28 -06:00
func (this scrollbarLayout) RecommendedHeight (hints tomo.LayoutHints, boxes tomo.BoxQuerier, width int) int {
return this.MinimumSize(hints, boxes).X
2024-06-11 15:17:11 -06:00
}
2024-07-21 09:48:28 -06:00
func (this scrollbarLayout) RecommendedWidth (hints tomo.LayoutHints, boxes tomo.BoxQuerier, height int) int {
return this.MinimumSize(hints, boxes).Y
2024-06-11 15:17:11 -06:00
}
2023-09-14 18:34:56 -06:00
func (this scrollbarLayout) viewportContentRatio () float64 {
if this.linked == nil { return 0 }
2023-09-14 19:04:59 -06:00
return this.viewportLength() / this.contentLength()
2023-09-14 18:34:56 -06:00
}
func (this scrollbarLayout) viewportLength () float64 {
if this.vertical {
return float64(this.linked.GetBox().InnerBounds().Dy())
2023-09-14 18:34:56 -06:00
} else {
return float64(this.linked.GetBox().InnerBounds().Dx())
2023-09-14 18:34:56 -06:00
}
}
func (this scrollbarLayout) contentLength () float64 {
if this.vertical {
return float64(this.linked.ContentBounds().Dy())
} else {
return float64(this.linked.ContentBounds().Dx())
}
}
func (this scrollbarLayout) contentPos () float64 {
if this.vertical {
return float64(this.linked.ContentBounds().Min.Y)
} else {
return float64(this.linked.ContentBounds().Min.X)
}
}