-
Notifications
You must be signed in to change notification settings - Fork 85
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
[WIP] PlatformTextInputService2 #1853
base: jb-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 |
---|---|---|
|
@@ -19,26 +19,33 @@ package androidx.compose.foundation.text.input.internal | |
import androidx.compose.foundation.content.internal.ReceiveContentConfiguration | ||
import androidx.compose.foundation.text.computeSizeForDefaultText | ||
import androidx.compose.foundation.text.focusedRectInRoot | ||
import androidx.compose.foundation.text.input.TextFieldBuffer | ||
import androidx.compose.foundation.text.input.TextFieldCharSequence | ||
import androidx.compose.foundation.text.input.delete | ||
import androidx.compose.foundation.text.input.setSelectionCoerced | ||
import androidx.compose.runtime.snapshotFlow | ||
import androidx.compose.ui.ExperimentalComposeUiApi | ||
import androidx.compose.ui.geometry.Rect | ||
import androidx.compose.ui.platform.PlatformTextInputMethodRequest | ||
import androidx.compose.ui.platform.PlatformTextInputSession | ||
import androidx.compose.ui.platform.ViewConfiguration | ||
import androidx.compose.ui.text.TextLayoutResult | ||
import androidx.compose.ui.text.TextRange | ||
import androidx.compose.ui.text.input.EditCommand | ||
import androidx.compose.ui.text.input.EditProcessor | ||
import androidx.compose.ui.text.input.ImeAction | ||
import androidx.compose.ui.text.input.ImeOptions | ||
import androidx.compose.ui.text.input.TextEditingScope | ||
import androidx.compose.ui.text.input.TextEditorState | ||
import androidx.compose.ui.text.input.TextFieldValue | ||
import kotlin.math.absoluteValue | ||
import kotlin.math.sign | ||
import kotlinx.coroutines.coroutineScope | ||
import kotlinx.coroutines.flow.Flow | ||
import kotlinx.coroutines.flow.MutableSharedFlow | ||
import kotlinx.coroutines.flow.callbackFlow | ||
import kotlinx.coroutines.flow.filterNotNull | ||
import kotlinx.coroutines.launch | ||
|
||
@OptIn(ExperimentalComposeUiApi::class) | ||
internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSession( | ||
state: TransformedTextFieldState, | ||
layoutState: TextLayoutState, | ||
|
@@ -73,10 +80,19 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe | |
} | ||
} | ||
|
||
fun editText(block: TextEditingScope.() -> Unit) { | ||
state.editUntransformedTextAsUser { | ||
with(TextEditingScope(this)) { | ||
block() | ||
} | ||
} | ||
} | ||
|
||
coroutineScope { | ||
launch { | ||
state.collectImeNotifications { _, newValue, _ -> | ||
updateTextFieldValue(newValue.toTextFieldValue()) | ||
val outputValueFlow = callbackFlow { | ||
state.collectImeNotifications { oldValue, newValue, restartIme -> | ||
println("IME Notification: oldValue=$oldValue, newValue=$newValue, restartIme=$restartIme") | ||
trySend(newValue.toTextFieldValue()) | ||
} | ||
} | ||
|
||
|
@@ -97,13 +113,16 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe | |
|
||
startInputMethod( | ||
SkikoPlatformTextInputMethodRequest( | ||
state = state.untransformedText.toTextFieldValue(), | ||
value = { state.untransformedText.toTextFieldValue() }, | ||
state = state.untransformedText.asTextEditorState(), | ||
imeOptions = imeOptions, | ||
onEditCommand = ::onEditCommand, | ||
onImeAction = onImeAction, | ||
editProcessor = editProcessor, | ||
outputValue = outputValueFlow, | ||
textLayoutResult = snapshotFlow(layoutState::layoutResult).filterNotNull(), | ||
focusedRectInRoot = focusedRectInRootFlow, | ||
editText = ::editText | ||
) | ||
) | ||
} | ||
|
@@ -112,13 +131,152 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe | |
private fun TextFieldCharSequence.toTextFieldValue() = | ||
TextFieldValue(toString(), selection, composition) | ||
|
||
private fun TextFieldCharSequence.asTextEditorState() = object : TextEditorState { | ||
|
||
override val length: Int | ||
get() = this@asTextEditorState.length | ||
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 we need to override this? 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. This whole wrapper is because |
||
|
||
override fun get(index: Int): Char = this@asTextEditorState[index] | ||
|
||
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { | ||
return this@asTextEditorState.subSequence(startIndex, endIndex) | ||
} | ||
|
||
override val selection: TextRange | ||
get() = this@asTextEditorState.selection | ||
|
||
override val composition: TextRange? | ||
get() = this@asTextEditorState.composition | ||
|
||
} | ||
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. the same question about all overrides - I miss the context - for me this looks like redundant delegation - why we are doing this? |
||
|
||
/** | ||
* Helper function that returns true when [high] is a Unicode high-surrogate code unit and [low] is | ||
* a Unicode low-surrogate code unit. | ||
*/ | ||
private fun isSurrogatePair(high: Char, low: Char): Boolean = | ||
high.isHighSurrogate() && low.isLowSurrogate() | ||
|
||
|
||
/** | ||
* Returns the index, in characters, of the code point at distance [offset] from [index]. | ||
* | ||
* If there aren't enough codepoints in the correct direction, returns 0 (if [offset] is negative) | ||
* or the length of the char sequence (if [offset] is positive). | ||
*/ | ||
private fun CharSequence.indexOfCodePointAtOffset(index: Int, offset: Int): Int { | ||
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. Not obvious logic, it's better to cover this function with tests separately |
||
val sign = offset.sign | ||
val distance = offset.absoluteValue | ||
|
||
var currentOffset = index | ||
for (i in 0 until distance) { | ||
currentOffset += sign | ||
if (currentOffset <= 0) { | ||
return 0 | ||
} else if (currentOffset >= length) { | ||
return length | ||
} | ||
|
||
|
||
val lead = this[currentOffset - 1] | ||
val trail = this[currentOffset] | ||
|
||
if (isSurrogatePair(lead, trail)) { | ||
currentOffset += sign | ||
} | ||
} | ||
|
||
return currentOffset | ||
} | ||
|
||
|
||
private var TextFieldBuffer.cursor: Int | ||
get() = if (selection.collapsed) selection.end else -1 | ||
set(value) { | ||
setSelectionCoerced(value, value) | ||
} | ||
|
||
private fun TextEditingScope(buffer: TextFieldBuffer) = object : TextEditingScope { | ||
|
||
override fun deleteSurroundingTextInCodePoints( | ||
lengthBeforeCursor: Int, | ||
lengthAfterCursor: Int | ||
) { | ||
val charSequence = buffer.asCharSequence() | ||
val selection = buffer.selection | ||
buffer.delete( | ||
start = selection.end, | ||
end = charSequence.indexOfCodePointAtOffset( | ||
index = selection.end, | ||
offset = lengthAfterCursor | ||
) | ||
) | ||
buffer.delete( | ||
start = charSequence.indexOfCodePointAtOffset( | ||
index = selection.start, | ||
offset = -lengthBeforeCursor | ||
), | ||
end = selection.start | ||
) | ||
} | ||
|
||
override fun commitText(text: CharSequence, newCursorPosition: Int) { | ||
// API description says replace ongoing composition text if there. Then, if there is no | ||
// composition text, insert text into cursor position or replace selection. | ||
val replacementRange = buffer.composition ?: buffer.selection | ||
buffer.replace(replacementRange.start, replacementRange.end, text) | ||
|
||
// After replace function is called, the editing buffer places the cursor at the end of the | ||
// modified range. | ||
val newCursor = buffer.cursor | ||
|
||
// See API description for the meaning of newCursorPosition. | ||
val newCursorInBuffer = | ||
if (newCursorPosition > 0) { | ||
newCursor + newCursorPosition - 1 | ||
} else { | ||
newCursor + newCursorPosition - text.length | ||
} | ||
buffer.setSelectionCoerced(newCursorInBuffer, newCursorInBuffer) | ||
} | ||
|
||
override fun setComposingText(text: CharSequence, newCursorPosition: Int) { | ||
val replacementRange = buffer.composition ?: buffer.selection | ||
// API doc says, if there is ongoing composing text, replace it with new text. | ||
// If there is no composing text, insert composing text into cursor position with | ||
// removing selected text if any. | ||
buffer.replace(replacementRange.start, replacementRange.end, text) | ||
if (text.isNotEmpty()) { | ||
buffer.setComposition(replacementRange.start, replacementRange.start + text.length) | ||
} | ||
|
||
// After replace function is called, the editing buffer places the cursor at the end of the | ||
// modified range. | ||
val newCursor = buffer.cursor | ||
|
||
// See API description for the meaning of newCursorPosition. | ||
val newCursorInBuffer = | ||
if (newCursorPosition > 0) { | ||
newCursor + newCursorPosition - 1 | ||
} else { | ||
newCursor + newCursorPosition - text.length | ||
} | ||
|
||
buffer.cursor = newCursorInBuffer | ||
} | ||
} | ||
|
||
|
||
@OptIn(ExperimentalComposeUiApi::class) | ||
private data class SkikoPlatformTextInputMethodRequest( | ||
override val state: TextFieldValue, | ||
override val value: () -> TextFieldValue, | ||
override val state: TextEditorState, | ||
override val imeOptions: ImeOptions, | ||
override val onEditCommand: (List<EditCommand>) -> Unit, | ||
override val onImeAction: ((ImeAction) -> Unit)?, | ||
override val editProcessor: EditProcessor?, | ||
override val outputValue: Flow<TextFieldValue>, | ||
override val textLayoutResult: Flow<TextLayoutResult>, | ||
override val focusedRectInRoot: Flow<Rect> | ||
override val focusedRectInRoot: Flow<Rect>, | ||
override val editText: (block: TextEditingScope.() -> Unit) -> Unit | ||
): PlatformTextInputMethodRequest |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
/* | ||
* Copyright 2025 The Android Open Source Project | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package androidx.compose.ui.text.input | ||
|
||
import androidx.compose.ui.geometry.Rect | ||
import androidx.compose.ui.text.TextRange | ||
|
||
interface TextEditorState : CharSequence { | ||
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. Do we want to make it stable from the start? |
||
|
||
val selection: TextRange | ||
val composition: TextRange? | ||
|
||
} | ||
|
||
interface TextEditingScope { | ||
/** | ||
* Deletes text around the cursor. | ||
* | ||
* This intends to replicate [DeleteSurroundingTextInCodePointsCommand] for | ||
* [PlatformTextInputService2]. | ||
*/ | ||
fun deleteSurroundingTextInCodePoints(lengthBeforeCursor: Int, lengthAfterCursor: Int) | ||
|
||
/** | ||
* Commits text and repositions the cursor. | ||
* | ||
* This intends to replicate [CommitTextCommand] for [PlatformTextInputService2]. | ||
*/ | ||
fun commitText(text: CharSequence, newCursorPosition: Int) | ||
|
||
/** | ||
* Sets the composing text and repositions the cursor. | ||
* | ||
* This intends to replicate [SetComposingTextCommand] for [PlatformTextInputService2]. | ||
*/ | ||
fun setComposingText(text: CharSequence, newCursorPosition: Int) | ||
} | ||
|
||
interface PlatformTextInputService2 { | ||
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. All public APIs should be covered by KDocs |
||
|
||
fun startInput( | ||
state: TextEditorState, | ||
imeOptions: ImeOptions, | ||
editText: (block: TextEditingScope.() -> Unit) -> Unit, | ||
) | ||
|
||
fun stopInput() | ||
|
||
fun focusedRectChanged(rect: Rect) | ||
|
||
} |
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.
Remove or wrap into some private debug flag