mirror of
https://gitlab.com/cc-ru/ocelot/ocelot-desktop.git
synced 2025-12-20 02:59:19 +01:00
465 lines
16 KiB
Scala
465 lines
16 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 selection: Selection = new Selection()
|
|
|
|
// view
|
|
private var isFocused = false
|
|
private var scroll = 0f
|
|
private var blinkTimer = 0f
|
|
private var cursorOffset = 0f
|
|
private var selectionStartOffset = 0f
|
|
private var selectionEndOffset = 0f
|
|
|
|
private val enabledRegister = Register.sampling(enabled)
|
|
|
|
cursor.onChange(position => {
|
|
cursorOffset = charsWidth(_text.chars, 0, position)
|
|
blinkTimer = 0
|
|
adjustScroll()
|
|
})
|
|
|
|
selection.onChange((from, to) => {
|
|
selectionStartOffset = charsWidth(_text.chars, 0, from min to)
|
|
selectionEndOffset = charsWidth(_text.chars, 0, from max to)
|
|
})
|
|
|
|
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.active = false
|
|
cursor.position = cursor.position max 0 min _text.chars.length
|
|
}
|
|
|
|
private def selectedText: String = new String(_text.chars, selection.start, selection.length)
|
|
|
|
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(MouseEvent.State.Pressed, button) if enabled =>
|
|
val inBounds = mouseInBounds
|
|
if (isFocused && !inBounds) unfocus()
|
|
if (!isFocused && inBounds) focus()
|
|
if (isFocused) {
|
|
val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x)
|
|
val clamped = _text.chars.length.min(pos).max(0)
|
|
// a bit of special logic: right click moves cursor position, but only if we clicked outside the current selection
|
|
// otherwise it would be impossible to do context operations on the selected text
|
|
if (button != MouseEvent.Button.Right || !selection.active || clamped < selection.from || clamped > selection.to) {
|
|
setCursorAndSelectionPosition(clamped)
|
|
}
|
|
}
|
|
}
|
|
|
|
eventHandlers += {
|
|
case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds =>
|
|
val menu = new ContextMenu
|
|
if (selection.active) {
|
|
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 (menu.children.nonEmpty) {
|
|
menu.addSeparator()
|
|
}
|
|
menu.addEntry(ContextMenuEntry("Select all") { selectAll() })
|
|
root.get.contextMenus.open(menu)
|
|
|
|
case DoubleClickEvent(MouseEvent.Button.Left, _) if isFocused && mouseInBounds =>
|
|
selectWord()
|
|
|
|
case event @ DragEvent(DragEvent.State.Start, MouseEvent.Button.Left, mouse) if isFocused =>
|
|
val pos = pixelToCursorPosition(mouse.x - bounds.x)
|
|
selection.active = true
|
|
selection.to = pos
|
|
cursor.position = pos
|
|
event.consume()
|
|
|
|
case event @ DragEvent(DragEvent.State.Drag, MouseEvent.Button.Left, mouse) if isFocused =>
|
|
val pos = pixelToCursorPosition(mouse.x - bounds.x)
|
|
selection.to = pos
|
|
cursor.position = pos
|
|
event.consume()
|
|
|
|
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused =>
|
|
if (selection.active && !KeyEvents.isShiftDown) {
|
|
setCursorAndSelectionPosition(selection.start)
|
|
} else if (cursor.position > 0) {
|
|
setCursorAndSelectionPosition(cursor.position - 1)
|
|
}
|
|
event.consume()
|
|
|
|
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused =>
|
|
if (selection.active && !KeyEvents.isShiftDown) {
|
|
setCursorAndSelectionPosition(selection.end)
|
|
} else if (cursor.position < _text.chars.length) {
|
|
setCursorAndSelectionPosition(cursor.position + 1)
|
|
}
|
|
event.consume()
|
|
|
|
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused =>
|
|
setCursorAndSelectionPosition(0)
|
|
event.consume()
|
|
|
|
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused =>
|
|
setCursorAndSelectionPosition(_text.chars.length)
|
|
event.consume()
|
|
|
|
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused =>
|
|
if (selection.active) {
|
|
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.active) {
|
|
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.active =>
|
|
copySelection()
|
|
event.consume()
|
|
|
|
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_X, _)
|
|
if isFocused && KeyEvents.isControlDown && selection.active =>
|
|
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.active) 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
|
|
}
|
|
|
|
/**
|
|
* Generally you can just do `cursor.position = x`.
|
|
* But this method will take care of correctly repositioning the selection as well.
|
|
*/
|
|
private def setCursorAndSelectionPosition(position: Int): Unit = {
|
|
if (!selection.active && KeyEvents.isShiftDown && cursor.position != position) {
|
|
selection.active = true
|
|
selection.from = cursor.position
|
|
} else if (selection.active && !KeyEvents.isShiftDown) {
|
|
selection.active = false
|
|
}
|
|
cursor.position = position
|
|
if (selection.active) {
|
|
selection.to = cursor.position
|
|
} else {
|
|
selection.from = cursor.position
|
|
}
|
|
}
|
|
|
|
private def selectAll(): Unit = {
|
|
selection.active = true
|
|
selection.from = 0
|
|
selection.to = _text.chars.length
|
|
}
|
|
|
|
private def selectWord(): Unit = {
|
|
selection.active = true
|
|
selection.from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1)
|
|
val to = _text.chars.indexWhere(isWhitespace, cursor.position)
|
|
selection.to = if (to >= 0 && to < _text.chars.length) to else _text.chars.length
|
|
}
|
|
|
|
private def copySelection(): Unit = {
|
|
UiHandler.clipboard = selectedText
|
|
}
|
|
|
|
private def cutSelection(): Unit = {
|
|
UiHandler.clipboard = selectedText
|
|
deleteSelection()
|
|
}
|
|
|
|
private def pasteSelection(): Unit = {
|
|
if (selection.active) deleteSelection()
|
|
writeString(UiHandler.clipboard)
|
|
}
|
|
|
|
private def deleteSelection(): Unit = {
|
|
selection.active = false
|
|
_text.chars = _text.chars.take(selection.start) ++ _text.chars.drop(selection.end)
|
|
cursor.position = selection.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
|
|
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)
|
|
|
|
if (selection.active) {
|
|
val width = selectionEndOffset - selectionStartOffset
|
|
g.rect(position.x + selectionStartOffset + 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) {
|
|
if (selection.active) {
|
|
g.foreground = if (charOffset >= selectionStartOffset && charOffset < selectionEndOffset) 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
|
|
}
|
|
class Selection {
|
|
private val fromWatcher = Watcher(0)
|
|
private val toWatcher = Watcher(0)
|
|
|
|
def from: Int = fromWatcher.value
|
|
def from_=(x: Int): Unit = fromWatcher.value = x
|
|
|
|
def to: Int = toWatcher.value
|
|
def to_=(x: Int): Unit = toWatcher.value = x
|
|
|
|
def start: Int = from min to
|
|
def end: Int = from max to
|
|
def length: Int = end - start
|
|
|
|
var active: Boolean = false
|
|
|
|
def onChange(callback: (Int, Int) => Unit): Unit = {
|
|
fromWatcher.onChange(callback(_, to))
|
|
toWatcher.onChange(callback(from, _))
|
|
}
|
|
}
|
|
}
|