| /* |
| * 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. |
| */ |
| |
| package androidx.compose.ui.text.input |
| |
| import androidx.compose.ui.text.AnnotatedString |
| import androidx.compose.ui.text.findFollowingBreak |
| import androidx.compose.ui.text.findPrecedingBreak |
| |
| /** |
| * [EditCommand] is a command representation for the platform IME API function calls. The commands |
| * from the IME as function calls are translated into command pattern and used by |
| * [TextInputService.startInput]. For example, as a result of commit text function call by IME |
| * [CommitTextCommand] is created. |
| */ |
| interface EditCommand { |
| /** Apply the command on the editing buffer. */ |
| fun applyTo(buffer: EditingBuffer) |
| } |
| |
| /** |
| * Commit final [text] to the text box and set the new cursor position. |
| * |
| * See |
| * [`commitText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#commitText(java.lang.CharSequence,%20int)). |
| * |
| * @param annotatedString The text to commit. |
| * @param newCursorPosition The cursor position after inserted text. |
| */ |
| class CommitTextCommand(val annotatedString: AnnotatedString, val newCursorPosition: Int) : |
| EditCommand { |
| |
| constructor( |
| /** The text to commit. We ignore any styles in the original API. */ |
| text: String, |
| /** The cursor position after setting composing text. */ |
| newCursorPosition: Int |
| ) : this(AnnotatedString(text), newCursorPosition) |
| |
| val text: String |
| get() = annotatedString.text |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| // API description says replace ongoing composition text if there. Then, if there is no |
| // composition text, insert text into cursor position or replace selection. |
| if (buffer.hasComposition()) { |
| buffer.replace(buffer.compositionStart, buffer.compositionEnd, text) |
| } else { |
| // In this editing buffer, insert into cursor or replace selection are equivalent. |
| buffer.replace(buffer.selectionStart, buffer.selectionEnd, text) |
| } |
| |
| // After replace function is called, the editing buffer places the cursor at the end of the |
| // modified range. |
| val newCursor = buffer.cursor |
| |
| // See above API description for the meaning of newCursorPosition. |
| val newCursorInBuffer = |
| if (newCursorPosition > 0) { |
| newCursor + newCursorPosition - 1 |
| } else { |
| newCursor + newCursorPosition - text.length |
| } |
| |
| buffer.cursor = newCursorInBuffer.coerceIn(0, buffer.length) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is CommitTextCommand) return false |
| |
| if (text != other.text) return false |
| if (newCursorPosition != other.newCursorPosition) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = text.hashCode() |
| result = 31 * result + newCursorPosition |
| return result |
| } |
| |
| override fun toString(): String { |
| return "CommitTextCommand(text='$text', newCursorPosition=$newCursorPosition)" |
| } |
| } |
| |
| /** |
| * Mark a certain region of text as composing text. |
| * |
| * See |
| * [`setComposingRegion`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setComposingRegion(int,%2520int)). |
| * |
| * @param start The inclusive start offset of the composing region. |
| * @param end The exclusive end offset of the composing region |
| */ |
| class SetComposingRegionCommand(val start: Int, val end: Int) : EditCommand { |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| // The API description says, different from SetComposingText, SetComposingRegion must |
| // preserve the ongoing composition text and set new composition. |
| if (buffer.hasComposition()) { |
| buffer.commitComposition() |
| } |
| |
| // Sanitize the input: reverse if reversed, clamped into valid range, ignore empty range. |
| val clampedStart = start.coerceIn(0, buffer.length) |
| val clampedEnd = end.coerceIn(0, buffer.length) |
| if (clampedStart == clampedEnd) { |
| // do nothing. empty composition range is not allowed. |
| } else if (clampedStart < clampedEnd) { |
| buffer.setComposition(clampedStart, clampedEnd) |
| } else { |
| buffer.setComposition(clampedEnd, clampedStart) |
| } |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is SetComposingRegionCommand) return false |
| |
| if (start != other.start) return false |
| if (end != other.end) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = start |
| result = 31 * result + end |
| return result |
| } |
| |
| override fun toString(): String { |
| return "SetComposingRegionCommand(start=$start, end=$end)" |
| } |
| } |
| |
| /** |
| * Replace the currently composing text with the given text, and set the new cursor position. Any |
| * composing text set previously will be removed automatically. |
| * |
| * See |
| * [`setComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setComposingText(java.lang.CharSequence,%2520int)). |
| * |
| * @param annotatedString The composing text. |
| * @param newCursorPosition The cursor position after setting composing text. |
| */ |
| class SetComposingTextCommand(val annotatedString: AnnotatedString, val newCursorPosition: Int) : |
| EditCommand { |
| |
| constructor( |
| /** The composing text. */ |
| text: String, |
| /** The cursor position after setting composing text. */ |
| newCursorPosition: Int |
| ) : this(AnnotatedString(text), newCursorPosition) |
| |
| val text: String |
| get() = annotatedString.text |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| if (buffer.hasComposition()) { |
| // API doc says, if there is ongoing composing text, replace it with new text. |
| val compositionStart = buffer.compositionStart |
| buffer.replace(buffer.compositionStart, buffer.compositionEnd, text) |
| if (text.isNotEmpty()) { |
| buffer.setComposition(compositionStart, compositionStart + text.length) |
| } |
| } else { |
| // If there is no composing text, insert composing text into cursor position with |
| // removing selected text if any. |
| val selectionStart = buffer.selectionStart |
| buffer.replace(buffer.selectionStart, buffer.selectionEnd, text) |
| if (text.isNotEmpty()) { |
| buffer.setComposition(selectionStart, selectionStart + 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 above API description for the meaning of newCursorPosition. |
| val newCursorInBuffer = |
| if (newCursorPosition > 0) { |
| newCursor + newCursorPosition - 1 |
| } else { |
| newCursor + newCursorPosition - text.length |
| } |
| |
| buffer.cursor = newCursorInBuffer.coerceIn(0, buffer.length) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is SetComposingTextCommand) return false |
| |
| if (text != other.text) return false |
| if (newCursorPosition != other.newCursorPosition) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = text.hashCode() |
| result = 31 * result + newCursorPosition |
| return result |
| } |
| |
| override fun toString(): String { |
| return "SetComposingTextCommand(text='$text', newCursorPosition=$newCursorPosition)" |
| } |
| } |
| |
| /** |
| * Delete [lengthBeforeCursor] characters of text before the current cursor position, and delete |
| * [lengthAfterCursor] characters of text after the current cursor position, excluding the |
| * selection. |
| * |
| * Before and after refer to the order of the characters in the string, not to their visual |
| * representation. |
| * |
| * See |
| * [`deleteSurroundingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#deleteSurroundingText(int,%2520int)). |
| * |
| * @param lengthBeforeCursor The number of characters in UTF-16 before the cursor to be deleted. |
| * Must be non-negative. |
| * @param lengthAfterCursor The number of characters in UTF-16 after the cursor to be deleted. Must |
| * be non-negative. |
| */ |
| class DeleteSurroundingTextCommand(val lengthBeforeCursor: Int, val lengthAfterCursor: Int) : |
| EditCommand { |
| init { |
| require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { |
| "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " + |
| "$lengthBeforeCursor and $lengthAfterCursor respectively." |
| } |
| } |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| // calculate the end with safe addition since lengthAfterCursor can be set to e.g. Int.MAX |
| // by the input |
| val end = buffer.selectionEnd.addExactOrElse(lengthAfterCursor) { buffer.length } |
| buffer.delete(buffer.selectionEnd, minOf(end, buffer.length)) |
| |
| // calculate the start with safe subtraction since lengthBeforeCursor can be set to e.g. |
| // Int.MAX by the input |
| val start = buffer.selectionStart.subtractExactOrElse(lengthBeforeCursor) { 0 } |
| buffer.delete(maxOf(0, start), buffer.selectionStart) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is DeleteSurroundingTextCommand) return false |
| |
| if (lengthBeforeCursor != other.lengthBeforeCursor) return false |
| if (lengthAfterCursor != other.lengthAfterCursor) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = lengthBeforeCursor |
| result = 31 * result + lengthAfterCursor |
| return result |
| } |
| |
| override fun toString(): String { |
| return "DeleteSurroundingTextCommand(lengthBeforeCursor=$lengthBeforeCursor, " + |
| "lengthAfterCursor=$lengthAfterCursor)" |
| } |
| } |
| |
| /** |
| * A variant of [DeleteSurroundingTextCommand]. The difference is that |
| * * The lengths are supplied in code points, not in chars. |
| * * This command does nothing if there are one or more invalid surrogate pairs in the requested |
| * range. |
| * |
| * See |
| * [`deleteSurroundingTextInCodePoints`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#deleteSurroundingTextInCodePoints(int,%2520int)). |
| * |
| * @param lengthBeforeCursor The number of characters in Unicode code points before the cursor to be |
| * deleted. Must be non-negative. |
| * @param lengthAfterCursor The number of characters in Unicode code points after the cursor to be |
| * deleted. Must be non-negative. |
| */ |
| class DeleteSurroundingTextInCodePointsCommand( |
| val lengthBeforeCursor: Int, |
| val lengthAfterCursor: Int |
| ) : EditCommand { |
| init { |
| require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) { |
| "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " + |
| "$lengthBeforeCursor and $lengthAfterCursor respectively." |
| } |
| } |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| // Convert code point length into character length. Then call the common logic of the |
| // DeleteSurroundingTextEditOp |
| var beforeLenInChars = 0 |
| for (i in 0 until lengthBeforeCursor) { |
| beforeLenInChars++ |
| if (buffer.selectionStart > beforeLenInChars) { |
| val lead = buffer[buffer.selectionStart - beforeLenInChars - 1] |
| val trail = buffer[buffer.selectionStart - beforeLenInChars] |
| |
| if (isSurrogatePair(lead, trail)) { |
| beforeLenInChars++ |
| } |
| } else { |
| // overflowing |
| beforeLenInChars = buffer.selectionStart |
| break |
| } |
| } |
| |
| var afterLenInChars = 0 |
| for (i in 0 until lengthAfterCursor) { |
| afterLenInChars++ |
| if (buffer.selectionEnd + afterLenInChars < buffer.length) { |
| val lead = buffer[buffer.selectionEnd + afterLenInChars - 1] |
| val trail = buffer[buffer.selectionEnd + afterLenInChars] |
| |
| if (isSurrogatePair(lead, trail)) { |
| afterLenInChars++ |
| } |
| } else { |
| // overflowing |
| afterLenInChars = buffer.length - buffer.selectionEnd |
| break |
| } |
| } |
| |
| buffer.delete(buffer.selectionEnd, buffer.selectionEnd + afterLenInChars) |
| buffer.delete(buffer.selectionStart - beforeLenInChars, buffer.selectionStart) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is DeleteSurroundingTextInCodePointsCommand) return false |
| |
| if (lengthBeforeCursor != other.lengthBeforeCursor) return false |
| if (lengthAfterCursor != other.lengthAfterCursor) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = lengthBeforeCursor |
| result = 31 * result + lengthAfterCursor |
| return result |
| } |
| |
| override fun toString(): String { |
| return "DeleteSurroundingTextInCodePointsCommand(lengthBeforeCursor=$lengthBeforeCursor, " + |
| "lengthAfterCursor=$lengthAfterCursor)" |
| } |
| } |
| |
| /** |
| * Sets the selection on the text. When [start] and [end] have the same value, it sets the cursor |
| * position. |
| * |
| * See |
| * [`setSelection`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setSelection(int,%2520int)). |
| * |
| * @param start The inclusive start offset of the selection region. |
| * @param end The exclusive end offset of the selection region. |
| */ |
| class SetSelectionCommand(val start: Int, val end: Int) : EditCommand { |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| // Sanitize the input: reverse if reversed, clamped into valid range. |
| val clampedStart = start.coerceIn(0, buffer.length) |
| val clampedEnd = end.coerceIn(0, buffer.length) |
| if (clampedStart < clampedEnd) { |
| buffer.setSelection(clampedStart, clampedEnd) |
| } else { |
| buffer.setSelection(clampedEnd, clampedStart) |
| } |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is SetSelectionCommand) return false |
| |
| if (start != other.start) return false |
| if (end != other.end) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| var result = start |
| result = 31 * result + end |
| return result |
| } |
| |
| override fun toString(): String { |
| return "SetSelectionCommand(start=$start, end=$end)" |
| } |
| } |
| |
| /** |
| * Finishes the composing text that is currently active. This simply leaves the text as-is, removing |
| * any special composing styling or other state that was around it. The cursor position remains |
| * unchanged. |
| * |
| * See |
| * [`finishComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#finishComposingText()). |
| */ |
| class FinishComposingTextCommand : EditCommand { |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| buffer.commitComposition() |
| } |
| |
| override fun equals(other: Any?): Boolean = other is FinishComposingTextCommand |
| |
| override fun hashCode(): Int = this::class.hashCode() |
| |
| override fun toString(): String { |
| return "FinishComposingTextCommand()" |
| } |
| } |
| |
| /** |
| * Represents a backspace operation at the cursor position. |
| * |
| * If there is composition, delete the text in the composition range. If there is no composition but |
| * there is selection, delete whole selected range. If there is no composition and selection, |
| * perform backspace key event at the cursor position. |
| */ |
| class BackspaceCommand : EditCommand { |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| if (buffer.hasComposition()) { |
| buffer.delete(buffer.compositionStart, buffer.compositionEnd) |
| return |
| } |
| |
| if (buffer.cursor == -1) { |
| val delStart = buffer.selectionStart |
| val delEnd = buffer.selectionEnd |
| buffer.cursor = buffer.selectionStart |
| buffer.delete(delStart, delEnd) |
| return |
| } |
| |
| if (buffer.cursor == 0) { |
| return |
| } |
| |
| val prevCursorPos = buffer.toString().findPrecedingBreak(buffer.cursor) |
| buffer.delete(prevCursorPos, buffer.cursor) |
| } |
| |
| override fun equals(other: Any?): Boolean = other is BackspaceCommand |
| |
| override fun hashCode(): Int = this::class.hashCode() |
| |
| override fun toString(): String { |
| return "BackspaceCommand()" |
| } |
| } |
| |
| /** |
| * Moves the cursor with [amount] characters. |
| * |
| * If there is selection, cancel the selection first and move the cursor to the selection start |
| * position. Then perform the cursor movement. |
| * |
| * @param amount The amount of cursor movement. If you want to move backward, pass negative value. |
| */ |
| class MoveCursorCommand(val amount: Int) : EditCommand { |
| |
| override fun applyTo(buffer: EditingBuffer) { |
| if (buffer.cursor == -1) { |
| buffer.cursor = buffer.selectionStart |
| } |
| |
| var newCursor = buffer.selectionStart |
| val bufferText = buffer.toString() |
| if (amount > 0) { |
| for (i in 0 until amount) { |
| val next = bufferText.findFollowingBreak(newCursor) |
| if (next == -1) break |
| newCursor = next |
| } |
| } else { |
| for (i in 0 until -amount) { |
| val prev = bufferText.findPrecedingBreak(newCursor) |
| if (prev == -1) break |
| newCursor = prev |
| } |
| } |
| |
| buffer.cursor = newCursor |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is MoveCursorCommand) return false |
| |
| if (amount != other.amount) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| return amount |
| } |
| |
| override fun toString(): String { |
| return "MoveCursorCommand(amount=$amount)" |
| } |
| } |
| |
| /** Deletes all the text in the buffer. */ |
| class DeleteAllCommand : EditCommand { |
| override fun applyTo(buffer: EditingBuffer) { |
| buffer.replace(0, buffer.length, "") |
| } |
| |
| override fun equals(other: Any?): Boolean = other is DeleteAllCommand |
| |
| override fun hashCode(): Int = this::class.hashCode() |
| |
| override fun toString(): String { |
| return "DeleteAllCommand()" |
| } |
| } |
| |
| /** |
| * 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() |