mirror of
https://gitlab.com/cc-ru/ocelot/ocelot-desktop.git
synced 2025-12-20 02:59:19 +01:00
Split TextInput state from presentation
This commit is contained in:
parent
3bee61832f
commit
605023118f
@ -8,8 +8,9 @@ import ocelot.desktop.ui.UiHandler
|
|||||||
import ocelot.desktop.ui.event.handlers.MouseHandler
|
import ocelot.desktop.ui.event.handlers.MouseHandler
|
||||||
import ocelot.desktop.ui.event.sources.KeyEvents
|
import ocelot.desktop.ui.event.sources.KeyEvents
|
||||||
import ocelot.desktop.ui.event.{KeyEvent, MouseEvent}
|
import ocelot.desktop.ui.event.{KeyEvent, MouseEvent}
|
||||||
|
import ocelot.desktop.ui.widget.TextInput.State
|
||||||
import ocelot.desktop.ui.widget.traits.HoverAnimation
|
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 ocelot.desktop.util.animation.ColorAnimation
|
||||||
import org.lwjgl.input.Keyboard
|
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 HoverAnimationColorDefault: Color = ColorScheme("TextInputBackground")
|
||||||
override protected val HoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive")
|
override protected val HoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive")
|
||||||
|
|
||||||
// text state
|
// model
|
||||||
private var chars = initialText.toCharArray
|
private val state: State = State(initialText.toCharArray)
|
||||||
private var textChanged = false
|
|
||||||
|
|
||||||
// cursor state
|
// view
|
||||||
private var cursorPos = 0
|
|
||||||
private var cursorOffset = 0f
|
|
||||||
|
|
||||||
// view state
|
|
||||||
private var isFocused = false
|
private var isFocused = false
|
||||||
private var scroll = 0f
|
private var scroll = 0f
|
||||||
private var blinkTimer = 0f
|
private var blinkTimer = 0f
|
||||||
|
private var cursorOffset = 0f
|
||||||
|
|
||||||
|
private val stateWatcher = Watcher(state)
|
||||||
private val enabledRegister = Register.sampling(enabled)
|
private val enabledRegister = Register.sampling(enabled)
|
||||||
|
|
||||||
private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f)
|
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
|
def validator(text: String): Boolean = true
|
||||||
final def isInputValid: Boolean = validator(text)
|
final def isInputValid: Boolean = validator(text)
|
||||||
|
|
||||||
def text: String = chars.mkString
|
def text: String = state.chars.mkString
|
||||||
def text_=(value: String): Unit = {
|
def text_=(value: String): Unit = {
|
||||||
chars = value.toCharArray
|
state.chars = value.toCharArray
|
||||||
adjustCursor(0)
|
state.cursorPosition = 0
|
||||||
// note: we are not setting `textChanged = true` here to avoid potential recursion with `onInput` callback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected var placeholder: Array[Char] = "".toCharArray
|
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 =>
|
case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) if enabled =>
|
||||||
focus()
|
focus()
|
||||||
val absoluteX = UiHandler.mousePosition.x - bounds.x + scroll - 4
|
val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x)
|
||||||
var width = 0
|
state.cursorPosition = state.chars.length.min(pos).max(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))
|
|
||||||
|
|
||||||
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused =>
|
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()
|
event.consume()
|
||||||
|
|
||||||
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused =>
|
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()
|
event.consume()
|
||||||
|
|
||||||
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused =>
|
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused =>
|
||||||
adjustCursor(0)
|
state.cursorPosition = 0
|
||||||
event.consume()
|
event.consume()
|
||||||
|
|
||||||
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused =>
|
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused =>
|
||||||
adjustCursor(chars.length)
|
state.cursorPosition = state.chars.length
|
||||||
event.consume()
|
event.consume()
|
||||||
|
|
||||||
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused =>
|
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) {
|
if (!lhs.isEmpty) {
|
||||||
chars = lhs.take(lhs.length - 1) ++ rhs
|
state.chars = lhs.take(lhs.length - 1) ++ rhs
|
||||||
textChanged = true
|
state.cursorPosition -= 1
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
event.consume()
|
event.consume()
|
||||||
|
|
||||||
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_DELETE, _) if isFocused =>
|
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) {
|
if (!rhs.isEmpty) {
|
||||||
val cw = charWidth(rhs.head)
|
state.chars = lhs ++ rhs.drop(1)
|
||||||
chars = lhs ++ rhs.drop(1)
|
|
||||||
textChanged = true
|
|
||||||
blinkTimer = 0
|
|
||||||
if (rhs.drop(1).map(charWidth).sum < size.width - 16) {
|
|
||||||
scroll = (scroll - cw).max(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
event.consume()
|
event.consume()
|
||||||
|
|
||||||
@ -178,44 +159,51 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
|
|||||||
event.consume()
|
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 = {
|
private def writeString(string: String): Unit = {
|
||||||
val (lhs, rhs) = chars.splitAt(cursorPos)
|
val (lhs, rhs) = state.chars.splitAt(state.cursorPosition)
|
||||||
val array = string.toCharArray
|
val array = string.toCharArray
|
||||||
chars = lhs ++ array ++ rhs
|
state.chars = lhs ++ array ++ rhs
|
||||||
textChanged = true
|
state.cursorPosition += string.length
|
||||||
adjustCursor(cursorPos + string.length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private def writeChar(char: Char): Unit = {
|
private def writeChar(char: Char): Unit = {
|
||||||
val (lhs, rhs) = chars.splitAt(cursorPos)
|
val (lhs, rhs) = state.chars.splitAt(state.cursorPosition)
|
||||||
chars = lhs ++ Array(char) ++ rhs
|
state.chars = lhs ++ Array(char) ++ rhs
|
||||||
textChanged = true
|
state.cursorPosition += 1
|
||||||
adjustCursor(cursorPos + 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 = {
|
override def update(): Unit = {
|
||||||
super.update()
|
super.update()
|
||||||
|
|
||||||
// process state changes
|
// process state changes
|
||||||
if (textChanged) {
|
if (stateWatcher.didChange) {
|
||||||
onInput(text)
|
onInput(text)
|
||||||
updateAnimationTargets()
|
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()) {
|
if (enabledRegister.update()) {
|
||||||
updateAnimationTargets()
|
updateAnimationTargets()
|
||||||
@ -237,7 +225,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
|
|||||||
}
|
}
|
||||||
|
|
||||||
private def targetBorderColor: Color = ColorScheme(
|
private def targetBorderColor: Color = ColorScheme(
|
||||||
if (validator(chars.mkString)) {
|
if (validator(text)) {
|
||||||
if (isFocused) "TextInputBorderFocused"
|
if (isFocused) "TextInputBorderFocused"
|
||||||
else if (!enabled) "TextInputBorderDisabled"
|
else if (!enabled) "TextInputBorderDisabled"
|
||||||
else "TextInputBorder"
|
else "TextInputBorder"
|
||||||
@ -247,7 +235,6 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
|
|||||||
else "TextInputBorderError"
|
else "TextInputBorderError"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
private def targetForegroundColor: Color = ColorScheme(
|
private def targetForegroundColor: Color = ColorScheme(
|
||||||
if (!enabled) "TextInputForegroundDisabled"
|
if (!enabled) "TextInputForegroundDisabled"
|
||||||
else "TextInputForeground"
|
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.setScissor(position.x + 4, position.y, size.width - 8f, size.height)
|
||||||
|
|
||||||
g.background = Color.Transparent
|
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
|
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) {
|
for (char <- charsToDisplay) {
|
||||||
g.char(position.x + 8 + charOffset - scroll, position.y + 4, char)
|
g.char(position.x + 8 + charOffset - scroll, position.y + 4, char)
|
||||||
charOffset += g.font.charWidth(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)
|
||||||
|
}
|
||||||
|
|||||||
23
src/main/scala/ocelot/desktop/util/Watcher.scala
Normal file
23
src/main/scala/ocelot/desktop/util/Watcher.scala
Normal file
@ -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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user