Implement text navigation via Ctrl keybings

This commit is contained in:
Fingercomp 2025-09-04 01:46:43 +03:00
parent 7f0fccae80
commit 8f291c4a80
No known key found for this signature in database
GPG Key ID: BBC71CEE45D86E37
13 changed files with 85 additions and 33 deletions

View File

@ -1,6 +1,6 @@
package ocelot.desktop.color package ocelot.desktop.color
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry.Vector3D import ocelot.desktop.geometry.Vector3D
import java.nio.ByteBuffer import java.nio.ByteBuffer

View File

@ -1,9 +0,0 @@
package ocelot.desktop.geometry
object FloatUtils {
implicit class ExtendedFloat(val v: Float) extends AnyVal {
def lerp(that: Float, alpha: Float): Float = v * (1 - alpha) + that * alpha
def clamped(min: Float = 0f, max: Float = 1f): Float = v.min(max).max(min)
}
}

View File

@ -1,6 +1,6 @@
package ocelot.desktop.geometry package ocelot.desktop.geometry
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
object Vector3D { object Vector3D {
val Zero: Vector3D = Vector3D(0, 0, 0) val Zero: Vector3D = Vector3D(0, 0, 0)

View File

@ -2,7 +2,7 @@ package ocelot.desktop.node
import ocelot.desktop.audio.{SoundBuffers, SoundCategory, SoundSource} import ocelot.desktop.audio.{SoundBuffers, SoundCategory, SoundSource}
import ocelot.desktop.color.Color import ocelot.desktop.color.Color
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.graphics.{Graphics, IconSource} import ocelot.desktop.graphics.{Graphics, IconSource}
import ocelot.desktop.inventory.item.SelfDestructingCardItem import ocelot.desktop.inventory.item.SelfDestructingCardItem
import ocelot.desktop.node.BoomCardFxHandler.{ExpandIntensity, ExpandPeriod, FlickerAlpha, FlickerDuty, GlowAlpha, MaxSize, MinSize} import ocelot.desktop.node.BoomCardFxHandler.{ExpandIntensity, ExpandPeriod, FlickerAlpha, FlickerDuty, GlowAlpha, MaxSize, MinSize}

View File

@ -2,7 +2,7 @@ package ocelot.desktop.node
import ocelot.desktop.ColorScheme import ocelot.desktop.ColorScheme
import ocelot.desktop.entity.traits.OcelotInterface import ocelot.desktop.entity.traits.OcelotInterface
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry.Vector2D import ocelot.desktop.geometry.Vector2D
import ocelot.desktop.graphics.Graphics import ocelot.desktop.graphics.Graphics
import ocelot.desktop.node.OcelotLogParticleNode._ import ocelot.desktop.node.OcelotLogParticleNode._

View File

@ -3,7 +3,7 @@ package ocelot.desktop.node.nodes
import ocelot.desktop.color.RGBAColorNorm import ocelot.desktop.color.RGBAColorNorm
import ocelot.desktop.entity.OcelotBlock import ocelot.desktop.entity.OcelotBlock
import ocelot.desktop.entity.traits.OcelotInterface import ocelot.desktop.entity.traits.OcelotInterface
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.graphics.{Graphics, IconSource} import ocelot.desktop.graphics.{Graphics, IconSource}
import ocelot.desktop.node.Node.HighlightThickness import ocelot.desktop.node.Node.HighlightThickness
import ocelot.desktop.node.nodes.OcelotBlockNode.ActivityFadeOutMs import ocelot.desktop.node.nodes.OcelotBlockNode.ActivityFadeOutMs

View File

@ -1,7 +1,7 @@
package ocelot.desktop.ui.widget package ocelot.desktop.ui.widget
import ocelot.desktop.ColorScheme import ocelot.desktop.ColorScheme
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D} import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D}
import ocelot.desktop.graphics.Graphics import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.UiHandler

View File

