From c796b74ea39283290cd32a534cea247bf3e418ea Mon Sep 17 00:00:00 2001 From: UnicornFreedom Date: Fri, 22 Aug 2025 02:59:24 +0200 Subject: [PATCH] Add support for TextInput text selection --- .../resources/ocelot/desktop/colorscheme.txt | 2 + .../scala/ocelot/desktop/ui/UiHandler.scala | 2 +- .../ocelot/desktop/ui/widget/TextInput.scala | 197 +++++++++++++++--- .../scala/ocelot/desktop/util/Watcher.scala | 4 +- 4 files changed, 173 insertions(+), 32 deletions(-) diff --git a/src/main/resources/ocelot/desktop/colorscheme.txt b/src/main/resources/ocelot/desktop/colorscheme.txt index 4fed976..e5e6022 100644 --- a/src/main/resources/ocelot/desktop/colorscheme.txt +++ b/src/main/resources/ocelot/desktop/colorscheme.txt @@ -68,7 +68,9 @@ TextInputBorderDisabled = #666666 TextInputBorderFocused = #336666 TextInputBackground = #aaaaaa TextInputBackgroundActive = #bbbbbb +TextInputBackgroundSelected = #336666 TextInputForeground = #333333 +TextInputForegroundSelected = #aaaaaa TextInputForegroundDisabled = #888888 TextInputBorderError = #aa8888 TextInputBorderErrorDisabled = #aa8888 diff --git a/src/main/scala/ocelot/desktop/ui/UiHandler.scala b/src/main/scala/ocelot/desktop/ui/UiHandler.scala index 1d326aa..76fda33 100644 --- a/src/main/scala/ocelot/desktop/ui/UiHandler.scala +++ b/src/main/scala/ocelot/desktop/ui/UiHandler.scala @@ -80,7 +80,7 @@ object UiHandler extends Logging { _clipboard.getData(DataFlavor.stringFlavor).toString } catch { case _: UnsupportedFlavorException => - logger.debug("Trying to paste non-Unicode content from clipboard! Replaced with empty string.") + logger.debug("Trying to paste non-Unicode content from clipboard! Replaced with an empty string.") "" } } diff --git a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala index 9b7e00e..f5361f5 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/TextInput.scala @@ -7,8 +7,8 @@ import ocelot.desktop.graphics.{Font, Graphics} 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.{Cursor, Text} +import ocelot.desktop.ui.event.{DragEvent, KeyEvent, MouseEvent} +import ocelot.desktop.ui.widget.TextInput.{Cursor, Selector, Text} import ocelot.desktop.ui.widget.traits.HoverAnimation import ocelot.desktop.util.{DrawUtils, Register, Watcher} import ocelot.desktop.util.animation.ColorAnimation @@ -18,30 +18,35 @@ import org.lwjgl.input.Keyboard 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 selector: Selector = new Selector() // view private var isFocused = false private var scroll = 0f private var blinkTimer = 0f private var cursorOffset = 0f + private var selectorStartOffset = 0f + private var selectorEndOffset = 0f private val enabledRegister = Register.sampling(enabled) + cursor.onChange(position => { cursorOffset = charsWidth(_text.chars, 0, position) 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(_text.chars, 0, _text.chars.length) - val areaWidth = size.width - 16 - if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth + adjustScroll() + }) + + selector.onChange((from, to) => { + selectorStartOffset = charsWidth(_text.chars, 0, from min to) + selectorEndOffset = charsWidth(_text.chars, 0, from max to) }) private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f) @@ -60,7 +65,12 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w 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 + def text_=(value: String): Unit = { + _text.chars = value.codePoints().toArray + selector.active = false + } + + private def selectedText: String = new String(_text.chars, selector.start, selector.length) protected var placeholder: Array[Int] = Array.empty def placeholder_=(value: String): Unit = placeholder = value.codePoints().toArray @@ -88,6 +98,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w override def minimumSize: Size2D = Size2D(200, 24) override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 24) override def receiveAllMouseEvents = true + override def receiveDragEvents: Boolean = true protected def font: Font = Font.NormalFont @@ -108,53 +119,125 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w } eventHandlers += { - case MouseEvent(MouseEvent.State.Released, MouseEvent.Button.Left) => - if (isFocused && !clippedBounds.contains(UiHandler.mousePosition)) { - unfocus() + case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) if enabled => + val inBounds = clippedBounds.contains(UiHandler.mousePosition) + if (isFocused && !inBounds) unfocus() + if (!isFocused && inBounds) focus() + if (isFocused) { + val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x) + cursor.position = _text.chars.length.min(pos).max(0) + selector.from = cursor.position + selector.active = false } - case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) if enabled => - focus() - val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x) - cursor.position = _text.chars.length.min(pos).max(0) + case event @ DragEvent(DragEvent.State.Start, MouseEvent.Button.Left, mouse) if isFocused => + val pos = pixelToCursorPosition(mouse.x - bounds.x) + selector.active = true + selector.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) + selector.to = pos + cursor.position = pos + event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused => - if (cursor.position > 0) cursor.position -= 1 + if (KeyEvents.isShiftDown && !selector.active) { + selector.active = true + selector.from = cursor.position + } + if (selector.active && !KeyEvents.isShiftDown) { + cursor.position = selector.start + selector.active = false + } else if (cursor.position > 0) { + cursor.position -= 1 + if (selector.active) selector.to = cursor.position + } event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused => - if (cursor.position < _text.chars.length) cursor.position += 1 + if (KeyEvents.isShiftDown && !selector.active) { + selector.active = true + selector.from = cursor.position + } + if (selector.active && !KeyEvents.isShiftDown) { + cursor.position = selector.end + selector.active = false + } else if (cursor.position < _text.chars.length) { + cursor.position += 1 + if (selector.active) selector.to = cursor.position + } event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused => + if (!selector.active && KeyEvents.isShiftDown) { + selector.active = true + selector.from = cursor.position + } + if (selector.active && !KeyEvents.isShiftDown) selector.active = false cursor.position = 0 + if (selector.active && KeyEvents.isShiftDown) selector.to = cursor.position event.consume() case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused => + if (!selector.active && KeyEvents.isShiftDown) { + selector.active = true + selector.from = cursor.position + } + if (selector.active && !KeyEvents.isShiftDown) selector.active = false cursor.position = _text.chars.length + if (selector.active && KeyEvents.isShiftDown) selector.to = cursor.position event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused => - val (lhs, rhs) = _text.chars.splitAt(cursor.position) - if (!lhs.isEmpty) { - _text.chars = lhs.take(lhs.length - 1) ++ rhs - cursor.position -= 1 + if (selector.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 => - val (lhs, rhs) = _text.chars.splitAt(cursor.position) - if (!rhs.isEmpty) { - _text.chars = lhs ++ rhs.drop(1) + if (selector.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 | KeyEvent.State.Repeat, Keyboard.KEY_A, _) + if isFocused && KeyEvents.isControlDown => + selectAll() + event.consume() + + case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_C, _) + if isFocused && KeyEvents.isControlDown && selector.active => + UiHandler.clipboard = selectedText + event.consume() + + case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_X, _) + if isFocused && KeyEvents.isControlDown && selector.active => + UiHandler.clipboard = selectedText + deleteSelection() + event.consume() + case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_INSERT, _) if isFocused => + if (selector.active) deleteSelection() writeString(UiHandler.clipboard) event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_V, _) if isFocused && KeyEvents.isControlDown => + if (selector.active) deleteSelection() writeString(UiHandler.clipboard) event.consume() @@ -163,6 +246,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w event.consume() case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, _, char) if isFocused && !char.isControl => + if (selector.active) deleteSelection() writeChar(char) event.consume() } @@ -182,6 +266,18 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w pos } + private def selectAll(): Unit = { + selector.active = true + selector.from = 0 + selector.to = _text.chars.length + } + + private def deleteSelection(): Unit = { + selector.active = false + _text.chars = _text.chars.take(selector.start) ++ _text.chars.drop(selector.end) + cursor.position = selector.start + } + private def writeString(string: String): Unit = { val (lhs, rhs) = _text.chars.splitAt(cursor.position) val array = string.codePoints().toArray @@ -195,15 +291,28 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w 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.didChange) { + if (_text.changed()) { onInput(text) updateAnimationTargets() - if (cursor.position > _text.chars.length) cursor.position = _text.chars.length + adjustScroll() } if (enabledRegister.update()) { @@ -247,12 +356,21 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w g.setScissor(position.x + 4, position.y, size.width - 8f, size.height) + if (selector.active) { + val width = selectorEndOffset - selectorStartOffset + g.rect(position.x + selectorStartOffset + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor) + } + g.background = Color.Transparent - g.foreground = if (_text.chars.nonEmpty || isFocused) foregroundAnimation.color else PlaceholderForegroundColor + 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 (selector.active) { + g.foreground = if (charOffset >= selectorStartOffset && charOffset < selectorEndOffset) ForegroundSelectedColor else foreground + } g.char(position.x + 8 + charOffset - scroll, position.y + 4, char) charOffset += g.font.charWidth(char) } @@ -272,4 +390,25 @@ object TextInput { def position: Int = value def position_=(newValue: Int): Unit = value = newValue } + class Selector { + 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, _)) + } + } } diff --git a/src/main/scala/ocelot/desktop/util/Watcher.scala b/src/main/scala/ocelot/desktop/util/Watcher.scala index 4d3547d..ae4c009 100644 --- a/src/main/scala/ocelot/desktop/util/Watcher.scala +++ b/src/main/scala/ocelot/desktop/util/Watcher.scala @@ -12,13 +12,13 @@ class Watcher[T](initialValue: T) { def value: T = _value def value_=(newValue: T): Unit = { dirty = _value != newValue - _value = newValue _callback.foreach(_(newValue)) + _value = newValue } def onChange(callback: T => Unit): Unit = _callback = Some(callback) - def didChange: Boolean = { + def changed(): Boolean = { if (dirty) { dirty = false true