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

[WIP] PlatformTextInputService2 #1853

Open
wants to merge 1 commit into
base: jb-main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Copy link
Member

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

trySend(newValue.toTextFieldValue())
}
}

Expand All @@ -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
)
)
}
Expand All @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

why we need to override this?

Copy link
Member Author

Choose a reason for hiding this comment

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

This whole wrapper is because TextFieldCharSequence is in foundation, but PlatformTextInputService(2) is in ui. So foundation needs to wrap it in an interface that's available in ui.


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

}
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 {
Copy link
Member

Choose a reason for hiding this comment

The 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
17 changes: 17 additions & 0 deletions compose/ui/ui-text/api/desktop/ui-text.api
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,12 @@ public abstract interface class androidx/compose/ui/text/input/PlatformTextInput
public fun updateTextLayoutResult (Landroidx/compose/ui/text/input/TextFieldValue;Landroidx/compose/ui/text/input/OffsetMapping;Landroidx/compose/ui/text/TextLayoutResult;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/geometry/Rect;Landroidx/compose/ui/geometry/Rect;)V
}

public abstract interface class androidx/compose/ui/text/input/PlatformTextInputService2 {
public abstract fun focusedRectChanged (Landroidx/compose/ui/geometry/Rect;)V
public abstract fun startInput (Landroidx/compose/ui/text/input/TextEditorState;Landroidx/compose/ui/text/input/ImeOptions;Lkotlin/jvm/functions/Function1;)V
public abstract fun stopInput ()V
}

public final class androidx/compose/ui/text/input/SetComposingRegionCommand : androidx/compose/ui/text/input/EditCommand {
public static final field $stable I
public fun <init> (II)V
Expand Down Expand Up @@ -1394,6 +1400,17 @@ public final class androidx/compose/ui/text/input/SetSelectionCommand : androidx
public fun toString ()Ljava/lang/String;
}

public abstract interface class androidx/compose/ui/text/input/TextEditingScope {
public abstract fun commitText (Ljava/lang/CharSequence;I)V
public abstract fun deleteSurroundingTextInCodePoints (II)V
public abstract fun setComposingText (Ljava/lang/CharSequence;I)V
}

public abstract interface class androidx/compose/ui/text/input/TextEditorState : java/lang/CharSequence {
public abstract fun getComposition-MzsxiRA ()Landroidx/compose/ui/text/TextRange;
public abstract fun getSelection-d9O1mEE ()J
}

public final class androidx/compose/ui/text/input/TextFieldValue {
public static final field $stable I
public static final field Companion Landroidx/compose/ui/text/input/TextFieldValue$Companion;
Expand Down
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 {
Copy link
Member

Choose a reason for hiding this comment

The 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 {
Copy link
Member

Choose a reason for hiding this comment

The 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)

}
6 changes: 4 additions & 2 deletions compose/ui/ui/api/desktop/ui.api
Original file line number Diff line number Diff line change
Expand Up @@ -3584,12 +3584,15 @@ public abstract interface class androidx/compose/ui/platform/PlatformTextInputIn

public abstract interface class androidx/compose/ui/platform/PlatformTextInputMethodRequest {
public abstract fun getEditProcessor ()Landroidx/compose/ui/text/input/EditProcessor;
public abstract fun getEditText ()Lkotlin/jvm/functions/Function1;
public abstract fun getFocusedRectInRoot ()Lkotlinx/coroutines/flow/Flow;
public abstract fun getImeOptions ()Landroidx/compose/ui/text/input/ImeOptions;
public abstract fun getOnEditCommand ()Lkotlin/jvm/functions/Function1;
public abstract fun getOnImeAction ()Lkotlin/jvm/functions/Function1;
public abstract fun getState ()Landroidx/compose/ui/text/input/TextFieldValue;
public abstract fun getOutputValue ()Lkotlinx/coroutines/flow/Flow;
public abstract fun getState ()Landroidx/compose/ui/text/input/TextEditorState;
public abstract fun getTextLayoutResult ()Lkotlinx/coroutines/flow/Flow;
public abstract fun getValue ()Lkotlin/jvm/functions/Function0;
}

public abstract interface class androidx/compose/ui/platform/PlatformTextInputModifierNode : androidx/compose/ui/node/DelegatableNode {
Expand All @@ -3602,7 +3605,6 @@ public final class androidx/compose/ui/platform/PlatformTextInputModifierNodeKt

public abstract interface class androidx/compose/ui/platform/PlatformTextInputSession {
public abstract fun startInputMethod (Landroidx/compose/ui/platform/PlatformTextInputMethodRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun updateTextFieldValue (Landroidx/compose/ui/text/input/TextFieldValue;)V
}

public abstract interface class androidx/compose/ui/platform/PlatformTextInputSessionScope : androidx/compose/ui/platform/PlatformTextInputSession, kotlinx/coroutines/CoroutineScope {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ internal class DesktopTextInputService(private val component: PlatformComponent)
}

fun inputMethodTextChanged(event: InputMethodEvent) {
if (!event.isConsumed) {
if ((currentInput != null) && !event.isConsumed) {
replaceInputMethodText(event)
event.consume()
}
Expand Down
Loading