@ -3,7 +3,7 @@ package ocelot.desktop.ui.widget
import ocelot.desktop.ColorScheme import ocelot.desktop.ColorScheme
import ocelot.desktop.audio.{ClickSoundSource, SoundSource} import ocelot.desktop.audio.{ClickSoundSource, SoundSource}
import ocelot.desktop.color.Color import ocelot.desktop.color.Color
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry.Size2D import ocelot.desktop.geometry.Size2D
import ocelot.desktop.graphics.Graphics import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.event.handlers.MouseHandler import ocelot.desktop.ui.event.handlers.MouseHandler

View File

@ -11,12 +11,11 @@ import ocelot.desktop.ui.event.{DragEvent, KeyEvent, MouseEvent}
import ocelot.desktop.ui.widget.TextInput.{Cursor, Selection, Text} import ocelot.desktop.ui.widget.TextInput.{Cursor, Selection, Text}
import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry}
import ocelot.desktop.ui.widget.traits.HoverAnimation import ocelot.desktop.ui.widget.traits.HoverAnimation
import ocelot.desktop.util.NumberUtils.ExtendedInt
import ocelot.desktop.util.animation.ColorAnimation import ocelot.desktop.util.animation.ColorAnimation
import ocelot.desktop.util.{BaseWatcher, DrawUtils, Register, Watcher} import ocelot.desktop.util.{BaseWatcher, DrawUtils, Register, Watcher}
import org.lwjgl.input.Keyboard import org.lwjgl.input.Keyboard
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")
@ -210,6 +209,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
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 =>
handleKeyMovement(selection match { handleKeyMovement(selection match {
case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start
case _ if KeyEvents.isControlDown => findWordBoundary(cursor.position - 1, forward = false, wordStart = true)
case _ => cursor.position - 1 case _ => cursor.position - 1
}) })
@ -218,6 +218,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
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 =>
handleKeyMovement(selection match { handleKeyMovement(selection match {
case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end
case _ if KeyEvents.isControlDown => findWordBoundary(cursor.position + 1, forward = true, wordStart = false)
case _ => cursor.position + 1 case _ => cursor.position + 1
}) })
@ -235,11 +236,14 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
if (selection.nonEmpty) { if (selection.nonEmpty) {
deleteSelection() deleteSelection()
} else { } else {
val (lhs, rhs) = _text.chars.splitAt(cursor.position) val start = if (KeyEvents.isControlDown) {
if (!lhs.isEmpty) { findWordBoundary(cursor.position - 1, forward = false, wordStart = true)
_text.chars = lhs.take(lhs.length - 1) ++ rhs } else {
cursor.position -= 1 (cursor.position - 1).clamped(0, _text.chars.length)
} }
deleteRange(start, cursor.position)
cursor.position = start
} }
event.consume() event.consume()
@ -248,10 +252,13 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
if (selection.nonEmpty) { if (selection.nonEmpty) {
deleteSelection() deleteSelection()
} else { } else {
val (lhs, rhs) = _text.chars.splitAt(cursor.position) val end = if (KeyEvents.isControlDown) {
if (!rhs.isEmpty) { findWordBoundary(cursor.position + 1, forward = true, wordStart = false)
_text.chars = lhs ++ rhs.drop(1) } else {
(cursor.position + 1).clamped(0, _text.chars.length)
} }
deleteRange(cursor.position, end)
} }
event.consume() event.consume()
@ -331,11 +338,42 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
selection = Selection(0, _text.chars.length) selection = Selection(0, _text.chars.length)
} }
/**
* Finds a word boundary closest to the `initialPosition` going in the specified direction.
*
* If `wordStart` is `true`, the word boundary begins a word. Otherwise, it ends a word (one past the last character
* of the word). If no boundary is found, the function returns the furthest index in the specified direction.
*
* May return the `initialPosition` if it's already at the boundary.
*/
private def findWordBoundary(initialPosition: Int, forward: Boolean, wordStart: Boolean): Int = {
import Character.isLetterOrDigit
val start = initialPosition.clamped(0, _text.chars.length)
val indices = if (forward) {
start.to(_text.chars.length)
} else {
start.to(0, -1)
}
val isBoundaryCodepoints: (Int, Int) => Boolean = if (wordStart) {
(prev, current) => !isLetterOrDigit(prev) && isLetterOrDigit(current)
} else {
(prev, current) => isLetterOrDigit(prev) && !isLetterOrDigit(current)
}
def isBoundary(idx: Int): Boolean = {
idx == 0 || idx == _text.chars.length || isBoundaryCodepoints(_text.chars(idx - 1), _text.chars(idx))
}
indices.find(isBoundary).getOrElse(indices.end)
}
private def selectWord(): Unit = { private def selectWord(): Unit = {
val from = 0 max (_text.chars.lastIndexWhere(isWhitespace, cursor.position - 1) + 1) val from = findWordBoundary(cursor.position, forward = false, wordStart = true)
val to = _text.chars.indexWhere(isWhitespace, cursor.position) val to = findWordBoundary(cursor.position, forward = true, wordStart = false)
val clampedTo = if (to >= 0 && to < _text.chars.length) to else _text.chars.length selection = Selection(from, to)
selection = Selection(from, clampedTo)
} }
private def copySelection(): Unit = { private def copySelection(): Unit = {
@ -354,9 +392,15 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
private def deleteSelection(): Unit = { private def deleteSelection(): Unit = {
for (Selection.Ordered(start, end) <- selection) { for (Selection.Ordered(start, end) <- selection) {
_text.chars = _text.chars.take(start) ++ _text.chars.drop(end) deleteRange(start, end)
cursor.position = start cursor.position = start
} }
selection = None
}
private def deleteRange(start: Int, end: Int): Unit = {
_text.chars = _text.chars.take(start) ++ _text.chars.drop(end)
} }
private def writeString(string: String): Unit = { private def writeString(string: String): Unit = {

View File

@ -1,7 +1,7 @@
package ocelot.desktop.ui.widget package ocelot.desktop.ui.widget
import ocelot.desktop.color.Color import ocelot.desktop.color.Color
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry._ import ocelot.desktop.geometry._
import ocelot.desktop.graphics.scene.{Camera3D, Scene3D} import ocelot.desktop.graphics.scene.{Camera3D, Scene3D}
import ocelot.desktop.graphics.{Graphics, IconSource, Viewport3D} import ocelot.desktop.graphics.{Graphics, IconSource, Viewport3D}

View File

@ -1,6 +1,6 @@
package ocelot.desktop.ui.widget.contextmenu package ocelot.desktop.ui.widget.contextmenu
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry.Vector2D import ocelot.desktop.geometry.Vector2D
import ocelot.desktop.graphics.Graphics import ocelot.desktop.graphics.Graphics
import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.UiHandler

View File

@ -0,0 +1,17 @@
package ocelot.desktop.util
object NumberUtils {
implicit class ExtendedFloat(val v: Float) extends AnyVal {
def lerp(that: Float, alpha: Float): Float = v * (1 - alpha) + that * alpha
def clamped(min: Float = 0f, max: Float = 1f): Float = v.min(max).max(min)
}
implicit class ExtendedInt(val v: Int) extends AnyVal {
def clamped(min: Int, max: Int): Int = v.min(max).max(min)
}
implicit class ExtendedLong(val v: Long) extends AnyVal {
def clamped(min: Long, max: Long): Float = v.min(max).max(min)
}
}

View File

@ -2,7 +2,7 @@ package ocelot.desktop.windows
import ocelot.desktop.OcelotDesktop import ocelot.desktop.OcelotDesktop
import ocelot.desktop.color.{Color, IntColor} import ocelot.desktop.color.{Color, IntColor}
import ocelot.desktop.geometry.FloatUtils.ExtendedFloat import ocelot.desktop.util.NumberUtils.ExtendedFloat
import ocelot.desktop.geometry._ import ocelot.desktop.geometry._
import ocelot.desktop.graphics.IconSource import ocelot.desktop.graphics.IconSource
import ocelot.desktop.graphics.mesh.{Mesh3D, MeshBuilder3D} import ocelot.desktop.graphics.mesh.{Mesh3D, MeshBuilder3D}