package ocelot.desktop.ui.widget import ocelot.desktop.ColorScheme import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D} import ocelot.desktop.graphics.Graphics import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.handlers.HoverHandler import ocelot.desktop.ui.event.sources.KeyEvents import ocelot.desktop.ui.event.{Capturing, MouseEvent, ScrollEvent} import ocelot.desktop.ui.layout.Layout import ocelot.desktop.ui.widget.ScrollView.{DecayFactor, DragState, MaxScrollVelocity, MinThumbSize, ScrollVelocity} import ocelot.desktop.util.Logging class ScrollView(val inner: Widget) extends Widget with Logging with HoverHandler { override protected val layout: Layout = new Layout(this) { override def relayout(): Unit = { inner.rawSetPosition(position - Vector2D(xOffset, yOffset)) inner.relayout() } } inner.size = Size2D.Zero children +:= inner var scrollToEnd = false private var xOffset = 0f private var yOffset = 0f private var xVelocity = 0f private var yVelocity = 0f private var dragState: DragState = DragState.None private var vAnim = 0f private var hAnim = 0f def offset: Vector2D = Vector2D(xOffset, yOffset) override def receiveScrollEvents = true override def receiveMouseEvents = true eventHandlers += { case ScrollEvent(offset) if bounds.contains(UiHandler.mousePosition) => if (KeyEvents.isShiftDown) { xVelocity -= offset * ScrollVelocity } else { yVelocity -= offset * ScrollVelocity scrollToEnd = false } case Capturing(event @ MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left)) => val pos = UiHandler.mousePosition if (vThumbBounds.contains(pos) || hThumbBounds.contains(pos)) { event.consume() } dragState = if (vThumbBounds.contains(pos)) { scrollToEnd = false DragState.Vertical(yOffset, pos) } else if (hThumbBounds.contains(pos)) { DragState.Horizontal(xOffset, pos) } else { DragState.None } case MouseEvent(MouseEvent.State.Released, MouseEvent.Button.Left) => dragState = DragState.None } override def minimumSize: Size2D = Size2D.Zero override def size_=(value: Size2D): Unit = { super.size_=(value) clampOffsets(resetVelocities = true) } override def shouldClip = true override def draw(g: Graphics): Unit = { inner.draw(g) if (vThumbVisible) { drawVThumb(g) } if (hThumbVisible) { drawHThumb(g) } } private def nextOffsetVelocity(x: Float, v: Float, dt: Float): (Float, Float) = { var x_ = x var v_ = v v_ = v_.clamped(-MaxScrollVelocity, MaxScrollVelocity) x_ += v_ * dt // exponential decay. v_ *= math.exp(-DecayFactor * UiHandler.dt).toFloat (x_, v_) } override def update(): Unit = { super.update() recalculateBounds() val prevXOffset = xOffset val prevYOffset = yOffset // in plain text, the logic is: // - if we're not holding down a thumb, apply the accumulated scroll velocity // - otherwise reset the velocity to zero and set the offset depending on where the cursor is if (dragState.isHorizontal) { xVelocity = 0 } else { val next = nextOffsetVelocity(xOffset, xVelocity, UiHandler.dt) xOffset = next._1 xVelocity = next._2 } if (scrollToEnd) { yOffset = maxYOffset yVelocity = 0 if (dragState.isVertical) { dragState = DragState.None } } else if (dragState.isVertical) { yVelocity = 0 } else { val next = nextOffsetVelocity(yOffset, yVelocity, UiHandler.dt) yOffset = next._1 yVelocity = next._2 } clampOffsets(resetVelocities = true) val mousePos = UiHandler.mousePosition val vAnimDir = if (isHovered && vThumbHoverArea.contains(mousePos) || dragState.isVertical) 1 else -1 val hAnimDir = if (isHovered && hThumbHoverArea.contains(mousePos) || dragState.isHorizontal) 1 else -1 vAnim = (vAnim + UiHandler.dt / 0.2f * vAnimDir).clamped() hAnim = (hAnim + UiHandler.dt / 0.2f * hAnimDir).clamped() dragState match { case DragState.Vertical(startOffset, startPos) => yOffset = startOffset + (mousePos - startPos).y / vThumbCoeff clampOffsets(resetVelocities = false) case DragState.Horizontal(startOffset, startPos) => xOffset = startOffset + (mousePos - startPos).x / hThumbCoeff clampOffsets(resetVelocities = false) case DragState.None => } if (prevXOffset != xOffset || prevYOffset != yOffset) { relayout() } } private def drawVThumb(g: Graphics): Unit = { val b = vThumbBounds g.rect(position.x + size.width - 11, position.y, 10, size.height, ColorScheme("Scrollbar").mapA(_ * vAnim)) g.rect(b.x + 3, b.y, b.w - 6, b.h, ColorScheme("ScrollbarThumb").withAlpha(vAnim * 0.5f + 0.4f)) } private def drawHThumb(g: Graphics): Unit = { val b = hThumbBounds g.rect(position.x, position.y + size.height - 11, size.width - 12, 10, ColorScheme("Scrollbar").mapA(_ * hAnim)) g.rect(b.x, b.y + 3, b.w, b.h - 6, ColorScheme("ScrollbarThumb").withAlpha(hAnim * 0.5f + 0.4f)) } private def maxXOffset: Float = inner.size.width - size.width private def maxYOffset: Float = inner.size.height - size.height private def clampOffsets(resetVelocities: Boolean): Unit = { val newXOffset = xOffset.clamped(max = maxXOffset) val newYOffset = yOffset.clamped(max = maxYOffset) if (resetVelocities) { if (xOffset != newXOffset) { xVelocity = 0 } if (yOffset != newYOffset) { yVelocity = 0 } } xOffset = newXOffset yOffset = newYOffset } private def vThumbHoverArea: Rect2D = { Rect2D(position.x + size.width - 12, position.y, 12, size.height) } private def computeThumbSize(preferredSize: Float, scrollBarSize: Float): Float = { preferredSize.max(MinThumbSize).min(scrollBarSize) } private def vThumbBounds: Rect2D = { val x = position.x + size.width - 12 val y = position.y + yOffset * vThumbCoeff Rect2D(x, y + 2, 12, computeThumbSize(vThumbCoeff * size.height, vScrollBarSize)) } private def hThumbHoverArea: Rect2D = { Rect2D(position.x, position.y + size.height - 12, size.width, 12) } private def hThumbBounds: Rect2D = { val x = position.x + xOffset * hThumbCoeff val y = position.y + size.height - 12 Rect2D(x + 2, y, computeThumbSize(hThumbCoeff * size.width, hScrollBarSize), 12) } protected def vThumbVisible: Boolean = inner.size.height > size.height protected def hThumbVisible: Boolean = inner.size.width > size.width private def vScrollBarSize: Float = size.height - 4 private def hScrollBarSize: Float = size.width - 14 private def vThumbCoeff: Float = vScrollBarSize / inner.size.height private def hThumbCoeff: Float = hScrollBarSize / inner.size.width } object ScrollView { private val ScrollVelocity = 385f private val MaxScrollVelocity = ScrollVelocity * 4 private val DecayFactor = 5f private val MinThumbSize = 15f /** * Which of the thumbs are being dragged. */ sealed trait DragState { def isVertical: Boolean = false def isHorizontal: Boolean = false } object DragState { case class Vertical(startOffset: Float, startPos: Vector2D) extends DragState { override def isVertical: Boolean = true } case class Horizontal(startOffset: Float, startPos: Vector2D) extends DragState { override def isHorizontal: Boolean = true } case object None extends DragState } }