1 /*
2 * Copyright 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.text.input
18
19 import androidx.compose.ui.text.AnnotatedString
20 import androidx.compose.ui.text.findFollowingBreak
21 import androidx.compose.ui.text.findPrecedingBreak
22 import androidx.compose.ui.text.internal.requirePrecondition
23
24 /**
25 * [EditCommand] is a command representation for the platform IME API function calls. The commands
26 * from the IME as function calls are translated into command pattern and used by
27 * [TextInputService.startInput]. For example, as a result of commit text function call by IME
28 * [CommitTextCommand] is created.
29 */
30 interface EditCommand {
31 /** Apply the command on the editing buffer. */
applyTonull32 fun applyTo(buffer: EditingBuffer)
33 }
34
35 /**
36 * Commit final [text] to the text box and set the new cursor position.
37 *
38 * See
39 * [`commitText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#commitText(java.lang.CharSequence,%20int)).
40 *
41 * @param annotatedString The text to commit.
42 * @param newCursorPosition The cursor position after inserted text.
43 */
44 class CommitTextCommand(val annotatedString: AnnotatedString, val newCursorPosition: Int) :
45 EditCommand {
46
47 constructor(
48 /** The text to commit. We ignore any styles in the original API. */
49 text: String,
50 /** The cursor position after setting composing text. */
51 newCursorPosition: Int
52 ) : this(AnnotatedString(text), newCursorPosition)
53
54 val text: String
55 get() = annotatedString.text
56
57 override fun applyTo(buffer: EditingBuffer) {
58 // API description says replace ongoing composition text if there. Then, if there is no
59 // composition text, insert text into cursor position or replace selection.
60 if (buffer.hasComposition()) {
61 buffer.replace(buffer.compositionStart, buffer.compositionEnd, text)
62 } else {
63 // In this editing buffer, insert into cursor or replace selection are equivalent.
64 buffer.replace(buffer.selectionStart, buffer.selectionEnd, text)
65 }
66
67 // After replace function is called, the editing buffer places the cursor at the end of the
68 // modified range.
69 val newCursor = buffer.cursor
70
71 // See above API description for the meaning of newCursorPosition.
72 val newCursorInBuffer =
73 if (newCursorPosition > 0) {
74 newCursor + newCursorPosition - 1
75 } else {
76 newCursor + newCursorPosition - text.length
77 }
78
79 buffer.cursor = newCursorInBuffer.coerceIn(0, buffer.length)
80 }
81
82 override fun equals(other: Any?): Boolean {
83 if (this === other) return true
84 if (other !is CommitTextCommand) return false
85
86 if (text != other.text) return false
87 if (newCursorPosition != other.newCursorPosition) return false
88
89 return true
90 }
91
92 override fun hashCode(): Int {
93 var result = text.hashCode()
94 result = 31 * result + newCursorPosition
95 return result
96 }
97
98 override fun toString(): String {
99 return "CommitTextCommand(text='$text', newCursorPosition=$newCursorPosition)"
100 }
101 }
102
103 /**
104 * Mark a certain region of text as composing text.
105 *
106 * See
107 * [`setComposingRegion`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setComposingRegion(int,%2520int)).
108 *
109 * @param start The inclusive start offset of the composing region.
110 * @param end The exclusive end offset of the composing region
111 */
112 class SetComposingRegionCommand(val start: Int, val end: Int) : EditCommand {
113
applyTonull114 override fun applyTo(buffer: EditingBuffer) {
115 // The API description says, different from SetComposingText, SetComposingRegion must
116 // preserve the ongoing composition text and set new composition.
117 if (buffer.hasComposition()) {
118 buffer.commitComposition()
119 }
120
121 // Sanitize the input: reverse if reversed, clamped into valid range, ignore empty range.
122 val clampedStart = start.coerceIn(0, buffer.length)
123 val clampedEnd = end.coerceIn(0, buffer.length)
124 if (clampedStart == clampedEnd) {
125 // do nothing. empty composition range is not allowed.
126 } else if (clampedStart < clampedEnd) {
127 buffer.setComposition(clampedStart, clampedEnd)
128 } else {
129 buffer.setComposition(clampedEnd, clampedStart)
130 }
131 }
132
equalsnull133 override fun equals(other: Any?): Boolean {
134 if (this === other) return true
135 if (other !is SetComposingRegionCommand) return false
136
137 if (start != other.start) return false
138 if (end != other.end) return false
139
140 return true
141 }
142
hashCodenull143 override fun hashCode(): Int {
144 var result = start
145 result = 31 * result + end
146 return result
147 }
148
toStringnull149 override fun toString(): String {
150 return "SetComposingRegionCommand(start=$start, end=$end)"
151 }
152 }
153
154 /**
155 * Replace the currently composing text with the given text, and set the new cursor position. Any
156 * composing text set previously will be removed automatically.
157 *
158 * See
159 * [`setComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setComposingText(java.lang.CharSequence,%2520int)).
160 *
161 * @param annotatedString The composing text.
162 * @param newCursorPosition The cursor position after setting composing text.
163 */
164 class SetComposingTextCommand(val annotatedString: AnnotatedString, val newCursorPosition: Int) :
165 EditCommand {
166
167 constructor(
168 /** The composing text. */
169 text: String,
170 /** The cursor position after setting composing text. */
171 newCursorPosition: Int
172 ) : this(AnnotatedString(text), newCursorPosition)
173
174 val text: String
175 get() = annotatedString.text
176
applyTonull177 override fun applyTo(buffer: EditingBuffer) {
178 if (buffer.hasComposition()) {
179 // API doc says, if there is ongoing composing text, replace it with new text.
180 val compositionStart = buffer.compositionStart
181 buffer.replace(buffer.compositionStart, buffer.compositionEnd, text)
182 if (text.isNotEmpty()) {
183 buffer.setComposition(compositionStart, compositionStart + text.length)
184 }
185 } else {
186 // If there is no composing text, insert composing text into cursor position with
187 // removing selected text if any.
188 val selectionStart = buffer.selectionStart
189 buffer.replace(buffer.selectionStart, buffer.selectionEnd, text)
190 if (text.isNotEmpty()) {
191 buffer.setComposition(selectionStart, selectionStart + text.length)
192 }
193 }
194
195 // After replace function is called, the editing buffer places the cursor at the end of the
196 // modified range.
197 val newCursor = buffer.cursor
198
199 // See above API description for the meaning of newCursorPosition.
200 val newCursorInBuffer =
201 if (newCursorPosition > 0) {
202 newCursor + newCursorPosition - 1
203 } else {
204 newCursor + newCursorPosition - text.length
205 }
206
207 buffer.cursor = newCursorInBuffer.coerceIn(0, buffer.length)
208 }
209
equalsnull210 override fun equals(other: Any?): Boolean {
211 if (this === other) return true
212 if (other !is SetComposingTextCommand) return false
213
214 if (text != other.text) return false
215 if (newCursorPosition != other.newCursorPosition) return false
216
217 return true
218 }
219
hashCodenull220 override fun hashCode(): Int {
221 var result = text.hashCode()
222 result = 31 * result + newCursorPosition
223 return result
224 }
225
toStringnull226 override fun toString(): String {
227 return "SetComposingTextCommand(text='$text', newCursorPosition=$newCursorPosition)"
228 }
229 }
230
231 /**
232 * Delete [lengthBeforeCursor] characters of text before the current cursor position, and delete
233 * [lengthAfterCursor] characters of text after the current cursor position, excluding the
234 * selection.
235 *
236 * Before and after refer to the order of the characters in the string, not to their visual
237 * representation.
238 *
239 * See
240 * [`deleteSurroundingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#deleteSurroundingText(int,%2520int)).
241 *
242 * @param lengthBeforeCursor The number of characters in UTF-16 before the cursor to be deleted.
243 * Must be non-negative.
244 * @param lengthAfterCursor The number of characters in UTF-16 after the cursor to be deleted. Must
245 * be non-negative.
246 */
247 class DeleteSurroundingTextCommand(val lengthBeforeCursor: Int, val lengthAfterCursor: Int) :
248 EditCommand {
249 init {
<lambda>null250 requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
251 "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " +
252 "$lengthBeforeCursor and $lengthAfterCursor respectively."
253 }
254 }
255
applyTonull256 override fun applyTo(buffer: EditingBuffer) {
257 // calculate the end with safe addition since lengthAfterCursor can be set to e.g. Int.MAX
258 // by the input
259 val end = buffer.selectionEnd.addExactOrElse(lengthAfterCursor) { buffer.length }
260 buffer.delete(buffer.selectionEnd, minOf(end, buffer.length))
261
262 // calculate the start with safe subtraction since lengthBeforeCursor can be set to e.g.
263 // Int.MAX by the input
264 val start = buffer.selectionStart.subtractExactOrElse(lengthBeforeCursor) { 0 }
265 buffer.delete(maxOf(0, start), buffer.selectionStart)
266 }
267
equalsnull268 override fun equals(other: Any?): Boolean {
269 if (this === other) return true
270 if (other !is DeleteSurroundingTextCommand) return false
271
272 if (lengthBeforeCursor != other.lengthBeforeCursor) return false
273 if (lengthAfterCursor != other.lengthAfterCursor) return false
274
275 return true
276 }
277
hashCodenull278 override fun hashCode(): Int {
279 var result = lengthBeforeCursor
280 result = 31 * result + lengthAfterCursor
281 return result
282 }
283
toStringnull284 override fun toString(): String {
285 return "DeleteSurroundingTextCommand(lengthBeforeCursor=$lengthBeforeCursor, " +
286 "lengthAfterCursor=$lengthAfterCursor)"
287 }
288 }
289
290 /**
291 * A variant of [DeleteSurroundingTextCommand]. The difference is that
292 * * The lengths are supplied in code points, not in chars.
293 * * This command does nothing if there are one or more invalid surrogate pairs in the requested
294 * range.
295 *
296 * See
297 * [`deleteSurroundingTextInCodePoints`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#deleteSurroundingTextInCodePoints(int,%2520int)).
298 *
299 * @param lengthBeforeCursor The number of characters in Unicode code points before the cursor to be
300 * deleted. Must be non-negative.
301 * @param lengthAfterCursor The number of characters in Unicode code points after the cursor to be
302 * deleted. Must be non-negative.
303 */
304 class DeleteSurroundingTextInCodePointsCommand(
305 val lengthBeforeCursor: Int,
306 val lengthAfterCursor: Int
307 ) : EditCommand {
308 init {
<lambda>null309 requirePrecondition(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
310 "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " +
311 "$lengthBeforeCursor and $lengthAfterCursor respectively."
312 }
313 }
314
applyTonull315 override fun applyTo(buffer: EditingBuffer) {
316 // Convert code point length into character length. Then call the common logic of the
317 // DeleteSurroundingTextEditOp
318 var beforeLenInChars = 0
319 for (i in 0 until lengthBeforeCursor) {
320 beforeLenInChars++
321 if (buffer.selectionStart > beforeLenInChars) {
322 val lead = buffer[buffer.selectionStart - beforeLenInChars - 1]
323 val trail = buffer[buffer.selectionStart - beforeLenInChars]
324
325 if (isSurrogatePair(lead, trail)) {
326 beforeLenInChars++
327 }
328 } else {
329 // overflowing
330 beforeLenInChars = buffer.selectionStart
331 break
332 }
333 }
334
335 var afterLenInChars = 0
336 for (i in 0 until lengthAfterCursor) {
337 afterLenInChars++
338 if (buffer.selectionEnd + afterLenInChars < buffer.length) {
339 val lead = buffer[buffer.selectionEnd + afterLenInChars - 1]
340 val trail = buffer[buffer.selectionEnd + afterLenInChars]
341
342 if (isSurrogatePair(lead, trail)) {
343 afterLenInChars++
344 }
345 } else {
346 // overflowing
347 afterLenInChars = buffer.length - buffer.selectionEnd
348 break
349 }
350 }
351
352 buffer.delete(buffer.selectionEnd, buffer.selectionEnd + afterLenInChars)
353 buffer.delete(buffer.selectionStart - beforeLenInChars, buffer.selectionStart)
354 }
355
equalsnull356 override fun equals(other: Any?): Boolean {
357 if (this === other) return true
358 if (other !is DeleteSurroundingTextInCodePointsCommand) return false
359
360 if (lengthBeforeCursor != other.lengthBeforeCursor) return false
361 if (lengthAfterCursor != other.lengthAfterCursor) return false
362
363 return true
364 }
365
hashCodenull366 override fun hashCode(): Int {
367 var result = lengthBeforeCursor
368 result = 31 * result + lengthAfterCursor
369 return result
370 }
371
toStringnull372 override fun toString(): String {
373 return "DeleteSurroundingTextInCodePointsCommand(lengthBeforeCursor=$lengthBeforeCursor, " +
374 "lengthAfterCursor=$lengthAfterCursor)"
375 }
376 }
377
378 /**
379 * Sets the selection on the text. When [start] and [end] have the same value, it sets the cursor
380 * position.
381 *
382 * See
383 * [`setSelection`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setSelection(int,%2520int)).
384 *
385 * @param start The inclusive start offset of the selection region.
386 * @param end The exclusive end offset of the selection region.
387 */
388 class SetSelectionCommand(val start: Int, val end: Int) : EditCommand {
389
applyTonull390 override fun applyTo(buffer: EditingBuffer) {
391 // Sanitize the input: reverse if reversed, clamped into valid range.
392 val clampedStart = start.coerceIn(0, buffer.length)
393 val clampedEnd = end.coerceIn(0, buffer.length)
394 if (clampedStart < clampedEnd) {
395 buffer.setSelection(clampedStart, clampedEnd)
396 } else {
397 buffer.setSelection(clampedEnd, clampedStart)
398 }
399 }
400
equalsnull401 override fun equals(other: Any?): Boolean {
402 if (this === other) return true
403 if (other !is SetSelectionCommand) return false
404
405 if (start != other.start) return false
406 if (end != other.end) return false
407
408 return true
409 }
410
hashCodenull411 override fun hashCode(): Int {
412 var result = start
413 result = 31 * result + end
414 return result
415 }
416
toStringnull417 override fun toString(): String {
418 return "SetSelectionCommand(start=$start, end=$end)"
419 }
420 }
421
422 /**
423 * Finishes the composing text that is currently active. This simply leaves the text as-is, removing
424 * any special composing styling or other state that was around it. The cursor position remains
425 * unchanged.
426 *
427 * See
428 * [`finishComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#finishComposingText()).
429 */
430 class FinishComposingTextCommand : EditCommand {
431
applyTonull432 override fun applyTo(buffer: EditingBuffer) {
433 buffer.commitComposition()
434 }
435
equalsnull436 override fun equals(other: Any?): Boolean = other is FinishComposingTextCommand
437
438 override fun hashCode(): Int = this::class.hashCode()
439
440 override fun toString(): String {
441 return "FinishComposingTextCommand()"
442 }
443 }
444
445 /**
446 * Represents a backspace operation at the cursor position.
447 *
448 * If there is composition, delete the text in the composition range. If there is no composition but
449 * there is selection, delete whole selected range. If there is no composition and selection,
450 * perform backspace key event at the cursor position.
451 */
452 class BackspaceCommand : EditCommand {
453
applyTonull454 override fun applyTo(buffer: EditingBuffer) {
455 if (buffer.hasComposition()) {
456 buffer.delete(buffer.compositionStart, buffer.compositionEnd)
457 return
458 }
459
460 if (buffer.cursor == -1) {
461 val delStart = buffer.selectionStart
462 val delEnd = buffer.selectionEnd
463 buffer.cursor = buffer.selectionStart
464 buffer.delete(delStart, delEnd)
465 return
466 }
467
468 if (buffer.cursor == 0) {
469 return
470 }
471
472 val prevCursorPos = buffer.toString().findPrecedingBreak(buffer.cursor)
473 buffer.delete(prevCursorPos, buffer.cursor)
474 }
475
equalsnull476 override fun equals(other: Any?): Boolean = other is BackspaceCommand
477
478 override fun hashCode(): Int = this::class.hashCode()
479
480 override fun toString(): String {
481 return "BackspaceCommand()"
482 }
483 }
484
485 /**
486 * Moves the cursor with [amount] characters.
487 *
488 * If there is selection, cancel the selection first and move the cursor to the selection start
489 * position. Then perform the cursor movement.
490 *
491 * @param amount The amount of cursor movement. If you want to move backward, pass negative value.
492 */
493 class MoveCursorCommand(val amount: Int) : EditCommand {
494
applyTonull495 override fun applyTo(buffer: EditingBuffer) {
496 if (buffer.cursor == -1) {
497 buffer.cursor = buffer.selectionStart
498 }
499
500 var newCursor = buffer.selectionStart
501 val bufferText = buffer.toString()
502 if (amount > 0) {
503 for (i in 0 until amount) {
504 val next = bufferText.findFollowingBreak(newCursor)
505 if (next == -1) break
506 newCursor = next
507 }
508 } else {
509 for (i in 0 until -amount) {
510 val prev = bufferText.findPrecedingBreak(newCursor)
511 if (prev == -1) break
512 newCursor = prev
513 }
514 }
515
516 buffer.cursor = newCursor
517 }
518
equalsnull519 override fun equals(other: Any?): Boolean {
520 if (this === other) return true
521 if (other !is MoveCursorCommand) return false
522
523 if (amount != other.amount) return false
524
525 return true
526 }
527
hashCodenull528 override fun hashCode(): Int {
529 return amount
530 }
531
toStringnull532 override fun toString(): String {
533 return "MoveCursorCommand(amount=$amount)"
534 }
535 }
536
537 /** Deletes all the text in the buffer. */
538 class DeleteAllCommand : EditCommand {
applyTonull539 override fun applyTo(buffer: EditingBuffer) {
540 buffer.replace(0, buffer.length, "")
541 }
542
equalsnull543 override fun equals(other: Any?): Boolean = other is DeleteAllCommand
544
545 override fun hashCode(): Int = this::class.hashCode()
546
547 override fun toString(): String {
548 return "DeleteAllCommand()"
549 }
550 }
551
552 /**
553 * Helper function that returns true when [high] is a Unicode high-surrogate code unit and [low] is
554 * a Unicode low-surrogate code unit.
555 */
isSurrogatePairnull556 private fun isSurrogatePair(high: Char, low: Char): Boolean =
557 high.isHighSurrogate() && low.isLowSurrogate()
558