package ocelot.desktop.ui.widget import ocelot.desktop.ColorScheme import ocelot.desktop.color.Color import ocelot.desktop.geometry.Size2D import ocelot.desktop.graphics.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.{ClickEvent, KeyEvent, MouseEvent} import ocelot.desktop.ui.widget.traits.HoverAnimation import ocelot.desktop.util.{DrawUtils, Message} import ocelot.desktop.util.animation.ColorAnimation import org.lwjgl.input.Keyboard import scala.collection.mutable.ArrayBuffer class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation { override protected val hoverAnimationColorDefault: Color = ColorScheme("TextInputBackground") override protected val hoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive") def onInput(text: String): Unit = {} def onConfirm(): Unit = { unfocus() } def validator(text: String): Boolean = true final def isInputValid: Boolean = validator(text) var isFocused = false def text: String = chars.mkString def text_=(value: String): Unit = chars = value.toCharArray protected var placeholder: Array[Char] = "".toCharArray def placeholder_=(value: Message): Unit = placeholder = value.toString.toCharArray private var cursorPos = 0 private var cursorOffset = 0f private var scroll = 0f private val CursorBlinkTime = 2f private var blinkTimer = 0f private var chars = initialText.toCharArray private var textWidth = 0 private var textChanged = false private val events = ArrayBuffer[TextEvent]() override protected def receiveClickEvents: Boolean = true // TODO: implement text selection // override protected def receiveDragEvents: Boolean = true override protected def allowClickReleaseOutsideThreshold: Boolean = false override def receiveAllMouseEvents = true private var prevEnabled = enabled eventHandlers += { case MouseEvent(MouseEvent.State.Release, MouseEvent.Button.Left) => if (isFocused && !clippedBounds.contains(UiHandler.mousePosition)) { unfocus() } case ClickEvent(MouseEvent.Button.Left, pos) if enabled => focus() events += new CursorMoveTo(pos.x) case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused => events += new CursorMoveLeft event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused => events += new CursorMoveRight event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused => events += new CursorMoveStart event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused => events += new CursorMoveEnd event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused => events += new EraseCharBack event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_DELETE, _) if isFocused => events += new EraseCharFront event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_INSERT, _) if isFocused => // TODO: insert the whole clipboard string at once instead of going char-by-char. for (char <- UiHandler.clipboard.toCharArray) { events += new WriteChar(char) } event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_V, _) if isFocused && KeyEvents.isControlDown => // TODO: insert the whole clipboard string at once instead of going char-by-char. for (char <- UiHandler.clipboard.toCharArray) { events += new WriteChar(char) } 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 => events += new WriteChar(char) event.consume() } def setInput(text: String): Unit = { this.chars = text.toCharArray cursorPos = 0 cursorOffset = 0 textWidth = 0 textChanged = true } override def minimumSize: Size2D = Size2D(200, 24) override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 24) private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) private val borderAnimation = new ColorAnimation(targetBorderColor, 7f) private def updateAnimationTargets(): Unit = { foregroundAnimation.goto(targetForegroundColor) borderAnimation.goto(targetBorderColor) } def focus(): Unit = { if (!isFocused) { if (enabled) { isFocused = true } updateAnimationTargets() } blinkTimer = 0 } def unfocus(): Unit = { if (isFocused) { isFocused = false updateAnimationTargets() } } private def targetBorderColor: Color = ColorScheme( if (validator(chars.mkString)) { 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" ) private val placeholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled") override def draw(g: Graphics): Unit = { if (textWidth == 0f && chars.nonEmpty) textWidth = chars.map(g.font.charWidth(_)).sum textChanged = false for (event <- events) event.handle(g) events.clear() if (textChanged) { val str = chars.mkString onInput(str) updateAnimationTargets() } 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 (chars.nonEmpty || isFocused) foregroundAnimation.color else placeholderForegroundColor var charOffset = 0 val charsToDisplay = if (chars.nonEmpty || isFocused) 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) } } override def update(): Unit = { super.update() val nextEnabled = enabled if (nextEnabled != prevEnabled) { updateAnimationTargets() prevEnabled = nextEnabled } if (isFocused && !enabled) { unfocus() } foregroundAnimation.update() borderAnimation.update() blinkTimer = (blinkTimer + UiHandler.dt) % CursorBlinkTime } private def charWidth(g: Graphics, c: Char): Int = g.font.charWidth(c) // noinspection SameParameterValue private def charsWidth(g: Graphics, chars: Array[Char], first: Int, last: Int): Int = { var width = 0 for (index <- first to last) { width += g.font.charWidth(chars(index)) } width } private def adjustScroll(): Unit = { if (cursorOffset < scroll) scroll = cursorOffset if (cursorOffset - scroll > size.width - 16) scroll = cursorOffset - size.width + 16 } private abstract class TextEvent { def handle(g: Graphics): Unit } // TODO: refactor this mess. have actions only move the cursor position explicitly. // then calculate the cursor offset (incl. the scroll offset) based on that automatically // rather than incrementally in the actions. private class CursorMoveLeft extends TextEvent { override def handle(g: Graphics): Unit = { if (cursorPos == 0) return cursorOffset -= charWidth(g, chars(cursorPos - 1)) cursorPos -= 1 blinkTimer = 0 adjustScroll() } } private class CursorMoveRight extends TextEvent { override def handle(g: Graphics): Unit = { if (cursorPos >= chars.length) return cursorOffset += charWidth(g, chars(cursorPos)) cursorPos += 1 blinkTimer = 0 adjustScroll() } } private class CursorMoveStart extends TextEvent { override def handle(g: Graphics): Unit = { cursorPos = 0 cursorOffset = 0 blinkTimer = 0 scroll = 0 } } private class CursorMoveEnd extends TextEvent { override def handle(g: Graphics): Unit = { cursorPos = chars.length cursorOffset = textWidth blinkTimer = 0 scroll = (textWidth - size.width + 16).max(0) } } private class CursorMoveTo(mouseX: Float) extends TextEvent { override def handle(g: Graphics): Unit = { val absoluteX = mouseX - bounds.x + scroll - 4 var width = 0 var pos = 0 while (width < absoluteX && pos < chars.length) { width += g.font.charWidth(chars(pos)) if (width < absoluteX) pos += 1 } cursorPos = chars.length.min(pos).max(0) cursorOffset = if (cursorPos > 0) charsWidth(g, chars, 0, (cursorPos - 1).max(0)) else 0 blinkTimer = 0 adjustScroll() } } private class EraseCharBack extends TextEvent { override def handle(g: Graphics): Unit = { val (lhs, rhs) = chars.splitAt(cursorPos) if (lhs.isEmpty) return val cw = charWidth(g, lhs.last) chars = lhs.take(lhs.length - 1) ++ rhs textChanged = true textWidth -= cw cursorOffset -= cw cursorPos -= 1 blinkTimer = 0 scroll = (scroll - cw).max(0) } } private class EraseCharFront extends TextEvent { override def handle(g: Graphics): Unit = { val (lhs, rhs) = chars.splitAt(cursorPos) if (rhs.isEmpty) return val cw = charWidth(g, rhs.head) chars = lhs ++ rhs.drop(1) textChanged = true textWidth -= cw blinkTimer = 0 if (rhs.drop(1).map(charWidth(g, _)).sum < size.width - 16) scroll = (scroll - cw).max(0) } } private class WriteChar(char: Char) extends TextEvent { override def handle(g: Graphics): Unit = { val (lhs, rhs) = chars.splitAt(cursorPos) chars = lhs ++ Array(char) ++ rhs textChanged = true textWidth += charWidth(g, char) (new CursorMoveRight).handle(g) } } }