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.{MouseEvent, ScrollEvent} 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 { 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 MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) => val pos = UiHandler.mousePosition dragState = if (vThumbBounds.contains(pos)) { DragState.Vertical(yOffset, pos) } else if (hThumbBounds.contains(pos)) { DragState.Horizontal(xOffset, pos) } else { DragState.None } if (dragState.isVertical) { scrollToEnd = false } 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 = { if (xOffset.abs > 0 || yOffset.abs > 0) { inner.position = position - Vector2D(xOffset, yOffset) } 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_.clamp(-MaxScrollVelocity, MaxScrollVelocity) x_ += v_ * dt // exponential decay. v_ *= math.exp(-DecayFactor * UiHandler.dt).toFloat (x_, v_) } override def update(): Unit = { super.update() recalculateBounds() // 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).clamp() hAnim = (hAnim + UiHandler.dt / 0.2f * hAnimDir).clamp() dragState match { case DragState.Vertical(startOffset, startPos) => yOffset = startOffset + (mousePos - startPos).y / vThumbCoeff case DragState.Horizontal(startOffset, startPos) => xOffset = startOffset + (mousePos - startPos).x / hThumbCoeff case DragState.None => return } clampOffsets(resetVelocities = false) } 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.clamp(max = maxXOffset) val newYOffset = yOffset.clamp(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 } }