package uk.co.nickthecoder.gamescupboard.client.text
/*
This is an almost identical copy of TextEditController from Korge.
This version fixes a few issues :
Caret was too wide (was 4, now 2)
Caret was fully to the left of the "center" position. Now it straddles that point.
Backspacing the penultimate char placed the caret at the end (should be before the last char).
Caret isn't visible (zero height) when the text is blank
Caret is incorrectly positioned when at the end of text, and the text ends with a space.
It also has an additional feature :
When the text is blank, and without focus, display a "prompt" text.
This is a common feature for "search" buttons on the web.
*/
import com.soywiz.kds.HistoryStack
import com.soywiz.klock.seconds
import com.soywiz.kmem.Platform
import com.soywiz.kmem.clamp
import com.soywiz.korev.ISoftKeyboardConfig
import com.soywiz.korev.Key
import com.soywiz.korev.KeyEvent
import com.soywiz.korev.SoftKeyboardConfig
import com.soywiz.korge.annotations.KorgeExperimental
import com.soywiz.korge.component.onNewAttachDetach
import com.soywiz.korge.input.cursor
import com.soywiz.korge.input.doubleClick
import com.soywiz.korge.input.newKeys
import com.soywiz.korge.input.newMouse
import com.soywiz.korge.time.timers
import com.soywiz.korge.ui.UIFocusManager
import com.soywiz.korge.ui.UIFocusable
import com.soywiz.korge.ui.blur
import com.soywiz.korge.ui.focusable
import com.soywiz.korge.ui.uiFocusManager
import com.soywiz.korge.ui.uiFocusedView
import com.soywiz.korge.util.CancellableGroup
import com.soywiz.korge.view.BlendMode
import com.soywiz.korge.view.Container
import com.soywiz.korge.view.RenderableView
import com.soywiz.korge.view.Stage
import com.soywiz.korge.view.Text
import com.soywiz.korge.view.View
import com.soywiz.korge.view.debug.debugVertexView
import com.soywiz.korgw.GameWindow
import com.soywiz.korgw.TextClipboardData
import com.soywiz.korim.color.Colors
import com.soywiz.korim.color.RGBA
import com.soywiz.korim.font.Font
import com.soywiz.korio.async.Signal
import com.soywiz.korio.lang.Closeable
import com.soywiz.korio.lang.cancel
import com.soywiz.korio.lang.withInsertion
import com.soywiz.korio.lang.withoutIndex
import com.soywiz.korio.lang.withoutRange
import com.soywiz.korio.util.length
import com.soywiz.korma.geom.Margin
import com.soywiz.korma.geom.Point
import com.soywiz.korma.geom.PointArrayList
import com.soywiz.korma.geom.bezier.Bezier
import com.soywiz.korma.geom.firstPoint
import com.soywiz.korma.geom.lastPoint
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sign
@OptIn(KorgeExperimental::class)
class TextInputController(
val textView: Text,
val caretContainer: Container = textView,
val eventHandler: View = textView,
val bg: RenderableView? = null,
) : Closeable, UIFocusable, ISoftKeyboardConfig by SoftKeyboardConfig() {
init {
textView.focusable = this
}
val stage: Stage? get() = textView.stage
var initialText: String = textView.text
private val closeables = CancellableGroup()
override val UIFocusManager.focusView: View get() = this@TextInputController.textView
val onEscPressed = Signal<TextInputController>()
val onReturnPressed = Signal<TextInputController>()
val onTextUpdated = Signal<TextInputController>()
val onFocused = Signal<TextInputController>()
val onFocusLost = Signal<TextInputController>()
val onOver = Signal<TextInputController>()
val onOut = Signal<TextInputController>()
val onSizeChanged = Signal<TextInputController>()
private val caret = caretContainer.debugVertexView().also {
it.blendMode = BlendMode.INVERT
it.visible = false
}
var padding: Margin = Margin(3.0, 2.0, 2.0, 2.0)
set(value) {
field = value
onSizeChanged(this)
}
init {
closeables += textView.onNewAttachDetach(onAttach = {
this.stage.uiFocusManager
})
onSizeChanged(this)
}
data class TextSnapshot(var text: String, var selectionRange: IntRange) {
fun apply(out: TextInputController) {
out.setTextNoSnapshot(text)
out.select(selectionRange)
}
}
private val textSnapshots = HistoryStack<TextSnapshot>()
private fun setTextNoSnapshot(text: String, out: TextSnapshot = TextSnapshot("", 0..0)): TextSnapshot? {
if (!acceptTextChange(textView.text, text)) return null
out.text = textView.text
out.selectionRange = selectionRange
textView.text = text
reclampSelection()
onTextUpdated(this)
return out
}
/**
* Used to turn the [promptText] on and off, but still allow [text] to return the correct value.
*/
private var isTextBlank = textView.text.isBlank()
/**
* When the text is blank, and this input does not have focus, then display this prompt text.
*/
var promptText: String = ""
set(v) {
field = v
if (isTextBlank) {
val snapshot = setTextNoSnapshot(if (focused) "" else promptText)
if (snapshot != null) {
textSnapshots.push(snapshot)
}
}
}
var text: String
/**
* textView.text will be the [promptText] when this input is blank.
* Do NOT return that, and return an empty string instead.
*/
get() = if (isTextBlank) "" else textView.text
set(value) {
isTextBlank = value.isBlank()
val snapshot = setTextNoSnapshot(if (isTextBlank && !focused) promptText else value)
if (snapshot != null) {
textSnapshots.push(snapshot)
}
}
fun undo() {
textSnapshots.undo()?.apply(this)
}
fun redo() {
textSnapshots.redo()?.apply(this)
}
fun insertText(substr: String) {
text = text
.withoutRange(selectionRange)
.withInsertion(min(selectionStart, selectionEnd), substr)
cursorIndex += substr.length
}
var font: Font
get() = textView.font as Font
set(value) {
textView.font = value
updateCaretPosition()
}
var textSize: Double
get() = textView.textSize
set(value) {
textView.textSize = value
updateCaretPosition()
}
var textColor: RGBA by textView::color
private var _selectionStart: Int = initialText.length
private var _selectionEnd: Int = _selectionStart
private fun clampIndex(index: Int) = index.clamp(0, text.length)
private fun reclampSelection() {
select(selectionStart, selectionEnd)
selectionStart = selectionStart
}
var selectionStart: Int
get() = _selectionStart
set(value) {
_selectionStart = clampIndex(value)
updateCaretPosition()
}
var selectionEnd: Int
get() = _selectionEnd
set(value) {
_selectionEnd = clampIndex(value)
updateCaretPosition()
}
var cursorIndex: Int
get() = selectionStart
set(value) {
val value = clampIndex(value)
_selectionStart = value
_selectionEnd = value
updateCaretPosition()
}
fun select(start: Int, end: Int) {
_selectionStart = clampIndex(start)
_selectionEnd = clampIndex(end)
updateCaretPosition()
}
fun select(range: IntRange) {
select(range.first, range.last + 1)
}
fun selectAll() {
select(0, text.length)
}
val selectionLength: Int get() = (selectionEnd - selectionStart).absoluteValue
val selectionText: String
get() = text.substring(
min(selectionStart, selectionEnd),
max(selectionStart, selectionEnd)
)
var selectionRange: IntRange
get() = min(selectionStart, selectionEnd) until max(selectionStart, selectionEnd)
set(value) {
select(value)
}
private val gameWindow get() = textView.stage!!.views.gameWindow
fun getCaretAtIndex(index: Int): Bezier {
// This is a horrible BODGE, which fixes two bugs.
// Firstly, if the text is blank, the caret ended up being 0 pixels high.
// Secondly, if the text ended in a space, the caret would be positioned before the space, not after it.
textView.text += "X"
val glyphPositions = textView.getGlyphMetrics().glyphs
textView.text = textView.text.substring(0, textView.text.length - 1)
if (glyphPositions.isEmpty()) {
// The bodge above prevents us getting here now ;-)
// NOTE, this return value is the original KorGE code, and is useless, because it creates a caret
// of zero height when the text is blank.
return Bezier(Point(), Point())
}
val glyph = glyphPositions[min(index, glyphPositions.size - 1)]
return when {
index < glyphPositions.size -> glyph.caretStart
else -> glyph.caretEnd
}
}
fun getIndexAtPos(pos: Point): Int {
val glyphPositions = textView.getGlyphMetrics().glyphs
var index = 0
var minDist = Double.POSITIVE_INFINITY
if (glyphPositions.isNotEmpty()) {
for (n in 0..glyphPositions.size) {
val glyph = glyphPositions[min(glyphPositions.size - 1, n)]
val dist = glyph.distToPath(pos)
if (minDist > dist) {
minDist = dist
index = when {
n >= glyphPositions.size - 1 && dist != 0.0 && glyph.distToPath(
pos,
startEnd = false
) < glyph.distToPath(pos, startEnd = true) -> n + 1
else -> n
}
}
}
}
return index
}
fun updateCaretPosition() {
val range = selectionRange
val array = PointArrayList(2)
if (range.isEmpty()) {
val last = (range.first >= this.text.length)
val caret = getCaretAtIndex(range.first)
val sign = if (last) -1.0 else +1.0
val normal = caret.normal(0.0) * (1 * sign)
val p0 = caret.points.firstPoint()
val p1 = caret.points.lastPoint()
array.add(p0 - normal)
array.add(p1 - normal)
array.add(p0 + normal)
array.add(p1 + normal)
} else {
for (n in range.first..range.last + 1) {
val caret = getCaretAtIndex(n)
val p0 = caret.points.firstPoint()
val p1 = caret.points.lastPoint()
array.add(p0)
array.add(p1)
}
}
caret.colorMul = Colors.WHITE
caret.pointsList = listOf(array)
caret.visible = focused
textView.invalidateRender()
}
fun moveToIndex(selection: Boolean, index: Int) {
if (selection) selectionStart = index else cursorIndex = index
}
fun nextIndex(index: Int, direction: Int, word: Boolean): Int {
val dir = direction.sign
if (word) {
val sidx = index + dir
var idx = sidx
while (true) {
if (idx !in text.indices) {
if (dir < 0) {
return idx - dir
} else {
return idx
}
}
if (!text[idx].isLetterOrDigit()) {
if (dir < 0) {
if (idx == sidx) return idx
return idx - dir
} else {
return idx
}
}
idx += dir
}
}
return index + dir
}
fun leftIndex(index: Int, word: Boolean): Int = nextIndex(index, -1, word)
fun rightIndex(index: Int, word: Boolean): Int = nextIndex(index, +1, word)
override var tabIndex: Int = 0
var acceptTextChange: (old: String, new: String) -> Boolean = { _, _ -> true }
override var focused: Boolean
set(value) {
if (value == focused) return
bg?.isFocused = value
if (value) {
if (stage?.uiFocusedView != this) {
stage?.uiFocusedView?.blur()
stage?.uiFocusedView = this
}
caret.visible = true
stage?.uiFocusManager?.requestToggleSoftKeyboard(true, this)
} else {
if (stage?.uiFocusedView == this) {
stage?.uiFocusedView = null
caret.visible = false
if (stage?.uiFocusedView !is ISoftKeyboardConfig) {
stage?.uiFocusManager?.requestToggleSoftKeyboard(false, null)
}
}
}
if (value) {
onFocused(this)
} else {
onFocusLost(this)
}
// When the focus changes
if (isTextBlank) {
val snapshot = setTextNoSnapshot(if (focused) "" else promptText)
if (snapshot != null) {
textSnapshots.push(snapshot)
}
}
}
get() = stage?.uiFocusedView == this
init {
this.eventHandler.cursor = GameWindow.Cursor.TEXT
closeables += this.eventHandler.timers.interval(0.5.seconds) {
if (!focused) {
caret.visible = false
} else {
if (selectionLength == 0) {
caret.visible = !caret.visible
} else {
caret.visible = true
}
}
}
closeables += this.eventHandler.newKeys {
typed {
if (!focused) return@typed
if (it.meta || it.ctrl) return@typed
val code = it.character.code
when (code) {
8, 127 -> Unit // backspace, backspace (handled by down event)
9, 10, 13 -> { // tab & return: Do nothing in single line text inputs
if (code == 10 || code == 13) {
onReturnPressed(this@TextInputController)
}
}
27 -> {
onEscPressed(this@TextInputController)
}
else -> {
insertText(it.characters())
}
}
}
down {
if (!focused) return@down
when (it.key) {
Key.C, Key.V, Key.Z, Key.A -> {
if (it.isNativeCtrl()) {
when (it.key) {
Key.Z -> {
if (it.shift) redo() else undo()
}
Key.C -> {
gameWindow.clipboardWrite(TextClipboardData(selectionText))
}
Key.V -> {
val rtext = (gameWindow.clipboardRead() as? TextClipboardData?)?.text
if (rtext != null) insertText(rtext)
}
Key.A -> {
selectAll()
}
else -> Unit
}
}
}
Key.BACKSPACE, Key.DELETE -> {
val range = selectionRange
if (range.length > 0) {
text = text.withoutRange(range)
cursorIndex = range.first
} else {
if (it.key == Key.BACKSPACE) {
if (cursorIndex > 0) {
// Grr, there's a side effect somewhere, which changes cursorIndex.
val oldCursorIndex = cursorIndex
text = text.withoutIndex(cursorIndex - 1)
// So let's use oldCursorIndex, and ignore that side effect!
// Without this "bodge", backspacing the penultimate char left the cursor at the end.
cursorIndex = oldCursorIndex - 1
}
} else {
if (cursorIndex < text.length) {
text = text.withoutIndex(cursorIndex)
}
}
}
}
Key.LEFT -> {
when {
it.isStartFinalSkip() -> moveToIndex(it.shift, 0)
else -> moveToIndex(it.shift, leftIndex(selectionStart, it.isWordSkip()))
}
}
Key.RIGHT -> {
when {
it.isStartFinalSkip() -> moveToIndex(it.shift, text.length)
else -> moveToIndex(it.shift, rightIndex(selectionStart, it.isWordSkip()))
}
}
Key.HOME -> moveToIndex(it.shift, 0)
Key.END -> moveToIndex(it.shift, text.length)
else -> Unit
}
}
}
closeables += this.eventHandler.newMouse {
bg?.isOver = false
onOut(this@TextInputController)
over {
onOver(this@TextInputController)
bg?.isOver = true
}
out {
onOut(this@TextInputController)
bg?.isOver = false
}
downImmediate {
cursorIndex = getIndexAtPos(it.currentPosLocal)
focused = true
}
down {
cursorIndex = getIndexAtPos(it.currentPosLocal)
}
downOutside {
if (focused) {
focused = false
blur()
}
}
moveAnywhere {
if (focused && it.pressing) {
selectionEnd = getIndexAtPos(it.currentPosLocal)
it.stopPropagation()
}
}
upOutside {
/*
// NO! Don't do this! In my game, I explicitly call TextInput.focus(),
// in a mouse up event, and the code below undoes my efforts!
// (Note the mouse never gets near the TextInput).
// PS. This is the ONLY place `dragging` is used. So I've removed it completely.
if (!dragging && focused) {
blur()
}
*/
}
doubleClick {
val index = getIndexAtPos(it.currentPosLocal)
select(leftIndex(index, true), rightIndex(index, true))
}
}
updateCaretPosition()
}
fun KeyEvent.isWordSkip(): Boolean = if (Platform.os.isApple) this.alt else this.ctrl
fun KeyEvent.isStartFinalSkip(): Boolean = this.meta && Platform.os.isApple
fun KeyEvent.isNativeCtrl(): Boolean = if (Platform.os.isApple) this.meta else this.ctrl
override fun close() {
this.textView.cursor = null
closeables.cancel()
textView.focusable = null
}
}