diff --git a/src/main/kotlin/ru/dbotthepony/mc/otm/client/screen/panels/TextInputPanel.kt b/src/main/kotlin/ru/dbotthepony/mc/otm/client/screen/panels/TextInputPanel.kt index 86f3ce71c..0b4cb9411 100644 --- a/src/main/kotlin/ru/dbotthepony/mc/otm/client/screen/panels/TextInputPanel.kt +++ b/src/main/kotlin/ru/dbotthepony/mc/otm/client/screen/panels/TextInputPanel.kt @@ -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( width: Float = 10f, height: Float = 10f, ) : EditablePanel(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 { + 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( 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( 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( private var textCache: String? = null private val lines = ArrayList() - private val selections = Int2ObjectOpenHashMap() + private val selections = Int2ObjectAVLTreeMap() private val undo = ArrayDeque() private val redo = ArrayDeque() @@ -96,6 +137,11 @@ open class TextInputPanel( 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( lines[index] = value } + private fun selectionIndex(index: Int) = object : Int2ObjectMap.Entry { + 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( lines.add("") } - val upperLines = selections.int2ObjectEntrySet().filter { it.intKey >= index } + val upperLines = ArrayList>() + upperLines.addAll(selections.int2ObjectEntrySet().iterator(selectionIndex(index))) for (entry in upperLines) { selections.remove(entry.intKey) @@ -207,7 +267,9 @@ open class TextInputPanel( lines.removeAt(index) selections.remove(index) - val upperLines = selections.int2ObjectEntrySet().filter { it.intKey > index } + + val upperLines = ArrayList>() + upperLines.addAll(selections.int2ObjectEntrySet().iterator(selectionIndex(index))) for (entry in upperLines) { selections.remove(entry.intKey) @@ -220,7 +282,7 @@ open class TextInputPanel( 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( } } - 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( } } - selections.clear() + // selections.clear() } protected open fun textChanged(oldText: String, newText: String) {} @@ -294,18 +371,38 @@ open class TextInputPanel( 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) { - cursorCharacter-- + 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( if (line != null) { cursorCharacter = line.length } + } else { + couldHaveChangedLine = true } + + return CursorAdvanceResult(oldLine, cursorLine, oldChar, cursorCharacter, couldHaveChangedLine = couldHaveChangedLine) } - fun advanceCursorRight() { - cursorCharacter++ + 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( } 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( 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( } if (key == InputConstants.KEY_LEFT) { - if (minecraft.window.isCtrlDown) { - val line = this[cursorLine] - - if (line == null || cursorCharacter <= 0) { - advanceCursorLeft() - } else { - if (cursorCharacter >= line.length) - cursorCharacter = line.length - 1 - - cursorCharacter = greedyAdvanceLeft(line, cursorCharacter) - } + if (!minecraft.window.isShiftDown) { + advanceCursorLeft(minecraft.window.isCtrlDown) + selections.clear() } else { - advanceCursorLeft() + if (this[cursorLine] == null) + return true + + 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) + } + } + } + } } return true } else if (key == InputConstants.KEY_RIGHT) { - if (minecraft.window.isCtrlDown) { - val line = this[cursorLine] - - if (line == null || cursorCharacter + 1 >= line.length) { - advanceCursorRight() - } else { - if (cursorCharacter < 0) - cursorCharacter = 0 - - cursorCharacter = greedyAdvanceRight(line, cursorCharacter) - } + if (!minecraft.window.isShiftDown) { + advanceCursorRight(minecraft.window.isCtrlDown) + selections.clear() } else { - advanceCursorRight() + if (this[cursorLine] == null) + return true + + 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( 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) { + wipeSelection() + + val line = this[cursorLine] + if (cursorLine <= 0 && cursorCharacter <= 0) { return true - } else if (cursorCharacter <= 0) { - val line = this[cursorLine] - + } else if (cursorCharacter <= 0 || line?.length == 0) { if (line == null) { cursorCharacter = this[--cursorLine]?.length ?: 0 + recordHistory() } else { removeLine(cursorLine) val newLine = this[cursorLine] @@ -497,17 +634,23 @@ open class TextInputPanel( recordHistory() } } else { - val line = this[cursorLine] - if (line != null) { - var cursorCharacter = cursorCharacter + pushbackSnapshotIfNoTimer() - if (cursorCharacter >= line.length) - cursorCharacter = line.length - 1 - - val newLine = line.substring(0, cursorCharacter - 1) + line.substring(cursorCharacter) - moveCursors(cursorLine, cursorCharacter, -1) - this[cursorLine] = newLine + // 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() } } @@ -516,6 +659,8 @@ open class TextInputPanel( } 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( return true } - if (snapshotTimer == null) - pushbackSnapshot() + pushbackSnapshotIfNoTimer() val bottomLine = this[cursorLine + 1]!! cursorCharacter = line.length @@ -550,8 +694,7 @@ open class TextInputPanel( recordHistory() } else { - if (snapshotTimer == null) - pushbackSnapshot() + pushbackSnapshotIfNoTimer() val cursorCharacter = cursorCharacter moveCursors(cursorLine, cursorCharacter, -1) @@ -572,10 +715,19 @@ open class TextInputPanel( 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( 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( 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( 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( } override fun mouseClickedInner(x: Double, y: Double, button: Int): Boolean { + selections.clear() requestFocus() return true } @@ -745,7 +935,7 @@ open class TextInputPanel( 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( 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) {