mirror of
https://gitlab.com/cc-ru/ocelot/ocelot-desktop.git
synced 2025-12-20 02:59:19 +01:00
264 lines
7.5 KiB
Scala
264 lines
7.5 KiB
Scala
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
|
|
}
|
|
}
|