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