Use punctuation boundaries for word selection on double-click

Closes #181.
This commit is contained in:
Fingercomp 2025-09-05 00:20:38 +03:00
parent 8f291c4a80
commit cbd3927cc5
No known key found for this signature in database
GPG Key ID: BBC71CEE45D86E37

View File

@ -8,7 +8,7 @@ import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.event.handlers.MouseHandler
import ocelot.desktop.ui.event.sources.KeyEvents
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, isPunctuation}
import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry}
import ocelot.desktop.ui.widget.traits.HoverAnimation
import ocelot.desktop.util.NumberUtils.ExtendedInt
@ -209,7 +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 =>
handleKeyMovement(selection match {
case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start
case _ if KeyEvents.isControlDown => findWordBoundary(cursor.position - 1, forward = false, wordStart = true)
case _ if KeyEvents.isControlDown => findWordBoundary(cursor.position - 1, forward = false, wordStart = true, punctuationBoundaries = false)
case _ => cursor.position - 1
})
@ -218,7 +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 =>
handleKeyMovement(selection match {
case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end
case _ if KeyEvents.isControlDown => findWordBoundary(cursor.position + 1, forward = true, wordStart = false)
case _ if KeyEvents.isControlDown => findWordBoundary(cursor.position + 1, forward = true, wordStart = false, punctuationBoundaries = false)
case _ => cursor.position + 1
})
@ -237,7 +237,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
deleteSelection()
} else {
val start = if (KeyEvents.isControlDown) {
findWordBoundary(cursor.position - 1, forward = false, wordStart = true)
findWordBoundary(cursor.position - 1, forward = false, wordStart = true, punctuationBoundaries = false)
} else {
(cursor.position - 1).clamped(0, _text.chars.length)
}
@ -253,7 +253,7 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
deleteSelection()
} else {
val end = if (KeyEvents.isControlDown) {
findWordBoundary(cursor.position + 1, forward = true, wordStart = false)
findWordBoundary(cursor.position + 1, forward = true, wordStart = false, punctuationBoundaries = false)
} else {
(cursor.position + 1).clamped(0, _text.chars.length)
}
@ -344,9 +344,12 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
* 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.
*
* If `punctuationBoundaries` is enabled, a contiguous punctuation sequence is treated as a word for the purposes of
* boundary search.
*
* May return the `initialPosition` if it's already at the boundary.
*/
private def findWordBoundary(initialPosition: Int, forward: Boolean, wordStart: Boolean): Int = {
private def findWordBoundary(initialPosition: Int, forward: Boolean, wordStart: Boolean, punctuationBoundaries: Boolean): Int = {
import Character.isLetterOrDigit
val start = initialPosition.clamped(0, _text.chars.length)
@ -358,9 +361,15 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
}
val isBoundaryCodepoints: (Int, Int) => Boolean = if (wordStart) {
(prev, current) => !isLetterOrDigit(prev) && isLetterOrDigit(current)
(prev, current) => (
isLetterOrDigit(current) && !isLetterOrDigit(prev)
|| punctuationBoundaries && isPunctuation(current) && !isPunctuation(prev)
)
} else {
(prev, current) => isLetterOrDigit(prev) && !isLetterOrDigit(current)
(prev, current) => (
!isLetterOrDigit(current) && isLetterOrDigit(prev)
|| punctuationBoundaries && !isPunctuation(current) && isPunctuation(prev)
)
}
def isBoundary(idx: Int): Boolean = {
@ -371,8 +380,8 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
}
private def selectWord(): Unit = {
val from = findWordBoundary(cursor.position, forward = false, wordStart = true)
val to = findWordBoundary(cursor.position, forward = true, wordStart = false)
val from = findWordBoundary(cursor.position, forward = false, wordStart = true, punctuationBoundaries = true)
val to = findWordBoundary(from + 1, forward = true, wordStart = false, punctuationBoundaries = true)
selection = Selection(from, to)
}
@ -507,6 +516,20 @@ class TextInput(val initialText: String = "") extends Widget with MouseHandler w
}
object TextInput {
private val PunctuationCategories = Array[Int](
Character.CONNECTOR_PUNCTUATION,
Character.DASH_PUNCTUATION,
Character.END_PUNCTUATION,
Character.FINAL_QUOTE_PUNCTUATION,
Character.INITIAL_QUOTE_PUNCTUATION,
Character.OTHER_PUNCTUATION,
Character.START_PUNCTUATION,
)
private def isPunctuation(codepoint: Int): Boolean = {
PunctuationCategories.contains(Character.getType(codepoint))
}
class Text(initialValue: Array[Int]) extends BaseWatcher(initialValue) {
def chars: Array[Int] = value
def chars_=(newValue: Array[Int]): Unit = value = newValue