Text input selection test

This commit is contained in:
DBotThePony 2023-01-25 21:03:22 +07:00
parent 409c5bb443
commit b62ac72bc5
Signed by: DBot
GPG Key ID: DCC23B5715498507

View File

@ -1,10 +1,16 @@
package ru.dbotthepony.mc.otm.client.screen.panels
import com.mojang.blaze3d.platform.GlStateManager
import com.mojang.blaze3d.platform.InputConstants
import com.mojang.blaze3d.systems.RenderSystem
import com.mojang.blaze3d.vertex.DefaultVertexFormat
import com.mojang.blaze3d.vertex.PoseStack
import com.mojang.blaze3d.vertex.VertexFormat
import it.unimi.dsi.fastutil.chars.CharOpenHashSet
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap
import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap
import it.unimi.dsi.fastutil.ints.Int2ObjectMap
import net.minecraft.client.gui.screens.Screen
import net.minecraft.client.renderer.GameRenderer
import ru.dbotthepony.mc.otm.client.isCtrlDown
import ru.dbotthepony.mc.otm.client.isKeyDown
import ru.dbotthepony.mc.otm.client.isShiftDown
@ -12,6 +18,8 @@ import ru.dbotthepony.mc.otm.client.minecraft
import ru.dbotthepony.mc.otm.client.render.DynamicBufferSource
import ru.dbotthepony.mc.otm.client.render.TextAlign
import ru.dbotthepony.mc.otm.client.render.drawAligned
import ru.dbotthepony.mc.otm.client.render.tesselator
import ru.dbotthepony.mc.otm.core.addAll
import ru.dbotthepony.mc.otm.core.math.RGBAColor
import ru.dbotthepony.mc.otm.milliTime
@ -23,10 +31,39 @@ open class TextInputPanel<out S : Screen>(
width: Float = 10f,
height: Float = 10f,
) : EditablePanel<S>(screen, parent, x, y, width, height) {
data class TextSelection(val start: Int, val end: Int) {
init {
require(start <= end) { "$start <= $end" }
require(start >= 0) { "$start >= 0" }
private data class TextSelection(val start: Int, val end: Int) {
val isInversed get() = start > end
val isNotEmpty get() = start != end
val actualStart get() = if (isInversed) this.end else this.start
val actualEnd get() = if (isInversed) this.start else this.end
fun sub(line: String): Pair<String, String> {
val before = line.substring(0, actualStart.coerceIn(0, line.length))
val selected = line.substring(actualStart.coerceIn(0, line.length), actualEnd.coerceAtMost(line.length))
return before to selected
}
fun coversEntireString(value: String?): Boolean {
if (value == null)
return false
return value.length <= actualEnd && actualStart <= 0
}
fun coversEntireLine(value: String?): Boolean {
if (value == null)
return true
return value.length < actualEnd && actualStart <= 0
}
fun coversNewline(value: String?): Boolean {
if (value == null)
return actualEnd == Int.MAX_VALUE
return value.length < actualEnd
}
}
@ -34,10 +71,13 @@ open class TextInputPanel<out S : Screen>(
private val lines = ArrayList(this@TextInputPanel.lines) // ultra fast copy
private val cursorLine = this@TextInputPanel.cursorLine
private val cursorCharacter = this@TextInputPanel.cursorCharacter
private val selections = Int2ObjectAVLTreeMap(this@TextInputPanel.selections)
fun apply() {
this@TextInputPanel.lines.clear()
this@TextInputPanel.lines.addAll(lines)
this@TextInputPanel.selections.clear()
this@TextInputPanel.selections.putAll(selections)
this@TextInputPanel.cursorCharacter = cursorCharacter
this@TextInputPanel.cursorLine = cursorLine
}
@ -51,6 +91,7 @@ open class TextInputPanel<out S : Screen>(
if (lines != other.lines) return false
if (cursorLine != other.cursorLine) return false
if (cursorCharacter != other.cursorCharacter) return false
if (selections != other.selections) return false
return true
}
@ -73,7 +114,7 @@ open class TextInputPanel<out S : Screen>(
private var textCache: String? = null
private val lines = ArrayList<String>()
private val selections = Int2ObjectOpenHashMap<TextSelection>()
private val selections = Int2ObjectAVLTreeMap<TextSelection>()
private val undo = ArrayDeque<Snapshot>()
private val redo = ArrayDeque<Snapshot>()
@ -96,6 +137,11 @@ open class TextInputPanel<out S : Screen>(
undo.addLast(snapshot)
}
private fun pushbackSnapshotIfNoTimer() {
if (snapshotTimer == null)
pushbackSnapshot()
}
private fun recordHistory(hard: Boolean = false) {
if (hard) {
pushbackSnapshot()
@ -173,6 +219,19 @@ open class TextInputPanel<out S : Screen>(
lines[index] = value
}
private fun selectionIndex(index: Int) = object : Int2ObjectMap.Entry<TextSelection> {
override fun setValue(newValue: TextSelection): TextSelection? {
return selections.put(index, newValue)
}
override fun getIntKey(): Int {
return index
}
override val value: TextSelection
get() = selections[index] ?: throw NoSuchElementException()
}
fun insertLine(index: Int, value: String = "") {
lines.ensureCapacity(index)
@ -180,7 +239,8 @@ open class TextInputPanel<out S : Screen>(
lines.add("")
}
val upperLines = selections.int2ObjectEntrySet().filter { it.intKey >= index }
val upperLines = ArrayList<Int2ObjectMap.Entry<TextSelection>>()
upperLines.addAll(selections.int2ObjectEntrySet().iterator(selectionIndex(index)))
for (entry in upperLines) {
selections.remove(entry.intKey)
@ -207,7 +267,9 @@ open class TextInputPanel<out S : Screen>(
lines.removeAt(index)
selections.remove(index)
val upperLines = selections.int2ObjectEntrySet().filter { it.intKey > index }
val upperLines = ArrayList<Int2ObjectMap.Entry<TextSelection>>()
upperLines.addAll(selections.int2ObjectEntrySet().iterator(selectionIndex(index)))
for (entry in upperLines) {
selections.remove(entry.intKey)
@ -220,7 +282,7 @@ open class TextInputPanel<out S : Screen>(
return true
}
fun moveCursors(line: Int, character: Int, moveBy: Int) {
protected fun moveCursors(line: Int, character: Int, moveBy: Int) {
@Suppress("name_shadowing")
val moveBy = if (character + moveBy < 0) -character else moveBy
@ -252,9 +314,24 @@ open class TextInputPanel<out S : Screen>(
}
}
fun wipeSelection() {
protected fun wipeSelection() {
if (selections.isEmpty())
return
pushbackSnapshotIfNoTimer()
val inversed = ArrayList(selections.int2ObjectEntrySet())
inversed.sortByDescending { it.intKey }
var downTo = inversed.size.ushr(1)
if (inversed.size and 1 == 1) downTo++
for (i in inversed.size - 1 downTo downTo) {
val i2 = inversed.size - i
val a = inversed[i]
val b = inversed[i2]
inversed[i] = b
inversed[i2] = a
}
for ((lineNumber, selection) in inversed) {
if (selection.start != selection.end) {
@ -264,7 +341,7 @@ open class TextInputPanel<out S : Screen>(
}
}
selections.clear()
// selections.clear()
}
protected open fun textChanged(oldText: String, newText: String) {}
@ -294,18 +371,38 @@ open class TextInputPanel<out S : Screen>(
lines.addAll(value.split(NEWLINES))
}
fun advanceCursorLeft() {
data class CursorAdvanceResult(
val oldLine: Int,
val newLine: Int,
val oldCharacter: Int,
val newCharacter: Int,
val couldHaveChangedLine: Boolean
) {
val linesChanged get() = oldLine != newLine
val charsChanged get() = linesChanged || oldCharacter != newCharacter
val advancedChars get() = newCharacter - oldCharacter
}
fun advanceCursorLeft(greedy: Boolean = false): CursorAdvanceResult {
val oldLine = cursorLine
val oldChar = cursorCharacter
val line = this[cursorLine]
var couldHaveChangedLine = false
if (line != null && cursorCharacter > line.length) {
cursorCharacter = line.length - 1
}
if (cursorCharacter > 0) {
if (greedy && line != null) {
cursorCharacter = greedyAdvanceLeft(line, cursorCharacter)
} else {
cursorCharacter--
}
} else if (cursorLine > 0) {
cursorLine--
cursorCharacter = 0
couldHaveChangedLine = true
@Suppress("name_shadowing")
val line = this[cursorLine]
@ -313,15 +410,29 @@ open class TextInputPanel<out S : Screen>(
if (line != null) {
cursorCharacter = line.length
}
}
} else {
couldHaveChangedLine = true
}
fun advanceCursorRight() {
cursorCharacter++
return CursorAdvanceResult(oldLine, cursorLine, oldChar, cursorCharacter, couldHaveChangedLine = couldHaveChangedLine)
}
fun advanceCursorRight(greedy: Boolean = false): CursorAdvanceResult {
val oldLine = cursorLine
val oldChar = cursorCharacter
var couldHaveChangedLine = false
val line = this[cursorLine]
if (greedy && line != null && cursorCharacter + 1 < line.length) {
cursorCharacter = greedyAdvanceRight(line, cursorCharacter)
} else {
cursorCharacter++
}
if (line != null && cursorCharacter > line.length) {
couldHaveChangedLine = true
if (lines.size <= cursorLine + 1) {
cursorCharacter = line.length
} else {
@ -331,6 +442,8 @@ open class TextInputPanel<out S : Screen>(
} else if (line == null) {
cursorCharacter = 0
}
return CursorAdvanceResult(oldLine, cursorLine, oldChar, cursorCharacter, couldHaveChangedLine = couldHaveChangedLine)
}
override fun keyPressedInternal(key: Int, scancode: Int, mods: Int): Boolean {
@ -394,7 +507,6 @@ open class TextInputPanel<out S : Screen>(
if (multiLine) {
val lines = if (selections.isNotEmpty()) selections.keys else listOf(cursorLine)
if (minecraft.window.isKeyDown(InputConstants.KEY_RSHIFT) || minecraft.window.isKeyDown(InputConstants.KEY_LSHIFT)) {
var hit = false
@ -429,36 +541,54 @@ open class TextInputPanel<out S : Screen>(
}
if (key == InputConstants.KEY_LEFT) {
if (minecraft.window.isCtrlDown) {
val line = this[cursorLine]
if (line == null || cursorCharacter <= 0) {
advanceCursorLeft()
if (!minecraft.window.isShiftDown) {
advanceCursorLeft(minecraft.window.isCtrlDown)
selections.clear()
} else {
if (cursorCharacter >= line.length)
cursorCharacter = line.length - 1
if (this[cursorLine] == null)
return true
cursorCharacter = greedyAdvanceLeft(line, cursorCharacter)
val existing = selections[cursorLine]
val result = advanceCursorLeft(minecraft.window.isCtrlDown)
if (!result.linesChanged)
selections[result.oldLine] = TextSelection(
existing?.start ?: result.oldCharacter,
result.newCharacter)
else {
this[result.newLine]?.let {
val existingNewline = selections[result.newLine]
if (existingNewline == null) {
selections[result.newLine] = TextSelection(
Int.MAX_VALUE,
it.length)
} else {
if (!existingNewline.isInversed) {
selections[result.newLine] = TextSelection(
existingNewline.start,
it.length)
}
}
}
}
} else {
advanceCursorLeft()
}
return true
} else if (key == InputConstants.KEY_RIGHT) {
if (minecraft.window.isCtrlDown) {
val line = this[cursorLine]
if (line == null || cursorCharacter + 1 >= line.length) {
advanceCursorRight()
if (!minecraft.window.isShiftDown) {
advanceCursorRight(minecraft.window.isCtrlDown)
selections.clear()
} else {
if (cursorCharacter < 0)
cursorCharacter = 0
if (this[cursorLine] == null)
return true
cursorCharacter = greedyAdvanceRight(line, cursorCharacter)
}
} else {
advanceCursorRight()
val existing = selections[cursorLine]
val result = advanceCursorRight(minecraft.window.isCtrlDown)
selections[result.oldLine] = TextSelection(
existing?.start ?: result.oldCharacter,
if (result.couldHaveChangedLine) Int.MAX_VALUE else result.newCharacter)
}
return true
@ -467,23 +597,30 @@ open class TextInputPanel<out S : Screen>(
cursorLine--
}
selections.clear()
return true
} else if (key == InputConstants.KEY_DOWN) {
if (cursorLine < lines.size - 1) {
cursorLine++
}
selections.clear()
return true
}
if (key == InputConstants.KEY_BACKSPACE) {
if (cursorLine <= 0 && cursorCharacter <= 0) {
return true
} else if (cursorCharacter <= 0) {
wipeSelection()
val line = this[cursorLine]
if (cursorLine <= 0 && cursorCharacter <= 0) {
return true
} else if (cursorCharacter <= 0 || line?.length == 0) {
if (line == null) {
cursorCharacter = this[--cursorLine]?.length ?: 0
recordHistory()
} else {
removeLine(cursorLine)
val newLine = this[cursorLine]
@ -497,25 +634,33 @@ open class TextInputPanel<out S : Screen>(
recordHistory()
}
} else {
val line = this[cursorLine]
if (line != null) {
var cursorCharacter = cursorCharacter
if (cursorCharacter >= line.length)
cursorCharacter = line.length - 1
pushbackSnapshotIfNoTimer()
// remove from very end
if (cursorCharacter >= line.length) {
val newLine = line.substring(0, line.length - 1)
moveCursors(cursorLine, cursorCharacter, -1)
this[cursorLine] = newLine
recordHistory()
} else {
val newLine = line.substring(0, cursorCharacter - 1) + line.substring(cursorCharacter)
moveCursors(cursorLine, cursorCharacter, -1)
this[cursorLine] = newLine
recordHistory()
}
} else if (cursorLine > 0) {
cursorLine--
recordHistory()
}
}
return true
}
if (key == InputConstants.KEY_DELETE) {
wipeSelection()
if (cursorLine !in 0 until lines.size) {
cursorLine = lines.size - 1
return true
@ -540,8 +685,7 @@ open class TextInputPanel<out S : Screen>(
return true
}
if (snapshotTimer == null)
pushbackSnapshot()
pushbackSnapshotIfNoTimer()
val bottomLine = this[cursorLine + 1]!!
cursorCharacter = line.length
@ -550,8 +694,7 @@ open class TextInputPanel<out S : Screen>(
recordHistory()
} else {
if (snapshotTimer == null)
pushbackSnapshot()
pushbackSnapshotIfNoTimer()
val cursorCharacter = cursorCharacter
moveCursors(cursorLine, cursorCharacter, -1)
@ -572,10 +715,19 @@ open class TextInputPanel<out S : Screen>(
redo()
}
if (key == InputConstants.KEY_A && minecraft.window.isCtrlDown) {
selections.clear()
for ((i, line) in lines.withIndex()) {
selections[i] = TextSelection(0, Int.MAX_VALUE)
}
}
return true
}
override fun charTypedInternal(codepoint: Char, mods: Int): Boolean {
wipeSelection()
var line = this[cursorLine]
if (line == null) {
@ -589,8 +741,7 @@ open class TextInputPanel<out S : Screen>(
else
line = line.substring(0, cursorCharacter) + codepoint + line.substring(cursorCharacter)
if (snapshotTimer == null)
pushbackSnapshot()
pushbackSnapshotIfNoTimer()
set(cursorLine, line)
moveCursors(cursorLine, cursorCharacter, 1)
@ -602,7 +753,9 @@ open class TextInputPanel<out S : Screen>(
override fun innerRender(stack: PoseStack, mouseX: Float, mouseY: Float, partialTick: Float) {
var y = 0f
for (line in lines) {
for ((i, line) in lines.withIndex()) {
val selection = selections[i]
font.drawAligned(
poseStack = stack,
buffer = BUFFER,
@ -613,6 +766,42 @@ open class TextInputPanel<out S : Screen>(
color = textColor
)
if (selection != null && (selection.isNotEmpty || selection.coversEntireLine(line))) {
val (before, selected) = selection.sub(line)
var x = 0f
if (before.isNotEmpty()) {
x = font.width(before).toFloat()
}
val width = if (selection.coversNewline(line)) this.width - x else font.width(selected).toFloat()
RenderSystem.setShader(GameRenderer::getPositionShader)
RenderSystem.setShaderColor(0.0f, 0.0f, 1.0f, 1.0f)
RenderSystem.disableTexture()
RenderSystem.enableColorLogicOp()
RenderSystem.logicOp(GlStateManager.LogicOp.OR_REVERSE)
RenderSystem.disableDepthTest()
RenderSystem.defaultBlendFunc()
val builder = tesselator.builder
builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION)
builder.vertex(stack.last().pose(), x, y + font.lineHeight + 2f, 0f).endVertex()
builder.vertex(stack.last().pose(), x + width, y + font.lineHeight + 2f, 0f).endVertex()
builder.vertex(stack.last().pose(), x + width, y, 0f).endVertex()
builder.vertex(stack.last().pose(), x, y, 0f).endVertex()
tesselator.end()
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f)
RenderSystem.disableColorLogicOp()
RenderSystem.enableTexture()
RenderSystem.enableDepthTest()
}
y += font.lineHeight + 2f
}
@ -678,6 +867,7 @@ open class TextInputPanel<out S : Screen>(
}
override fun mouseClickedInner(x: Double, y: Double, button: Int): Boolean {
selections.clear()
requestFocus()
return true
}
@ -745,7 +935,7 @@ open class TextInputPanel<out S : Screen>(
private fun greedyAdvanceLeft(input: String, position: Int): Int {
if (position <= 1)
return 0
return -1
@Suppress("name_shadowing")
var position = position
@ -768,6 +958,12 @@ open class TextInputPanel<out S : Screen>(
private fun greedyAdvanceRight(input: String, position: Int): Int {
@Suppress("name_shadowing")
var position = position
if (position < 0)
position = 0
else if (position >= input.length)
return position + 1
var type = CharType[input[position]]
while (position < input.length) {