blob: 5d0747ce0e6b597d7c89617008b3ab029d70a132 [file] [log] [blame]
/*
* Copyright 2019 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.
*/
@file:Suppress("DEPRECATION")
package androidx.compose.ui.text.input
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.emptyAnnotatedString
import androidx.compose.ui.util.fastForEach
/**
* Helper class to apply [EditCommand]s on an internal buffer. Used by TextField Composable
* to combine TextFieldValue lifecycle with the editing operations.
*
* * When a [TextFieldValue] is suggested by the developer, [reset] should be called.
* * When [TextInputService] provides [EditCommand]s, they should be applied to the internal
* buffer using [apply].
*/
class EditProcessor {
/**
* The current state of the internal editing buffer as a [TextFieldValue].
*/
/*@VisibleForTesting*/
internal var mBufferState: TextFieldValue = TextFieldValue(
emptyAnnotatedString(),
TextRange.Zero,
null
)
private set
// The editing buffer used for applying editor commands from IME.
/*@VisibleForTesting*/
internal var mBuffer: EditingBuffer = EditingBuffer(
text = mBufferState.annotatedString,
selection = mBufferState.selection
)
private set
/**
* Must be called whenever new editor model arrives.
*
* This method updates the internal editing buffer with the given editor model.
* This method may tell the IME about the selection offset changes or extracted text changes.
*/
@Suppress("ReferencesDeprecated")
fun reset(
value: TextFieldValue,
textInputSession: TextInputSession?,
) {
var textChanged = false
var selectionChanged = false
val compositionChanged = value.composition != mBuffer.composition
if (mBufferState.annotatedString != value.annotatedString) {
mBuffer = EditingBuffer(
text = value.annotatedString,
selection = value.selection
)
textChanged = true
} else if (mBufferState.selection != value.selection) {
mBuffer.setSelection(value.selection.min, value.selection.max)
selectionChanged = true
}
if (value.composition == null) {
mBuffer.commitComposition()
} else if (!value.composition.collapsed) {
mBuffer.setComposition(value.composition.min, value.composition.max)
}
// this is the same code as in TextInputServiceAndroid class where restartInput is decided
// if restartInput is going to be called the composition has to be cleared otherwise it
// results in keyboards behaving strangely.
val newValue = if (textChanged || (!selectionChanged && compositionChanged)) {
mBuffer.commitComposition()
value.copy(composition = null)
} else {
value
}
val oldValue = mBufferState
mBufferState = newValue
textInputSession?.updateState(oldValue, newValue)
}
/**
* Applies a set of [editCommands] to the internal text editing buffer.
*
* After applying the changes, returns the final state of the editing buffer as a
* [TextFieldValue]
*
* @param editCommands [EditCommand]s to be applied to the editing buffer.
*
* @return the [TextFieldValue] representation of the final buffer state.
*/
fun apply(editCommands: List<EditCommand>): TextFieldValue {
var lastCommand: EditCommand? = null
try {
editCommands.fastForEach {
lastCommand = it
it.applyTo(mBuffer)
}
} catch (e: Exception) {
throw RuntimeException(generateBatchErrorMessage(editCommands, lastCommand), e)
}
val newState = TextFieldValue(
annotatedString = mBuffer.toAnnotatedString(),
// preserve original reversed selection when creating new state.
// otherwise the text range may flicker to un-reversed for a frame,
// which can cause haptics and handles to be crossed.
selection = mBuffer.selection.run {
takeUnless { mBufferState.selection.reversed } ?: TextRange(max, min)
},
composition = mBuffer.composition
)
mBufferState = newState
return newState
}
/**
* Returns the current state of the internal editing buffer as a [TextFieldValue].
*/
fun toTextFieldValue(): TextFieldValue = mBufferState
private fun generateBatchErrorMessage(
editCommands: List<EditCommand>,
failedCommand: EditCommand?,
): String = buildString {
appendLine(
"Error while applying EditCommand batch to buffer (" +
"length=${mBuffer.length}, " +
"composition=${mBuffer.composition}, " +
"selection=${mBuffer.selection}):"
)
@Suppress("ListIterator")
editCommands.joinTo(this, separator = "\n") {
val prefix = if (failedCommand === it) " > " else " "
prefix + it.toStringForLog()
}
}
/**
* Generate a description of the command that is suitable for logging – this should not include
* any user-entered text, which may be sensitive.
*/
private fun EditCommand.toStringForLog(): String = when (this) {
is CommitTextCommand ->
"CommitTextCommand(text.length=${text.length}, newCursorPosition=$newCursorPosition)"
is SetComposingTextCommand ->
"SetComposingTextCommand(text.length=${text.length}, " +
"newCursorPosition=$newCursorPosition)"
is SetComposingRegionCommand -> toString()
is DeleteSurroundingTextCommand -> toString()
is DeleteSurroundingTextInCodePointsCommand -> toString()
is SetSelectionCommand -> toString()
is FinishComposingTextCommand -> toString()
is BackspaceCommand -> toString()
is MoveCursorCommand -> toString()
is DeleteAllCommand -> toString()
// Do not return toString() by default, since that might contain sensitive text.
else -> "Unknown EditCommand: " + (this::class.simpleName ?: "{anonymous EditCommand}")
}
}