Exit Full View

Games Cupboard / gamescupboard-client / src / commonMain / kotlin / uk / co / nickthecoder / gamescupboard / client / text / TextInputController.kt

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
    }
}