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