-
Notifications
You must be signed in to change notification settings - Fork 127
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,3 +20,4 @@ err.txt | |
*.xcuserstate | ||
xcuserdata | ||
kotlin-js-store/ | ||
/*/build |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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() = [email protected] | ||
val onEscPressed = Signal<TextEditController>() | ||
|
@@ -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 | ||
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,7 +11,7 @@ import kotlin.test.* | |
class UITextInputTest : ViewsForTesting() { | ||
@Test | ||
fun testBackspace() = viewsTest { | ||
val textInput = uiTextInput() | ||
val textInput = uiTextInput() {} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's this change? |
||
assertEquals(0, textInput.selectionStart) | ||
textInput.focus() | ||
keyType("hello") | ||
|
@@ -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() {} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}" | ||
|
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() | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.