Reimplement TextInput's selection state

This commit is contained in:
Fingercomp 2025-09-03 00:52:44 +03:00
parent c9f8f4a123
commit ca8ef6eee1
No known key found for this signature in database
GPG Key ID: BBC71CEE45D86E37
4 changed files with 122 additions and 106 deletions

View File

@ -1,5 +1,8 @@
package ocelot.desktop.ui.event package ocelot.desktop.ui.event
case class MouseEvent(state: MouseEvent.State.Value, button: MouseEvent.Button.Value)(val stateChanged: Boolean)
extends CapturingEvent
object MouseEvent { object MouseEvent {
object State extends Enumeration { object State extends Enumeration {
val Pressed, Released = Value val Pressed, Released = Value
@ -10,6 +13,14 @@ object MouseEvent {
val Right: Button.Value = Value(1) val Right: Button.Value = Value(1)
val Middle: Button.Value = Value(2) val Middle: Button.Value = Value(2)
} }
}
case class MouseEvent(state: MouseEvent.State.Value, button: MouseEvent.Button.Value) extends CapturingEvent object StateChanged {
def unapply(event: MouseEvent): Option[(State.Value, Button.Value)] = {
if (event.stateChanged) {
MouseEvent.unapply(event)
} else {
None
}
}
}
}

View File

@ -24,7 +24,7 @@ trait MouseHandler extends Widget {
protected def receiveDragEvents: Boolean = false protected def receiveDragEvents: Boolean = false
/** /**
* If `true`, drag events, once they start, will be sent on every update cycle when if the mouse does not move. * If `true`, drag events, once they start, will be sent on every update cycle even if the mouse does not move.
*/ */
protected def spamDragEvents: Boolean = true protected def spamDragEvents: Boolean = true

View File

@ -26,13 +26,13 @@ object MouseEvents {
if (MouseEvent.Button.values.map(_.id).contains(buttonIdx)) { if (MouseEvent.Button.values.map(_.id).contains(buttonIdx)) {
val button = MouseEvent.Button(buttonIdx) val button = MouseEvent.Button(buttonIdx)
val state = if (Mouse.getEventButtonState) MouseEvent.State.Pressed else MouseEvent.State.Released val state = if (Mouse.getEventButtonState) MouseEvent.State.Pressed else MouseEvent.State.Released
_events += MouseEvent(state, button) val changed = state match {
state match {
case MouseEvent.State.Pressed => case MouseEvent.State.Pressed =>
_pressedButtons += button _pressedButtons.add(button)
case MouseEvent.State.Released => case MouseEvent.State.Released =>
_pressedButtons -= button _pressedButtons.remove(button)
} }
_events += MouseEvent(state, button)(changed)
} }
val delta = Mouse.getEventDWheel val delta = Mouse.getEventDWheel
@ -49,7 +49,7 @@ object MouseEvents {
def releaseButtons(): Unit = { def releaseButtons(): Unit = {
for (button <- pressedButtons) { for (button <- pressedButtons) {
_events += MouseEvent(MouseEvent.State.Released, button) _events += MouseEvent(MouseEvent.State.Released, button)(stateChanged = true)
} }
_pressedButtons.clear() _pressedButtons.clear()

View File

@ -17,7 +17,6 @@ import org.lwjgl.input.Keyboard
import java.lang.Character.isWhitespace import java.lang.Character.isWhitespace
class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation { class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation {
private val CursorBlinkTime = 2f private val CursorBlinkTime = 2f
private val PlaceholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled") private val PlaceholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled")
@ -29,33 +28,44 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
// model // model
private val _text: Text = new Text(initialText.codePoints().toArray) private val _text: Text = new Text(initialText.codePoints().toArray)
private val cursor: Cursor = new Cursor() private val cursor: Cursor = new Cursor()
private val selection: Selection = new Selection() 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 // view
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 var cursorOffset = 0f
private var selectionStartOffset = 0f private var selectionOffsets: Option[(Int, Int)] = None
private var selectionEndOffset = 0f
private val enabledRegister = Register.sampling(enabled) 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 => { cursor.onChange(position => {
cursorOffset = charsWidth(_text.chars, 0, position) cursorOffset = charsWidth(_text.chars, 0, position)
blinkTimer = 0 blinkTimer = 0
adjustScroll() adjustScroll()
}) })
selection.onChange((from, to) => { selectionWatcher.onChange(newValue => {
selectionStartOffset = charsWidth(_text.chars, 0, from min to) selectionOffsets = newValue.map {
selectionEndOffset = charsWidth(_text.chars, 0, from max to) case Selection.Ordered(start, end) =>
(
charsWidth(_text.chars, 0, start),
charsWidth(_text.chars, 0, end),
)
}
}) })
private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f)
private val borderAnimation = new ColorAnimation(targetBorderColor, 7f) private val borderAnimation = new ColorAnimation(targetBorderColor, 7f)
// public API // public API
// ------------------------------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------------------------------
def onInput(text: String): Unit = {} def onInput(text: String): Unit = {}
@ -70,11 +80,14 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
def text: String = new String(_text.chars, 0, _text.chars.length) def text: String = new String(_text.chars, 0, _text.chars.length)
def text_=(value: String): Unit = { def text_=(value: String): Unit = {
_text.chars = value.codePoints().toArray _text.chars = value.codePoints().toArray
selection.active = false selection = None
cursor.position = cursor.position max 0 min _text.chars.length cursor.position = cursor.position max 0 min _text.chars.length
} }
private def selectedText: String = new String(_text.chars, selection.start, selection.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 protected var placeholder: Array[Int] = Array.empty
def placeholder_=(value: String): Unit = placeholder = value.codePoints().toArray def placeholder_=(value: String): Unit = placeholder = value.codePoints().toArray
@ -96,7 +109,6 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
} }
} }
// widget management // widget management
// ------------------------------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------------------------------
override def minimumSize: Size2D = Size2D(200, 24) override def minimumSize: Size2D = Size2D(200, 24)
@ -128,79 +140,77 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
} }
eventHandlers += { eventHandlers += {
case MouseEvent(MouseEvent.State.Pressed, button) if enabled => case MouseEvent.StateChanged(MouseEvent.State.Pressed, _) if enabled =>
val inBounds = mouseInBounds val inBounds = mouseInBounds
if (isFocused && !inBounds) unfocus() if (isFocused && !inBounds) unfocus()
if (!isFocused && inBounds) focus() if (!isFocused && inBounds) focus()
if (isFocused) { if (isFocused) {
val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x) val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x)
val clamped = _text.chars.length.min(pos).max(0) cursor.position = pos
// 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 += { eventHandlers += {
case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds => case MouseEvent.StateChanged(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds =>
val menu = new ContextMenu val menu = new ContextMenu
if (selection.active) {
if (selection.nonEmpty) {
menu.addEntry(ContextMenuEntry("Cut", IconSource.Icons.Cut) { cutSelection() }) menu.addEntry(ContextMenuEntry("Cut", IconSource.Icons.Cut) { cutSelection() })
menu.addEntry(ContextMenuEntry("Copy", IconSource.Icons.Copy) { copySelection() }) menu.addEntry(ContextMenuEntry("Copy", IconSource.Icons.Copy) { copySelection() })
} }
if (UiHandler.clipboard.nonEmpty) { if (UiHandler.clipboard.nonEmpty) {
menu.addEntry(ContextMenuEntry("Paste", IconSource.Icons.Paste) { pasteSelection() }) menu.addEntry(ContextMenuEntry("Paste", IconSource.Icons.Paste) { pasteSelection() })
} }
if (_text.chars.nonEmpty) {
if (menu.children.nonEmpty) { if (menu.children.nonEmpty) {
menu.addSeparator() menu.addSeparator()
} }
menu.addEntry(ContextMenuEntry("Select all") { selectAll() }) menu.addEntry(ContextMenuEntry("Select all") { selectAll() })
}
root.get.contextMenus.open(menu) root.get.contextMenus.open(menu)
case MouseEvent.StateChanged(MouseEvent.State.Pressed, _) if isFocused && mouseInBounds =>
selection = None
case DoubleClickEvent(MouseEvent.Button.Left, _) if isFocused && mouseInBounds => case DoubleClickEvent(MouseEvent.Button.Left, _) if isFocused && mouseInBounds =>
selectWord() selectWord()
case event @ DragEvent(DragEvent.State.Start, MouseEvent.Button.Left, mouse) if isFocused => case DragEvent(DragEvent.State.Start | DragEvent.State.Drag, MouseEvent.Button.Left, mouse) if isFocused =>
val pos = pixelToCursorPosition(mouse.x - bounds.x) val pos = pixelToCursorPosition(mouse.x - bounds.x)
selection.active = true selection = Selection(selection.fold(prevCursorPosition.value)(_.start), pos)
selection.to = pos
cursor.position = 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 => case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused =>
if (selection.active && !KeyEvents.isShiftDown) { handleKeyMovement(selection match {
setCursorAndSelectionPosition(selection.start) case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start
} else if (cursor.position > 0) { case _ => cursor.position - 1
setCursorAndSelectionPosition(cursor.position - 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 (selection.active && !KeyEvents.isShiftDown) { handleKeyMovement(selection match {
setCursorAndSelectionPosition(selection.end) case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end
} else if (cursor.position < _text.chars.length) { case _ => cursor.position + 1
setCursorAndSelectionPosition(cursor.position + 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 =>
setCursorAndSelectionPosition(0) handleKeyMovement(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 =>
setCursorAndSelectionPosition(_text.chars.length) handleKeyMovement(_text.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 =>
if (selection.active) { if (selection.nonEmpty) {
deleteSelection() deleteSelection()
} else { } else {
val (lhs, rhs) = _text.chars.splitAt(cursor.position) val (lhs, rhs) = _text.chars.splitAt(cursor.position)
@ -209,10 +219,11 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
cursor.position -= 1 cursor.position -= 1
} }
} }
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 =>
if (selection.active) { if (selection.nonEmpty) {
deleteSelection() deleteSelection()
} else { } else {
val (lhs, rhs) = _text.chars.splitAt(cursor.position) val (lhs, rhs) = _text.chars.splitAt(cursor.position)
@ -220,6 +231,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
_text.chars = lhs ++ rhs.drop(1) _text.chars = lhs ++ rhs.drop(1)
} }
} }
event.consume() event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_A, _) case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_A, _)
@ -233,12 +245,12 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
event.consume() event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_C, _) case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_C, _)
if isFocused && KeyEvents.isControlDown && selection.active => if isFocused && KeyEvents.isControlDown && selection.nonEmpty =>
copySelection() copySelection()
event.consume() event.consume()
case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_X, _) case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_X, _)
if isFocused && KeyEvents.isControlDown && selection.active => if isFocused && KeyEvents.isControlDown && selection.nonEmpty =>
cutSelection() cutSelection()
event.consume() event.consume()
@ -256,7 +268,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
event.consume() event.consume()
case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, _, char) if isFocused && !char.isControl => case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, _, char) if isFocused && !char.isControl =>
if (selection.active) deleteSelection() if (selection.nonEmpty) {
deleteSelection()
}
writeChar(char) writeChar(char)
event.consume() event.consume()
} }
@ -276,36 +291,25 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
pos pos
} }
/** private def handleKeyMovement(position: Int): Unit = {
* Generally you can just do `cursor.position = x`. selection = if (KeyEvents.isShiftDown) {
* But this method will take care of correctly repositioning the selection as well. Selection(selection.fold(cursor.position)(_.start), position)
*/
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 { } else {
selection.from = cursor.position None
} }
cursor.position = position
} }
private def selectAll(): Unit = { private def selectAll(): Unit = {
selection.active = true selection = Selection(0, _text.chars.length)
selection.from = 0
selection.to = _text.chars.length
} }
private def selectWord(): Unit = { private def selectWord(): Unit = {
selection.active = true val from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1)
selection.from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1)
val to = _text.chars.indexWhere(isWhitespace, cursor.position) val to = _text.chars.indexWhere(isWhitespace, cursor.position)
selection.to = if (to >= 0 && to < _text.chars.length) to else _text.chars.length val clampedTo = if (to >= 0 && to < _text.chars.length) to else _text.chars.length
selection = Selection(from, clampedTo)
} }
private def copySelection(): Unit = { private def copySelection(): Unit = {
@ -318,14 +322,15 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
} }
private def pasteSelection(): Unit = { private def pasteSelection(): Unit = {
if (selection.active) deleteSelection() if (selection.nonEmpty) deleteSelection()
writeString(UiHandler.clipboard) writeString(UiHandler.clipboard)
} }
private def deleteSelection(): Unit = { private def deleteSelection(): Unit = {
selection.active = false for (Selection.Ordered(start, end) <- selection) {
_text.chars = _text.chars.take(selection.start) ++ _text.chars.drop(selection.end) _text.chars = _text.chars.take(start) ++ _text.chars.drop(end)
cursor.position = selection.start cursor.position = start
}
} }
private def writeString(string: String): Unit = { private def writeString(string: String): Unit = {
@ -354,7 +359,6 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth
} }
override def update(): Unit = { override def update(): Unit = {
super.update() super.update()
@ -373,12 +377,12 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
} }
// update everything // update everything
prevCursorPosition.update()
foregroundAnimation.update() foregroundAnimation.update()
borderAnimation.update() borderAnimation.update()
blinkTimer = (blinkTimer + UiHandler.dt) % CursorBlinkTime blinkTimer = (blinkTimer + UiHandler.dt) % CursorBlinkTime
} }
private def updateAnimationTargets(): Unit = { private def updateAnimationTargets(): Unit = {
foregroundAnimation.goto(targetForegroundColor) foregroundAnimation.goto(targetForegroundColor)
borderAnimation.goto(targetBorderColor) borderAnimation.goto(targetBorderColor)
@ -406,9 +410,9 @@ 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)
if (selection.active) { for ((start, end) <- selectionOffsets) {
val width = selectionEndOffset - selectionStartOffset val width = end - start
g.rect(position.x + selectionStartOffset + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor) g.rect(position.x + start + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor)
} }
g.background = Color.Transparent g.background = Color.Transparent
@ -418,9 +422,10 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
var charOffset = 0 var charOffset = 0
val charsToDisplay = if (_text.chars.nonEmpty || isFocused) _text.chars else placeholder val charsToDisplay = if (_text.chars.nonEmpty || isFocused) _text.chars else placeholder
for (char <- charsToDisplay) { for (char <- charsToDisplay) {
if (selection.active) { for ((start, end) <- selectionOffsets) {
g.foreground = if (charOffset >= selectionStartOffset && charOffset < selectionEndOffset) ForegroundSelectedColor else foreground g.foreground = if (charOffset >= start && charOffset < end) ForegroundSelectedColor else foreground
} }
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)
} }
@ -436,29 +441,29 @@ object TextInput {
def chars: Array[Int] = value def chars: Array[Int] = value
def chars_=(newValue: Array[Int]): Unit = value = newValue def chars_=(newValue: Array[Int]): Unit = value = newValue
} }
class Cursor(initialValue: Int = 0) extends Watcher(initialValue) { class Cursor(initialValue: Int = 0) extends Watcher(initialValue) {
def position: Int = value def position: Int = value
def position_=(newValue: Int): Unit = value = newValue def position_=(newValue: Int): Unit = value = newValue
} }
class Selection {
private val fromWatcher = Watcher(0)
private val toWatcher = Watcher(0)
def from: Int = fromWatcher.value case class Selection(start: Int, end: Int) {
def from_=(x: Int): Unit = fromWatcher.value = x require(start != end)
}
def to: Int = toWatcher.value object Selection {
def to_=(x: Int): Unit = toWatcher.value = x def apply(start: Int, end: Int): Option[Selection] = {
Option.when(start != end) {
new Selection(start, end)
}
}
def start: Int = from min to object Ordered {
def end: Int = from max to def unapply(selection: Selection): Some[(Int, Int)] = {
def length: Int = end - start val Selection(start, end) = selection
var active: Boolean = false Some((start min end, start max end))
}
def onChange(callback: (Int, Int) => Unit): Unit = {
fromWatcher.onChange(callback(_, to))
toWatcher.onChange(callback(from, _))
} }
} }
} }