2025-08-12 02:58:20 +03:00

280 lines
8.6 KiB
Scala

package ocelot.desktop.ui.widget
import ocelot.desktop.ColorScheme
import ocelot.desktop.audio.{ClickSoundSource, SoundSource}
import ocelot.desktop.color.Color
import ocelot.desktop.geometry.Size2D
import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.event.handlers.{HoverHandler, MouseHandler}
import ocelot.desktop.ui.event.{ClickEvent, HoverEvent, MouseEvent}
import ocelot.desktop.ui.widget.IconButton.{AnimationSpeed, Mode}
import ocelot.desktop.ui.widget.tooltip.LabelTooltip
import ocelot.desktop.ui.widget.traits.HoverAnimation
import ocelot.desktop.util.animation.{ColorAnimation, ValueAnimation}
import ocelot.desktop.util.{DrawUtils, Spritesheet}
class IconButton(
releasedIcon: String,
pressedIcon: String,
releasedColor: Color = Color.White,
pressedColor: Color = Color.White,
sizeMultiplier: Float = 1,
mode: Mode = Mode.Regular,
drawBackground: Boolean = false,
padding: Float = 0,
tooltip: Option[String] = None,
darkenActiveColorFactor: Float = 0f,
val model: IconButton.Model = IconButton.DefaultModel(false),
) extends Widget with MouseHandler with HoverHandler with HoverAnimation {
override protected def receiveClickEvents: Boolean = true
private var prevModelValue = model.pressed
eventHandlers += {
case HoverEvent(HoverEvent.State.Enter) => onHoverEnter()
case HoverEvent(HoverEvent.State.Leave) => onHoverLeave()
case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) =>
mode match {
case Mode.Regular => handlePress()
case _ => // the other modes are triggered on click.
}
clickSoundSource.press.play()
case MouseEvent(MouseEvent.State.Released, MouseEvent.Button.Left) => mode match {
case Mode.Regular if model.pressed =>
handleRelease()
clickSoundSource.release.play()
case _ => // the other modes are triggered on click.
}
case ClickEvent(MouseEvent.Button.Left, _) =>
mode match {
case Mode.Regular => // regular buttons are handled above.
case Mode.Switch if model.pressed =>
handleRelease()
clickSoundSource.release.play()
case Mode.Radio if model.pressed => // the radio button is already pressed; ignore.
case Mode.Radio | Mode.Switch =>
handlePress()
clickSoundSource.release.play()
}
onClicked()
}
/**
* Called when the state is changed to pressed.
*
* UX note: override this for [[Mode.Switch]] and [[Mode.Radio]] buttons.
*
* @see [[onClicked]] for [[Mode.Regular]] buttons.
*/
def onPressed(): Unit = {}
/**
* Called when the state is changed to released.
*
* UX note: override this for [[Mode.Switch]] and [[Mode.Radio]] buttons.
*
* @see [[onClicked]] for [[Mode.Regular]] buttons.
*/
def onReleased(): Unit = {}
/**
* Called when the button is clicked (the mouse button has been released while it's within the button's bounds),
* regardless of the state.
*
* UX note: override this for [[Mode.Regular]] buttons.
*
* @see [[onPressed]] and [[onReleased]] for [[Mode.Switch]] and [[Mode.Radio]] buttons.
*/
def onClicked(): Unit = {}
private def onHoverEnter(): Unit = {
updateColorAnimationTargets()
for (tooltip <- labelTooltip) {
root.get.tooltipPool.addTooltip(tooltip)
}
}
private def onHoverLeave(): Unit = {
updateColorAnimationTargets()
for (tooltip <- labelTooltip) {
root.get.tooltipPool.closeTooltip(tooltip)
}
}
protected def pressedActiveColor: Color = pressedColor.toRGBANorm.mapRgb(_ * (1 - darkenActiveColorFactor))
protected def releasedActiveColor: Color = releasedColor.toRGBANorm.mapRgb(_ * (1 - darkenActiveColorFactor))
private def targetPressedColor: Color = if (isHovered) pressedActiveColor else pressedColor
private def targetReleasedColor: Color = if (isHovered) releasedActiveColor else releasedColor
private def targetIconMix: Float = if (model.pressed) 1f else 0f
private val pressedColorAnimation = new ColorAnimation(targetPressedColor, AnimationSpeed)
private val releasedColorAnimation = new ColorAnimation(targetReleasedColor, AnimationSpeed)
private val iconMixAnimation = new ValueAnimation(targetIconMix, AnimationSpeed)
private def handlePress(): Unit = {
model.pressed = true
onPressed()
}
private def handleRelease(): Unit = {
model.pressed = false
onReleased()
}
size = minimumSize
private def releasedIconSize: Size2D = Spritesheet.spriteSize(releasedIcon) * sizeMultiplier
private def pressedIconSize: Size2D = Spritesheet.spriteSize(pressedIcon) * sizeMultiplier
override def minimumSize: Size2D = releasedIconSize.max(pressedIconSize) + (padding * 2.0f)
override def maximumSize: Size2D = minimumSize
private val labelTooltip = tooltip.map(label => new LabelTooltip(label))
def borderColor: Color = ColorScheme("ButtonBorder")
override def draw(g: Graphics): Unit = {
if (drawBackground) {
g.rect(bounds, hoverAnimation.color)
DrawUtils.ring(g, position.x, position.y, width, height, 2, borderColor)
}
if (iconMixAnimation.value < 1f) {
g.sprite(
releasedIcon,
position + (size - releasedIconSize) / 2f,
releasedIconSize,
releasedColorAnimation.color.toRGBANorm.mapA(_ * (1f - iconMixAnimation.value)),
)
}
if (iconMixAnimation.value > 0f) {
g.sprite(
pressedIcon,
position + (size - pressedIconSize) / 2f,
pressedIconSize,
pressedColorAnimation.color.toRGBANorm.mapA(_ * iconMixAnimation.value),
)
}
}
private def updateColorAnimationTargets(): Unit = {
pressedColorAnimation.goto(targetPressedColor)
releasedColorAnimation.goto(targetReleasedColor)
}
protected def updateAnimationTargets(): Unit = {
updateColorAnimationTargets()
iconMixAnimation.goto(targetIconMix)
}
override def update(): Unit = {
super.update()
val nextModelValue = model.pressed
if (prevModelValue != nextModelValue) {
prevModelValue = nextModelValue
updateAnimationTargets()
}
pressedColorAnimation.update()
releasedColorAnimation.update()
iconMixAnimation.update()
}
protected def clickSoundSource: ClickSoundSource = SoundSource.InterfaceClick
}
object IconButton {
val AnimationSpeed = 10f
sealed trait Mode
object Mode {
/**
* Your regular, run-of-the-mill button you love.
*
* When the LMB is depressed, [[IconButton.onPressed]] is called.
* After it's released, [[IconButton.onReleased]] is called.
*/
case object Regular extends Mode
/**
* A toggleable button, or a switch.
*
* Clicking the LMB while the button is pressed releases it, and [[IconButton.onReleased]] is called.
* Clicking the LMB while the button is released depresses it, and [[IconButton.onPressed]] is called.
*/
case object Switch extends Mode
/**
* A radio button is like a switch except clicking on it while it's depressed doesn't do anything.
*
* [[IconButton.onReleased]] is never called (unless you do it yourself).
*
* Managing a group of radio buttons is your responsibility.
*/
case object Radio extends Mode
}
/**
* This is the visible button state.
*
* When [[IconButton.update]] notices a state change, it runs its animations.
*
* Note that callbacks are run after running the setter regardless of whether it does anything.
*/
trait Model {
/**
* Whether to display the pressed button state.
*/
def pressed: Boolean
/**
* This setter is used by [[IconButton]] to respond to user interaction.
*
* You may want to override this method to do nothing
* if you're proxying another model and updating it in callbacks.
*/
def pressed_=(newValue: Boolean): Unit
}
/**
* This is the default model that simply reads from a variable and writes to it.
*/
case class DefaultModel(override var pressed: Boolean) extends Model
/**
* This is a model that ignores user input (presumably you handle it in callbacks).
*/
class ReadOnlyModel(f: () => Boolean) extends Model {
override def pressed: Boolean = f()
override def pressed_=(newValue: Boolean): Unit = {}
}
object ReadOnlyModel {
/**
* A utility method to create a new instance of [[ReadOnlyModel]].
*
* The value returned by [[ReadOnlyModel.pressed]] is obtained
* by evaluating the provided expression '''every time'''.
*/
def apply(value: => Boolean): ReadOnlyModel = new ReadOnlyModel(value _)
}
}