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)) } } } }