Split TextInput state from presentation

This commit is contained in:
UnicornFreedom 2025-08-21 13:11:58 +02:00
parent 3bee61832f
commit 605023118f
No known key found for this signature in database
GPG Key ID: B4ED0DB6B940024F
2 changed files with 81 additions and 67 deletions

View File

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

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