package objects import "image" import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo/input" import "git.tebibyte.media/tomo/tomo/event" // Scrollbar is a special type of slider that can control the scroll of any // overflowing ContainerBox. type Scrollbar struct { tomo.ContainerBox handle *SliderHandle layout scrollbarLayout dragging bool dragOffset image.Point linkCookie event.Cookie on struct { valueChange event.FuncBroadcaster } } func newScrollbar (orient string) *Scrollbar { this := &Scrollbar { ContainerBox: tomo.NewContainerBox(), handle: &SliderHandle { Box: tomo.NewBox(), }, layout: scrollbarLayout { vertical: orient == "vertical", }, } this.Add(this.handle) this.SetFocusable(true) this.SetInputMask(true) this.OnKeyUp(this.handleKeyUp) this.OnKeyDown(this.handleKeyDown) this.OnButtonDown(this.handleButtonDown) this.OnButtonUp(this.handleButtonUp) this.OnMouseMove(this.handleMouseMove) this.OnScroll(this.handleScroll) this.handle.SetRole(tomo.R("objects", "SliderHandle")) this.handle.SetTag(orient, true) this.SetRole(tomo.R("objects", "Slider")) this.SetTag(orient, true) return this } // NewVerticalScrollbar creates a new vertical scrollbar. func NewVerticalScrollbar () *Scrollbar { return newScrollbar("vertical") } // NewHorizontalScrollbar creates a new horizontal scrollbar. func NewHorizontalScrollbar () *Scrollbar { return newScrollbar("horizontal") } // Link assigns this scrollbar to a ContentObject. Closing the returned cookie // will unlink it. func (this *Scrollbar) Link (box tomo.ContentObject) event.Cookie { this.layout.linked = box this.linkCookie = this.newLinkCookie ( box.OnContentBoundsChange(this.handleLinkedContentBoundsChange)) this.SetAttr(tomo.ALayout(this.layout)) return this.linkCookie } func (this *Scrollbar) handleLinkedContentBoundsChange () { if this.layout.linked == nil { return } previousValue := this.layout.value trackLength := this.layout.contentLength() - this.layout.viewportLength() if trackLength == 0 { this.layout.value = 0 } else { this.layout.value = this.layout.contentPos() / trackLength } this.SetAttr(tomo.ALayout(this.layout)) if this.layout.value != previousValue { this.on.valueChange.Broadcast() } } // 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 } // 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) { 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) } else { point.X = -int(position) } this.layout.linked.ScrollTo(point) } // OnValueChange specifies a function to be called when the user changes the // position of the scrollbar. func (this *Scrollbar) OnValueChange (callback func ()) event.Cookie { return this.on.valueChange.Connect(callback) } // 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 } 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 case input.KeyPageUp: return true case input.KeyPageDown: return true case input.KeyHome: return true case input.KeyEnd: return true } return false } func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool { modifiers := this.Window().Modifiers() switch key { case input.KeyUp, input.KeyLeft: if modifiers.Alt { this.SetValue(0) } else { this.scrollBy(this.StepSize()) } return true case input.KeyDown, input.KeyRight: if modifiers.Alt { this.SetValue(1) } else { this.scrollBy(-this.StepSize()) } case input.KeyPageUp: this.scrollBy(this.PageSize()) return true case input.KeyPageDown: this.scrollBy(-this.PageSize()) return true case input.KeyHome: this.SetValue(0) return true case input.KeyEnd: this.SetValue(1) return true } return false } func (this *Scrollbar) handleButtonDown (button input.Button) bool { pointer := this.Window().MousePosition() handle := this.handle.Bounds() within := pointer.In(handle) var above bool; if this.layout.vertical { above = pointer.Y < handle.Min.Y + handle.Dy() / 2 } else { above = pointer.X < handle.Min.X + handle.Dx() / 2 } switch button { case input.ButtonLeft: if within { this.dragging = true this.dragOffset = pointer.Sub(this.handle.Bounds().Min). Add(this.InnerBounds().Min) this.drag() } else { this.dragOffset = this.fallbackDragOffset() this.dragging = true this.drag() } case input.ButtonMiddle: if above { this.scrollBy(this.PageSize()) } else { this.scrollBy(-this.PageSize()) } case input.ButtonRight: if above { this.scrollBy(this.StepSize()) } else { this.scrollBy(-this.StepSize()) } } return true } func (this *Scrollbar) handleButtonUp (button input.Button) bool { if button != input.ButtonLeft || !this.dragging { return true } this.dragging = false return true } func (this *Scrollbar) handleMouseMove () bool { if !this.dragging { return false } this.drag() return true } 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 } this.layout.linked.ScrollTo ( this.layout.linked.ContentBounds().Min. Sub(image.Pt(int(x), int(y)))) return true } func (this *Scrollbar) drag () { pointer := this.Window().MousePosition().Sub(this.dragOffset) gutter := this.InnerBounds() 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.InnerBounds().Min. Add(image.Pt(0, this.handle.Bounds().Dy() / 2)) } else { return this.InnerBounds().Min. Add(image.Pt(this.handle.Bounds().Dx() / 2, 0)) } } 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)) } type scrollbarCookie struct { owner *Scrollbar subCookies []event.Cookie } func (this *Scrollbar) newLinkCookie (subCookies ...event.Cookie) *scrollbarCookie { return &scrollbarCookie { owner: this, subCookies: subCookies, } } func (this *scrollbarCookie) Close () { for _, cookie := range this.subCookies { cookie.Close() } this.owner.layout.linked = nil this.owner.SetAttr(tomo.ALayout(this.owner.layout)) } type scrollbarLayout struct { vertical bool value float64 linked tomo.ContentObject } func (scrollbarLayout) MinimumSize (hints tomo.LayoutHints, boxes tomo.BoxQuerier) image.Point { if boxes.Len() != 1 { return image.Pt(0, 0) } return boxes.MinimumSize(0) } func (this scrollbarLayout) Arrange (hints tomo.LayoutHints, boxes tomo.BoxArranger) { if boxes.Len() != 1 { return } handle := image.Rectangle { Max: boxes.MinimumSize(0) } gutter := hints.Bounds 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()) } // calculate handle length handleLength := gutterLength * this.viewportContentRatio() if handleLength < handleMin { handleLength = handleMin } if handleLength >= gutterLength { // 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? boxes.SetBounds(0, image.Rect(-32, -32, -16, -16)) return } 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) // place handle boxes.SetBounds(0, handle) } func (this scrollbarLayout) RecommendedHeight (hints tomo.LayoutHints, boxes tomo.BoxQuerier, width int) int { return this.MinimumSize(hints, boxes).X } func (this scrollbarLayout) RecommendedWidth (hints tomo.LayoutHints, boxes tomo.BoxQuerier, height int) int { return this.MinimumSize(hints, boxes).Y } func (this scrollbarLayout) viewportContentRatio () float64 { if this.linked == nil { return 0 } return this.viewportLength() / this.contentLength() } func (this scrollbarLayout) viewportLength () float64 { if this.vertical { return float64(this.linked.GetBox().InnerBounds().Dy()) } else { return float64(this.linked.GetBox().InnerBounds().Dx()) } } 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) } }