2025-08-13 00:51:04 +03:00

253 lines
7.1 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.{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
}
}