481 lines
15 KiB
Scala

package ocelot.desktop.ui.widget
import ocelot.desktop.ColorScheme
import ocelot.desktop.color.Color
import ocelot.desktop.geometry.Size2D
import ocelot.desktop.graphics.{Font, Graphics, IconSource}
import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.event.handlers.MouseHandler
import ocelot.desktop.ui.event.sources.KeyEvents
import ocelot.desktop.ui.event.{DoubleClickEvent, DragEvent, KeyEvent, MouseEvent}
import ocelot.desktop.ui.widget.TextInput.{Cursor, Selection, Text}
import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry}
import ocelot.desktop.ui.widget.traits.HoverAnimation
import ocelot.desktop.util.{DrawUtils, Register, Watcher}
import ocelot.desktop.util.animation.ColorAnimation
import org.lwjgl.input.Keyboard
import java.lang.Character.isWhitespace
class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation {
private val CursorBlinkTime = 2f
private val PlaceholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled")
private val BackgroundSelectedColor: Color = ColorScheme("TextInputBackgroundSelected")
private val ForegroundSelectedColor: Color = ColorScheme("TextInputForegroundSelected")
override protected val HoverAnimationColorDefault: Color = ColorScheme("TextInputBackground")
override protected val HoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive")
// model
private val _text: Text = new Text(initialText.codePoints().toArray)
private val cursor: Cursor = new Cursor()
private val selectionWatcher = new Watcher[Option[Selection]](None)
// updated after all events are processed so that event handlers can refer to the previous position.
private val prevCursorPosition = Register.sampling(cursor.position)
// view
private var isFocused = false
private var scroll = 0f
private var blinkTimer = 0f
private var cursorOffset = 0f
private var selectionOffsets: Option[(Int, Int)] = None
private val enabledRegister = Register.sampling(enabled)
private def selection: Option[Selection] = selectionWatcher.value
private def selection_=(newValue: Option[Selection]): Unit = {
selectionWatcher.value = newValue
}
cursor.onChange(position => {
cursorOffset = charsWidth(_text.chars, 0, position)
blinkTimer = 0
adjustScroll()
})
selectionWatcher.onChange(newValue => {
selectionOffsets = newValue.map {
case Selection.Ordered(start, end) =>
(
charsWidth(_text.chars, 0, start),
charsWidth(_text.chars, 0, end),
)
}
})
private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f)
private val borderAnimation = new ColorAnimation(targetBorderColor, 7f)
// public API
// -------------------------------------------------------------------------------------------------------------------
def onInput(text: String): Unit = {}
def onConfirm(): Unit = {
unfocus()
}
def validator(text: String): Boolean = true
final def isInputValid: Boolean = validator(text)
def text: String = new String(_text.chars, 0, _text.chars.length)
def text_=(value: String): Unit = {
_text.chars = value.codePoints().toArray
selection = None
cursor.position = cursor.position max 0 min _text.chars.length
}
private def selectedText: String = selection match {
case Some(Selection.Ordered(start, end)) => new String(_text.chars, start, end)
case None => ""
}
protected var placeholder: Array[Int] = Array.empty
def placeholder_=(value: String): Unit = placeholder = value.codePoints().toArray
def focus(): Unit = {
if (!isFocused) {
if (enabled) {
isFocused = true
}
updateAnimationTargets()
}
blinkTimer = 0
}
def unfocus(): Unit = {
if (isFocused) {
isFocused = false
updateAnimationTargets()
}
}
// widget management
// -------------------------------------------------------------------------------------------------------------------
override def minimumSize: Size2D = Size2D(200, 24)
override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 24)
override def receiveAllMouseEvents = true
override protected def receiveDragEvents: Boolean = true
override protected def receiveClickEvents: Boolean = true
override protected def spamDragEvents: Boolean = false
private def mouseInBounds: Boolean = clippedBounds.contains(UiHandler.mousePosition)
protected def font: Font = Font.NormalFont
private def charWidth(codePoint: Int): Int = font.charWidth(codePoint)
/**
* Calculates given text width in pixels.
* @param from inclusive
* @param to exclusive
*/
// noinspection SameParameterValue
private def charsWidth(chars: Array[Int], from: Int, to: Int): Int = {
var width = 0
for (index <- (from max 0) until (to min chars.length)) {
width += font.charWidth(chars(index))
}
width
}
eventHandlers += {
case MouseEvent.StateChanged(MouseEvent.State.Pressed, _) if enabled =>
val inBounds = mouseInBounds
if (isFocused && !inBounds) unfocus()
if (!isFocused && inBounds) focus()
if (isFocused) {
val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x)
cursor.position = pos
}
}
eventHandlers += {
case MouseEvent.StateChanged(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds =>
val menu = new ContextMenu
if (selection.nonEmpty) {
menu.addEntry(ContextMenuEntry("Cut", IconSource.Icons.Cut) { cutSelection() })
menu.addEntry(ContextMenuEntry("Copy", IconSource.Icons.Copy) { copySelection() })
}
if (UiHandler.clipboard.nonEmpty) {
menu.addEntry(ContextMenuEntry("Paste", IconSource.Icons.Paste) { pasteSelection() })
}
if (_text.chars.nonEmpty) {
if (menu.children.nonEmpty) {
menu.addSeparator()
}
menu.addEntry(ContextMenuEntry("Select all") { selectAll() })
}
root.get.contextMenus.open(menu)
case MouseEvent.StateChanged(MouseEvent.State.Pressed, _) if isFocused && mouseInBounds =>
selection = None
case DoubleClickEvent(MouseEvent.Button.Left, _) if isFocused && mouseInBounds =>
selectWord()
case DragEvent(DragEvent.State.Start | DragEvent.State.Drag, MouseEvent.Button.Left, mouse) if isFocused =>
val pos = if (mouse.y < bounds.y) {
0
} else if (mouse.y > bounds.max.y) {
_text.chars.length
} else {
pixelToCursorPosition(mouse.x - bounds.x)
}
extendSelection(pos, prevCursorPosition.value)
cursor.position = pos
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused =>
handleKeyMovement(selection match {
case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start
case _ => cursor.position - 1
})
event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused =>
handleKeyMovement(selection match {
case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end
case _ => cursor.position + 1
})
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused =>
handleKeyMovement(0)
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused =>
handleKeyMovement(_text.chars.length)
event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused =>
if (selection.nonEmpty) {
deleteSelection()
} else {
val (lhs, rhs) = _text.chars.splitAt(cursor.position)
if (!lhs.isEmpty) {
_text.chars = lhs.take(lhs.length - 1) ++ rhs
cursor.position -= 1
}
}
event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_DELETE, _) if isFocused =>
if (selection.nonEmpty) {
deleteSelection()
} else {
val (lhs, rhs) = _text.chars.splitAt(cursor.position)
if (!rhs.isEmpty) {
_text.chars = lhs ++ rhs.drop(1)
}
}
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_A, _)
if isFocused && KeyEvents.isControlDown =>
selectAll()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_W, _)
if isFocused && KeyEvents.isControlDown =>
selectWord()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_C, _)
if isFocused && KeyEvents.isControlDown && selection.nonEmpty =>
copySelection()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_X, _)
if isFocused && KeyEvents.isControlDown && selection.nonEmpty =>
cutSelection()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_INSERT, _) if isFocused =>
pasteSelection()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_V, _)
if isFocused && KeyEvents.isControlDown =>
pasteSelection()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_RETURN, _) if isFocused =>
onConfirm()
event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, _, char) if isFocused && !char.isControl =>
if (selection.nonEmpty) {
deleteSelection()
}
writeChar(char)
event.consume()
}
/**
* Will try to find out which cursor position corresponds to the point on the screen.
* @param x widget-local coordinate
*/
private def pixelToCursorPosition(x: Float): Int = {
val absoluteX = x + scroll - 4
var width = 0
var pos = 0
while (width < absoluteX && pos < _text.chars.length) {
width += charWidth(_text.chars(pos))
if (width < absoluteX) pos += 1
}
pos
}
private def extendSelection(position: Int, cursorPosition: Int = cursor.position): Unit = {
selection = Selection(selection.fold(cursorPosition)(_.start), position)
}
private def handleKeyMovement(position: Int): Unit = {
if (KeyEvents.isShiftDown) {
extendSelection(position)
} else {
selection = None
}
cursor.position = position
}
private def selectAll(): Unit = {
selection = Selection(0, _text.chars.length)
}
private def selectWord(): Unit = {
val from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1)
val to = _text.chars.indexWhere(isWhitespace, cursor.position)
val clampedTo = if (to >= 0 && to < _text.chars.length) to else _text.chars.length
selection = Selection(from, clampedTo)
}
private def copySelection(): Unit = {
UiHandler.clipboard = selectedText
}
private def cutSelection(): Unit = {
UiHandler.clipboard = selectedText
deleteSelection()
}
private def pasteSelection(): Unit = {
if (selection.nonEmpty) deleteSelection()
writeString(UiHandler.clipboard)
}
private def deleteSelection(): Unit = {
for (Selection.Ordered(start, end) <- selection) {
_text.chars = _text.chars.take(start) ++ _text.chars.drop(end)
cursor.position = start
}
}
private def writeString(string: String): Unit = {
val (lhs, rhs) = _text.chars.splitAt(cursor.position)
val array = string.codePoints().toArray
_text.chars = lhs ++ array ++ rhs
cursor.position += array.length
}
private def writeChar(codePoint: Int): Unit = {
val (lhs, rhs) = _text.chars.splitAt(cursor.position)
_text.chars = lhs ++ Array(codePoint) ++ rhs
cursor.position += 1
}
/**
* Apply a set of corrections to the scroll to make sure the cursor and text stay visible
*/
private def adjustScroll(): Unit = {
// make cursor visible
if (cursorOffset < scroll) scroll = cursorOffset
if (cursorOffset - scroll > size.width - 16) scroll = cursorOffset - size.width + 16
// apply pressure from the left (to maximize visible text, for nicer editing experience)
val fullTextWidth = charsWidth(_text.chars, 0, _text.chars.length)
val areaWidth = size.width - 16
if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth
}
override def update(): Unit = {
super.update()
// process state changes
if (_text.changed()) {
onInput(text)
updateAnimationTargets()
adjustScroll()
}
if (enabledRegister.update()) {
updateAnimationTargets()
}
if (isFocused && !enabled) {
unfocus()
}
// update everything
prevCursorPosition.update()
foregroundAnimation.update()
borderAnimation.update()
blinkTimer = (blinkTimer + UiHandler.dt) % CursorBlinkTime
}
private def updateAnimationTargets(): Unit = {
foregroundAnimation.goto(targetForegroundColor)
borderAnimation.goto(targetBorderColor)
}
private def targetBorderColor: Color = ColorScheme(
if (validator(text)) {
if (isFocused) "TextInputBorderFocused"
else if (!enabled) "TextInputBorderDisabled"
else "TextInputBorder"
} else {
if (isFocused) "TextInputBorderErrorFocused"
else if (!enabled) "TextInputBorderErrorDisabled"
else "TextInputBorderError"
}
)
private def targetForegroundColor: Color = ColorScheme(
if (!enabled) "TextInputForegroundDisabled"
else "TextInputForeground"
)
override def draw(g: Graphics): Unit = {
g.rect(bounds, hoverAnimation.color)
DrawUtils.ring(g, position.x, position.y, size.width, size.height, thickness = 2, borderAnimation.color)
g.setScissor(position.x + 4, position.y, size.width - 8f, size.height)
for ((start, end) <- selectionOffsets) {
val width = end - start
g.rect(position.x + start + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor)
}
g.background = Color.Transparent
val foreground = if (_text.chars.nonEmpty || isFocused) foregroundAnimation.color else PlaceholderForegroundColor
g.foreground = foreground
var charOffset = 0
val charsToDisplay = if (_text.chars.nonEmpty || isFocused) _text.chars else placeholder
for (char <- charsToDisplay) {
for ((start, end) <- selectionOffsets) {
g.foreground = if (charOffset >= start && charOffset < end) ForegroundSelectedColor else foreground
}
g.char(position.x + 8 + charOffset - scroll, position.y + 4, char)
charOffset += g.font.charWidth(char)
}
if (isFocused && blinkTimer < CursorBlinkTime * 0.5f) {
g.rect(position.x + 7 + cursorOffset - scroll, position.y + 4, 2, 16, borderAnimation.color)
}
}
}
object TextInput {
class Text(initialValue: Array[Int]) extends Watcher(initialValue) {
def chars: Array[Int] = value
def chars_=(newValue: Array[Int]): Unit = value = newValue
}
class Cursor(initialValue: Int = 0) extends Watcher(initialValue) {
def position: Int = value
def position_=(newValue: Int): Unit = value = newValue
}
case class Selection(start: Int, end: Int) {
require(start != end)
}
object Selection {
def apply(start: Int, end: Int): Option[Selection] = {
Option.when(start != end) {
new Selection(start, end)
}
}
object Ordered {
def unapply(selection: Selection): Some[(Int, Int)] = {
val Selection(start, end) = selection
Some((start min end, start max end))
}
}
}
}