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} import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.handlers.MouseHandler import ocelot.desktop.ui.event.sources.KeyEvents import ocelot.desktop.ui.event.{KeyEvent, MouseEvent} import ocelot.desktop.ui.widget.TextInput.{Cursor, Text} 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 class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation { private val CursorBlinkTime = 2f private val PlaceholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled") override protected val HoverAnimationColorDefault: Color = ColorScheme("TextInputBackground") override protected val HoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive") // model private val _text: Text = Text(initialText.codePoints().toArray) private val cursor: Cursor = Cursor() // view private var isFocused = false private var scroll = 0f private var blinkTimer = 0f private var cursorOffset = 0f private val textWatcher = Watcher(_text) private val cursorWatcher = Watcher(cursor) private val enabledRegister = Register.sampling(enabled) 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 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 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.Released, MouseEvent.Button.Left) => if (isFocused && !clippedBounds.contains(UiHandler.mousePosition)) { unfocus() } case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) if enabled => focus() val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x) cursor.position = _text.chars.length.min(pos).max(0) case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused => if (cursor.position > 0) cursor.position -= 1 event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused => if (cursor.position < _text.chars.length) cursor.position += 1 event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused => cursor.position = 0 event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused => cursor.position = _text.chars.length event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused => 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 => 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 | KeyEvent.State.Repeat, Keyboard.KEY_INSERT, _) if isFocused => writeString(UiHandler.clipboard) event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_V, _) if isFocused && KeyEvents.isControlDown => writeString(UiHandler.clipboard) 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 => 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 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 } override def update(): Unit = { super.update() // process state changes if (textWatcher.didChange) { onInput(text) updateAnimationTargets() if (cursor.position > _text.chars.length) cursor.position = _text.chars.length } if (cursorWatcher.didChange) { cursorOffset = charsWidth(_text.chars, 0, cursor.position) blinkTimer = 0 // 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 } 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) g.background = Color.Transparent g.foreground = if (_text.chars.nonEmpty || isFocused) foregroundAnimation.color else PlaceholderForegroundColor var charOffset = 0 val charsToDisplay = if (_text.chars.nonEmpty || isFocused) _text.chars else placeholder for (char <- charsToDisplay) { 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 { case class Text(var chars: Array[Int]) case class Cursor(var position: Int = 0) }