Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements for UITextInput #2154

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ err.txt
*.xcuserstate
xcuserdata
kotlin-js-store/
/*/build
7 changes: 7 additions & 0 deletions korge-sandbox/src/samples/MainTextInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
112 changes: 81 additions & 31 deletions korge/src/korlibs/korge/text/TextEditController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

companion object {
  val STATIC_CARET = TextInputSettings(caretBlinkingDuration = null)
}

)

@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() = [email protected]
val onEscPressed = Signal<TextEditController>()
Expand Down Expand Up @@ -89,7 +95,10 @@ class TextEditController(

private val textSnapshots = HistoryStack<TextSnapshot>()

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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this changed to private? Maybe we could rename keeping the old for compatibility to getCaretBezierAtIndex?

val glyphPositions = textView.getGlyphMetrics().glyphs
if (glyphPositions.isEmpty()) return Bezier(Point(), Point())
val glyph = glyphPositions[min(index, glyphPositions.size - 1)]
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -335,9 +364,9 @@ class TextEditController(
//override var focused: Boolean
// set(value) {
// if (value == focused) return
//
//
// bg?.isFocused = value
//
//
//
// }
// get() = stage?.uiFocusedView == this
Expand All @@ -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}")
Expand All @@ -372,9 +404,11 @@ class TextEditController(
onReturnPressed(this@TextEditController)
}
}

27 -> {
onEscPressed(this@TextEditController)
}

else -> {
insertText(it.characters())
}
Expand All @@ -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))
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -451,7 +501,7 @@ class TextEditController(
}

closeables += this.eventHandler.newMouse {
//container.mouse {
//container.mouse {
var dragging = false
bg?.isOver = false
onOut(this@TextEditController)
Expand Down
7 changes: 4 additions & 3 deletions korge/src/korlibs/korge/ui/UITextInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 { } }

Expand Down
4 changes: 4 additions & 0 deletions korge/src/korlibs/korge/view/Text.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment on lines +233 to +235
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are adding it, what about?

getGlyphMetricsForText(text: String)

So it is more generic. Or at least make it internal.


private val tempBmpEntry = Text2TextRendererActions.Entry()
private val fontMetrics = FontMetrics()
private val textMetrics = TextMetrics()
Expand Down
3 changes: 1 addition & 2 deletions korge/src@jvm/korlibs/korge/testing/testing_utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
) {
Expand Down
4 changes: 2 additions & 2 deletions korge/test/korlibs/korge/ui/UITextInputTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import kotlin.test.*
class UITextInputTest : ViewsForTesting() {
@Test
fun testBackspace() = viewsTest {
val textInput = uiTextInput()
val textInput = uiTextInput() {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this change?

assertEquals(0, textInput.selectionStart)
textInput.focus()
keyType("hello")
Expand Down Expand Up @@ -82,7 +82,7 @@ class UITextInputTest : ViewsForTesting() {

inner class TextInputTester(val stage: Stage) {
val log = arrayListOf<String>()
val textInput = stage.uiTextInput()
val textInput = stage.uiTextInput() {}
Copy link
Member

@soywiz soywiz Feb 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DITTO. What's this change?

fun log(action: String) {
log += action
log += "STATE: '${textInput.text}':${textInput.selectionStart}<..${textInput.selectionEnd}"
Expand Down
22 changes: 22 additions & 0 deletions korge/test@jvm/korlibs/korge/ui/UITextInputTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading