diff --git a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala index 57160d7..147d709 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala @@ -8,8 +8,9 @@ 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.State import ocelot.desktop.ui.widget.traits.HoverAnimation -import ocelot.desktop.util.{DrawUtils, Register} +import ocelot.desktop.util.{DrawUtils, Register, Watcher} import ocelot.desktop.util.animation.ColorAnimation import org.lwjgl.input.Keyboard @@ -20,19 +21,16 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w override protected val HoverAnimationColorDefault: Color = ColorScheme("TextInputBackground") override protected val HoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive") - // text state - private var chars = initialText.toCharArray - private var textChanged = false + // model + private val state: State = State(initialText.toCharArray) - // cursor state - private var cursorPos = 0 - private var cursorOffset = 0f - - // view state + // view private var isFocused = false private var scroll = 0f private var blinkTimer = 0f + private var cursorOffset = 0f + private val stateWatcher = Watcher(state) private val enabledRegister = Register.sampling(enabled) private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) @@ -50,11 +48,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w def validator(text: String): Boolean = true final def isInputValid: Boolean = validator(text) - def text: String = chars.mkString + def text: String = state.chars.mkString def text_=(value: String): Unit = { - chars = value.toCharArray - adjustCursor(0) - // note: we are not setting `textChanged = true` here to avoid potential recursion with `onInput` callback + state.chars = value.toCharArray + state.cursorPosition = 0 } protected var placeholder: Array[Char] = "".toCharArray @@ -110,53 +107,37 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) if enabled => focus() - val absoluteX = UiHandler.mousePosition.x - bounds.x + scroll - 4 - var width = 0 - var pos = 0 - while (width < absoluteX && pos < chars.length) { - width += charWidth(chars(pos)) - if (width < absoluteX) pos += 1 - } - adjustCursor(chars.length.min(pos).max(0)) + val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x) + state.cursorPosition = state.chars.length.min(pos).max(0) case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused => - if (cursorPos > 0) adjustCursor(cursorPos - 1) + if (state.cursorPosition > 0) state.cursorPosition -= 1 event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused => - if (cursorPos < chars.length) adjustCursor(cursorPos + 1) + if (state.cursorPosition < state.chars.length) state.cursorPosition += 1 event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused => - adjustCursor(0) + state.cursorPosition = 0 event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused => - adjustCursor(chars.length) + state.cursorPosition = state.chars.length event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused => - val (lhs, rhs) = chars.splitAt(cursorPos) + val (lhs, rhs) = state.chars.splitAt(state.cursorPosition) if (!lhs.isEmpty) { - chars = lhs.take(lhs.length - 1) ++ rhs - textChanged = true - adjustCursor(cursorPos - 1) - // if the text overflows - scroll it into visibility, it will feel nicer - val cw = charWidth(lhs.last) - scroll = (scroll - cw).max(0) + state.chars = lhs.take(lhs.length - 1) ++ rhs + state.cursorPosition -= 1 } event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_DELETE, _) if isFocused => - val (lhs, rhs) = chars.splitAt(cursorPos) + val (lhs, rhs) = state.chars.splitAt(state.cursorPosition) if (!rhs.isEmpty) { - val cw = charWidth(rhs.head) - chars = lhs ++ rhs.drop(1) - textChanged = true - blinkTimer = 0 - if (rhs.drop(1).map(charWidth).sum < size.width - 16) { - scroll = (scroll - cw).max(0) - } + state.chars = lhs ++ rhs.drop(1) } event.consume() @@ -178,44 +159,51 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w 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 < state.chars.length) { + width += charWidth(state.chars(pos)) + if (width < absoluteX) pos += 1 + } + pos + } private def writeString(string: String): Unit = { - val (lhs, rhs) = chars.splitAt(cursorPos) + val (lhs, rhs) = state.chars.splitAt(state.cursorPosition) val array = string.toCharArray - chars = lhs ++ array ++ rhs - textChanged = true - adjustCursor(cursorPos + string.length) + state.chars = lhs ++ array ++ rhs + state.cursorPosition += string.length } private def writeChar(char: Char): Unit = { - val (lhs, rhs) = chars.splitAt(cursorPos) - chars = lhs ++ Array(char) ++ rhs - textChanged = true - adjustCursor(cursorPos + 1) + val (lhs, rhs) = state.chars.splitAt(state.cursorPosition) + state.chars = lhs ++ Array(char) ++ rhs + state.cursorPosition += 1 } - private def adjustCursor(position: Int): Unit = { - cursorPos = position - cursorOffset = charsWidth(chars, 0, cursorPos) - blinkTimer = 0 - adjustScroll() - } - - private def adjustScroll(): Unit = { - if (cursorOffset < scroll) - scroll = cursorOffset - if (cursorOffset - scroll > size.width - 16) - scroll = cursorOffset - size.width + 16 - } override def update(): Unit = { super.update() // process state changes - if (textChanged) { + if (stateWatcher.didChange) { onInput(text) updateAnimationTargets() - textChanged = false + cursorOffset = charsWidth(state.chars, 0, state.cursorPosition) + 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(state.chars, 0, state.chars.length) + val areaWidth = size.width - 16 + if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth } if (enabledRegister.update()) { updateAnimationTargets() @@ -237,7 +225,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } private def targetBorderColor: Color = ColorScheme( - if (validator(chars.mkString)) { + if (validator(text)) { if (isFocused) "TextInputBorderFocused" else if (!enabled) "TextInputBorderDisabled" else "TextInputBorder" @@ -247,7 +235,6 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w else "TextInputBorderError" } ) - private def targetForegroundColor: Color = ColorScheme( if (!enabled) "TextInputForegroundDisabled" else "TextInputForeground" @@ -260,10 +247,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w 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 + g.foreground = if (state.chars.nonEmpty || isFocused) foregroundAnimation.color else PlaceholderForegroundColor var charOffset = 0 - val charsToDisplay = if (chars.nonEmpty || isFocused) chars else placeholder + val charsToDisplay = if (state.chars.nonEmpty || isFocused) state.chars else placeholder for (char <- charsToDisplay) { g.char(position.x + 8 + charOffset - scroll, position.y + 4, char) charOffset += g.font.charWidth(char) @@ -274,3 +261,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } } } + +object TextInput { + case class State(var chars: Array[Char], var cursorPosition: Int = 0) +} diff --git a/src/main/scala/ocelot/desktop/util/Watcher.scala b/src/main/scala/ocelot/desktop/util/Watcher.scala new file mode 100644 index 0000000..e217874 --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/Watcher.scala @@ -0,0 +1,23 @@ +package ocelot.desktop.util + +/** + * Keeps a reference to an object + * and tells whether there were any changes to the value since the last check. + */ +class Watcher[T](value: T) { + private var hash: Int = value.hashCode() + + def didChange: Boolean = { + val newHash = value.hashCode() + if (hash != newHash) { + hash = newHash + true + } else { + false + } + } +} + +object Watcher { + def apply[T](value: T) = new Watcher(value) +}