Skip to content

Commit

Permalink
[WIP] PlatformTextInputService2
Browse files Browse the repository at this point in the history
  • Loading branch information
m-sasha committed Feb 18, 2025
1 parent 3d74b8e commit d303828
Show file tree
Hide file tree
Showing 10 changed files with 520 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ 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.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
Expand All @@ -30,19 +32,23 @@ 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 androidx.compose.ui.unit.IntSize
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 @@ -77,10 +83,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())
}
}

Expand All @@ -101,13 +116,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 @@ -116,15 +134,154 @@ 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

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

}

/**
* 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 {
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

/**
Expand Down
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 {

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 {

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

0 comments on commit d303828

Please sign in to comment.