diff --git a/.gitignore b/.gitignore index 47389cd609..f4d0e79824 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ err.txt *.xcuserstate xcuserdata kotlin-js-store/ +/*/build diff --git a/korge-sandbox/src/samples/MainTextInput.kt b/korge-sandbox/src/samples/MainTextInput.kt index 8be879274f..bfcf40e98c 100644 --- a/korge-sandbox/src/samples/MainTextInput.kt +++ b/korge-sandbox/src/samples/MainTextInput.kt @@ -4,6 +4,7 @@ import korlibs.event.* import korlibs.image.color.* import korlibs.image.font.* import korlibs.io.file.std.* +import korlibs.korge.annotations.* import korlibs.korge.scene.* import korlibs.korge.text.* import korlibs.korge.ui.* @@ -13,6 +14,7 @@ import korlibs.math.geom.* import korlibs.math.geom.shape.* class MainTextInput : Scene() { + @OptIn(KorgeExperimental::class) override suspend fun SContainer.sceneMain() { //val bitmap = NativeImage(512, 512, premultiplied = true).context2d { // fill(Colors.RED) { @@ -46,6 +48,11 @@ class MainTextInput : Scene() { this.softKeyboardType = SoftKeyboardType.EMAIL_ADDRESS }.xy(200, 300) + uiTextInput("input with static caret", size = Size(512f, 64f), settings = TextInputSettings(caretBlinkingDuration = null)) { + this.textSize = 40.0 + this.font = font + }.xy(200, 400) + val textPath = buildVectorPath { circle(Point(0, 0), 100.0) } text( diff --git a/korge/src/korlibs/korge/text/TextEditController.kt b/korge/src/korlibs/korge/text/TextEditController.kt index 93017ec9bb..9a313a802b 100644 --- a/korge/src/korlibs/korge/text/TextEditController.kt +++ b/korge/src/korlibs/korge/text/TextEditController.kt @@ -24,19 +24,25 @@ import korlibs.time.* import kotlin.math.* import kotlin.text.isLetterOrDigit +data class TextInputSettings( + // Controls caret blinking duration. + // If null, then caret will be static. + val caretBlinkingDuration: TimeSpan? = 0.5.seconds, +) + @KorgeExperimental class TextEditController( val textView: Text, val caretContainer: Container = textView, val eventHandler: View = textView, val bg: RenderableView? = null, + val settings: TextInputSettings = TextInputSettings(), ) : 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.Scope.focusView: View get() = this@TextEditController.textView val onEscPressed = Signal() @@ -89,7 +95,10 @@ class TextEditController( private val textSnapshots = HistoryStack() - private fun setTextNoSnapshot(text: String, out: TextSnapshot = TextSnapshot("", 0..0)): 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 @@ -138,7 +147,7 @@ class TextEditController( } var textColor: RGBA by textView::color - private var _selectionStart: Int = initialText.length + private var _selectionStart: Int = textView.text.length private var _selectionEnd: Int = _selectionStart private fun clampIndex(index: Int) = index.clamp(0, text.length) @@ -186,7 +195,11 @@ class TextEditController( } val selectionLength: Int get() = (selectionEnd - selectionStart).absoluteValue - val selectionText: String get() = text.substring(min(selectionStart, selectionEnd), max(selectionStart, selectionEnd)) + 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) { @@ -195,7 +208,7 @@ class TextEditController( private val gameWindow get() = textView.stage!!.views.gameWindow - fun getCaretAtIndex(index: Int): Bezier { + private fun getCaretAtIndex(index: Int): Bezier { val glyphPositions = textView.getGlyphMetrics().glyphs if (glyphPositions.isEmpty()) return Bezier(Point(), Point()) val glyph = glyphPositions[min(index, glyphPositions.size - 1)] @@ -231,7 +244,11 @@ class TextEditController( minDist = dist //println("[$n] dist=$dist") index = when { - n >= glyphPositions.size - 1 && dist != 0.0 && glyph.distToPath(pos, startEnd = false) < glyph.distToPath(pos, startEnd = true) -> n + 1 + n >= glyphPositions.size - 1 && dist != 0.0 && glyph.distToPath( + pos, + startEnd = false + ) < glyph.distToPath(pos, startEnd = true) -> n + 1 + else -> n } } @@ -241,23 +258,35 @@ class TextEditController( return index } - fun updateCaretPosition() { + private fun updateCaretPosition() { val range = selectionRange //val startX = getCaretAtIndex(range.start) //val endX = getCaretAtIndex(range.endExclusive) val array = PointArrayList(if (range.isEmpty()) 2 else (range.length + 1) * 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(Ratio.ZERO) * (2.0 * sign) - val p0 = caret.points.first - val p1 = caret.points.last - array.add(p0) - array.add(p1) - array.add(p0 + normal) - array.add(p1 + normal) + if (range.first == 0 && this.text.isEmpty()) { + // Render caret when text is empty. + // We use the height of a space character as the caret height. + val caretHeight = textView.getGlyphMetricsForSpaceCharacter().glyphs.first().caretStart.points.last.y + val caretWidth = 2.0 + array.add(Point(0.0, 0)) + array.add(Point(0.0, caretHeight)) + array.add(Point(caretWidth, 0)) + array.add(Point(caretWidth, caretHeight)) + } else { + 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(Ratio.ZERO) * (2.0 * sign) + val p0 = caret.points.first + val p1 = caret.points.last + array.add(p0) + array.add(p1) + array.add(p0 + normal) + array.add(p1 + normal) + } + } else { for (n in range.first..range.last + 1) { val caret = getCaretAtIndex(n) @@ -335,9 +364,9 @@ class TextEditController( //override var focused: Boolean // set(value) { // if (value == focused) return -// + // // bg?.isFocused = value -// + // // // } // get() = stage?.uiFocusedView == this @@ -347,18 +376,21 @@ class TextEditController( this.eventHandler.cursor = Cursor.TEXT - closeables += this.eventHandler.timers.interval(0.5.seconds) { - if (!focused) { - caret.visible = false - } else { - if (selectionLength == 0) { - caret.visible = !caret.visible + if (settings.caretBlinkingDuration != null) { + closeables += this.eventHandler.timers.interval(settings.caretBlinkingDuration) { + if (!focused) { + caret.visible = false } else { - caret.visible = true + if (selectionLength == 0) { + caret.visible = !caret.visible + } else { + caret.visible = true + } } } } + closeables += this.eventHandler.newKeys { typed { //println("focused=$focused, focus=${textView.stage?.uiFocusManager?.uiFocusedView}") @@ -372,9 +404,11 @@ class TextEditController( onReturnPressed(this@TextEditController) } } + 27 -> { onEscPressed(this@TextEditController) } + else -> { insertText(it.characters()) } @@ -391,6 +425,7 @@ class TextEditController( Key.Z -> { if (it.shift) redo() else undo() } + Key.C, Key.X -> { if (selectionText.isNotEmpty()) { gameWindow.clipboardWrite(TextClipboardData(selectionText)) @@ -401,17 +436,22 @@ class TextEditController( moveToIndex(false, selection.first) } } + Key.V -> { - val rtext = (gameWindow.clipboardRead() as? TextClipboardData?)?.text + 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) { @@ -422,7 +462,8 @@ class TextEditController( if (cursorIndex > 0) { val oldCursorIndex = cursorIndex text = text.withoutIndex(cursorIndex - 1) - cursorIndex = oldCursorIndex - 1 // This [oldCursorIndex] is required since changing text might change the cursorIndex already in some circumstances + cursorIndex = + oldCursorIndex - 1 // This [oldCursorIndex] is required since changing text might change the cursorIndex already in some circumstances } } else { if (cursorIndex < text.length) { @@ -431,18 +472,27 @@ class TextEditController( } } } + Key.LEFT -> { when { it.isStartFinalSkip() -> moveToIndex(it.shift, 0) - else -> moveToIndex(it.shift, leftIndex(selectionStart, it.isWordSkip())) + 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())) + else -> moveToIndex( + it.shift, + rightIndex(selectionStart, it.isWordSkip()) + ) } } + Key.HOME -> moveToIndex(it.shift, 0) Key.END -> moveToIndex(it.shift, text.length) else -> Unit @@ -451,7 +501,7 @@ class TextEditController( } closeables += this.eventHandler.newMouse { - //container.mouse { + //container.mouse { var dragging = false bg?.isOver = false onOut(this@TextEditController) diff --git a/korge/src/korlibs/korge/ui/UITextInput.kt b/korge/src/korlibs/korge/ui/UITextInput.kt index a4d37d7fc9..fb8376beff 100644 --- a/korge/src/korlibs/korge/ui/UITextInput.kt +++ b/korge/src/korlibs/korge/ui/UITextInput.kt @@ -14,15 +14,16 @@ import korlibs.math.geom.* inline fun Container.uiTextInput( initialText: String = "", size: Size = Size(128, 24), + settings: TextInputSettings = TextInputSettings(), block: @ViewDslMarker UITextInput.() -> Unit = {} -): UITextInput = UITextInput(initialText, size) +): UITextInput = UITextInput(initialText, size, settings) .addTo(this).also { block(it) } /** * Simple Single Line Text Input */ @KorgeExperimental -class UITextInput(initialText: String = "", size: Size = Size(128, 24)) : +class UITextInput(initialText: String = "", size: Size = Size(128, 24), settings: TextInputSettings = TextInputSettings()) : UIView(size), //UIFocusable, ISoftKeyboardConfig by SoftKeyboardConfig() { @@ -38,7 +39,7 @@ class UITextInput(initialText: String = "", size: Size = Size(128, 24)) : //private val container = fixedSizeContainer(width - 4.0, height - 4.0).position(2.0, 3.0) private val textView = container.text(initialText, 16.0, color = Colors.BLACK, font = DefaultTtfFontAsBitmap) //private val textView = container.text(initialText, 16.0, color = Colors.BLACK, font = DefaultTtfFont) - val controller = TextEditController(textView, textView, this, bg) + val controller = TextEditController(textView, textView, this, bg, settings) //init { uiScrollable { } } diff --git a/korge/src/korlibs/korge/view/Text.kt b/korge/src/korlibs/korge/view/Text.kt index 8c4590fa5a..0b8b0c8a0e 100644 --- a/korge/src/korlibs/korge/view/Text.kt +++ b/korge/src/korlibs/korge/view/Text.kt @@ -230,6 +230,10 @@ open class Text( return _textMetricsResult ?: error("Must ensure font is resolved before calling getGlyphMetrics") } + fun getGlyphMetricsForSpaceCharacter(): TextMetricsResult { + return font.getOrNull()?.getTextBoundsWithGlyphs(fontSize, " ", renderer, alignment) ?: error("Must ensure font is resolved before calling getGlyphMetrics") + } + private val tempBmpEntry = Text2TextRendererActions.Entry() private val fontMetrics = FontMetrics() private val textMetrics = TextMetrics() diff --git a/korge/src@jvm/korlibs/korge/testing/testing_utils.kt b/korge/src@jvm/korlibs/korge/testing/testing_utils.kt index 99a49638e2..0c0b4aca1d 100644 --- a/korge/src@jvm/korlibs/korge/testing/testing_utils.kt +++ b/korge/src@jvm/korlibs/korge/testing/testing_utils.kt @@ -14,9 +14,8 @@ import korlibs.math.geom.* import kotlinx.coroutines.sync.* import java.awt.* -@OptIn(KorgeExperimental::class) inline fun korgeScreenshotTestV2( - korgeConfig: Korge, + korgeConfig: Korge = Korge(), settings: KorgeScreenshotValidationSettings = KorgeScreenshotValidationSettings(), crossinline callback: suspend Stage.(korgeScreenshotTester: KorgeScreenshotTester) -> Unit = {}, ) { diff --git a/korge/test/korlibs/korge/ui/UITextInputTest.kt b/korge/test/korlibs/korge/ui/UITextInputTest.kt index 25b1728862..68190fef9c 100644 --- a/korge/test/korlibs/korge/ui/UITextInputTest.kt +++ b/korge/test/korlibs/korge/ui/UITextInputTest.kt @@ -11,7 +11,7 @@ import kotlin.test.* class UITextInputTest : ViewsForTesting() { @Test fun testBackspace() = viewsTest { - val textInput = uiTextInput() + val textInput = uiTextInput() {} assertEquals(0, textInput.selectionStart) textInput.focus() keyType("hello") @@ -82,7 +82,7 @@ class UITextInputTest : ViewsForTesting() { inner class TextInputTester(val stage: Stage) { val log = arrayListOf() - val textInput = stage.uiTextInput() + val textInput = stage.uiTextInput() {} fun log(action: String) { log += action log += "STATE: '${textInput.text}':${textInput.selectionStart}<..${textInput.selectionEnd}" diff --git a/korge/test@jvm/korlibs/korge/ui/UITextInputTest.kt b/korge/test@jvm/korlibs/korge/ui/UITextInputTest.kt new file mode 100644 index 0000000000..254fb2d3a1 --- /dev/null +++ b/korge/test@jvm/korlibs/korge/ui/UITextInputTest.kt @@ -0,0 +1,22 @@ +package korlibs.korge.ui + +import korlibs.korge.annotations.* +import korlibs.korge.testing.* +import korlibs.korge.text.* +import korlibs.math.geom.* +import kotlin.test.* + +class UITextInputTestJvm { + @OptIn(KorgeExperimental::class) + @Test + fun emptyTextInputRendersCaret() = korgeScreenshotTestV2 { + val input = uiTextInput( + initialText = "", + size = Size(20.0, 24.0), + settings = TextInputSettings(caretBlinkingDuration = null) + ) { } + input.focus() + it.recordGolden(input, "emptyTextInputRendersCaret") + it.endTest() + } +} diff --git a/korge/testGoldens/korlibs.korge.ui.UITextInputTestJvm/emptyTextInputRendersCaret_emptyTextInputRendersCaret.png b/korge/testGoldens/korlibs.korge.ui.UITextInputTestJvm/emptyTextInputRendersCaret_emptyTextInputRendersCaret.png new file mode 100644 index 0000000000..f4752f6bce Binary files /dev/null and b/korge/testGoldens/korlibs.korge.ui.UITextInputTestJvm/emptyTextInputRendersCaret_emptyTextInputRendersCaret.png differ