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.InternalTextApi
21 import androidx.compose.ui.text.TextRange
22 import androidx.compose.ui.text.internal.requirePrecondition
23 
24 /**
25  * The editing buffer
26  *
27  * This class manages the all editing relate states, editing buffers, selection, styles, etc.
28  */
29 @OptIn(InternalTextApi::class)
30 class EditingBuffer(
31     /** The initial text of this editing buffer */
32     text: AnnotatedString,
33     /**
34      * The initial selection range of this buffer. If you provide collapsed selection, it is treated
35      * as the cursor position. The cursor and selection cannot exists at the same time. The
36      * selection must points the valid index of the initialText, otherwise IndexOutOfBoundsException
37      * will be thrown.
38      */
39     selection: TextRange
40 ) {
41     internal companion object {
42         internal const val NOWHERE = -1
43     }
44 
45     private val gapBuffer = PartialGapBuffer(text.text)
46 
47     /** The inclusive selection start offset */
48     internal var selectionStart = selection.min
49         private set(value) {
<lambda>null50             requirePrecondition(value >= 0) {
51                 "Cannot set selectionStart to a negative value: $value"
52             }
53             field = value
54         }
55 
56     /** The exclusive selection end offset */
57     internal var selectionEnd = selection.max
58         private set(value) {
<lambda>null59             requirePrecondition(value >= 0) {
60                 "Cannot set selectionEnd to a negative value: $value"
61             }
62             field = value
63         }
64 
65     /**
66      * The inclusive composition start offset
67      *
68      * If there is no composing text, returns -1
69      */
70     internal var compositionStart = NOWHERE
71         private set
72 
73     /**
74      * The exclusive composition end offset
75      *
76      * If there is no composing text, returns -1
77      */
78     internal var compositionEnd = NOWHERE
79         private set
80 
81     /** Helper function that returns true if the editing buffer has composition text */
hasCompositionnull82     internal fun hasComposition(): Boolean = compositionStart != NOWHERE
83 
84     /** Returns the composition information as TextRange. Returns null if no composition is set. */
85     internal val composition: TextRange?
86         get() =
87             if (hasComposition()) {
88                 TextRange(compositionStart, compositionEnd)
89             } else null
90 
91     /** Returns the selection information as TextRange */
92     internal val selection: TextRange
93         get() = TextRange(selectionStart, selectionEnd)
94 
95     /** Helper accessor for cursor offset */
96     /*VisibleForTesting*/
97     internal var cursor: Int
98         /**
99          * Return the cursor offset.
100          *
101          * Since selection and cursor cannot exist at the same time, return -1 if there is a
102          * selection.
103          */
104         get() = if (selectionStart == selectionEnd) selectionEnd else -1
105         /**
106          * Set the cursor offset.
107          *
108          * Since selection and cursor cannot exist at the same time, cancel selection if there is.
109          */
110         set(cursor) = setSelection(cursor, cursor)
111 
112     /** [] operator for the character at the index. */
getnull113     internal operator fun get(index: Int): Char = gapBuffer[index]
114 
115     /** Returns the length of the buffer. */
116     internal val length: Int
117         get() = gapBuffer.length
118 
119     internal constructor(
120         text: String,
121         selection: TextRange
122     ) : this(AnnotatedString(text), selection)
123 
124     init {
125         val start = selection.min
126         val end = selection.max
127         if (start < 0 || start > text.length) {
128             throw IndexOutOfBoundsException(
129                 "start ($start) offset is outside of text region ${text.length}"
130             )
131         }
132 
133         if (end < 0 || end > text.length) {
134             throw IndexOutOfBoundsException(
135                 "end ($end) offset is outside of text region ${text.length}"
136             )
137         }
138 
139         if (start > end) {
140             throw IllegalArgumentException("Do not set reversed range: $start > $end")
141         }
142     }
143 
replacenull144     internal fun replace(start: Int, end: Int, text: AnnotatedString) {
145         replace(start, end, text.text)
146     }
147 
148     /**
149      * Replace the text and move the cursor to the end of inserted text.
150      *
151      * This function cancels selection if there.
152      *
153      * @throws IndexOutOfBoundsException if start or end offset is outside of current buffer
154      * @throws IllegalArgumentException if start is larger than end. (reversed range)
155      */
replacenull156     internal fun replace(start: Int, end: Int, text: String) {
157 
158         if (start < 0 || start > gapBuffer.length) {
159             throw IndexOutOfBoundsException(
160                 "start ($start) offset is outside of text region ${gapBuffer.length}"
161             )
162         }
163 
164         if (end < 0 || end > gapBuffer.length) {
165             throw IndexOutOfBoundsException(
166                 "end ($end) offset is outside of text region ${gapBuffer.length}"
167             )
168         }
169 
170         if (start > end) {
171             throw IllegalArgumentException("Do not set reversed range: $start > $end")
172         }
173 
174         gapBuffer.replace(start, end, text)
175 
176         // On Android, all text modification APIs also provides explicit cursor location. On the
177         // hand, desktop application usually doesn't. So, here tentatively move the cursor to the
178         // end offset of the editing area for desktop like application. In case of Android,
179         // implementation will call setSelection immediately after replace function to update this
180         // tentative cursor location.
181         selectionStart = start + text.length
182         selectionEnd = start + text.length
183 
184         // Similarly, if text modification happens, cancel ongoing composition. If caller want to
185         // change the composition text, it is caller responsibility to call setComposition again
186         // to set composition range after replace function.
187         compositionStart = NOWHERE
188         compositionEnd = NOWHERE
189     }
190 
191     /**
192      * Remove the given range of text.
193      *
194      * Different from replace method, this doesn't move cursor location to the end of modified text.
195      * Instead, preserve the selection with adjusting the deleted text.
196      */
deletenull197     internal fun delete(start: Int, end: Int) {
198         val deleteRange = TextRange(start, end)
199 
200         gapBuffer.replace(start, end, "")
201 
202         val newSelection =
203             updateRangeAfterDelete(TextRange(selectionStart, selectionEnd), deleteRange)
204         selectionStart = newSelection.min
205         selectionEnd = newSelection.max
206 
207         if (hasComposition()) {
208             val compositionRange = TextRange(compositionStart, compositionEnd)
209             val newComposition = updateRangeAfterDelete(compositionRange, deleteRange)
210             if (newComposition.collapsed) {
211                 commitComposition()
212             } else {
213                 compositionStart = newComposition.min
214                 compositionEnd = newComposition.max
215             }
216         }
217     }
218 
219     /**
220      * Mark the specified area of the text as selected text.
221      *
222      * You can set cursor by specifying the same value to `start` and `end`. The reversed range is
223      * not allowed.
224      *
225      * @param start the inclusive start offset of the selection
226      * @param end the exclusive end offset of the selection
227      * @throws IndexOutOfBoundsException if start or end offset is outside of current buffer.
228      * @throws IllegalArgumentException if start is larger than end. (reversed range)
229      */
setSelectionnull230     internal fun setSelection(start: Int, end: Int) {
231         if (start < 0 || start > gapBuffer.length) {
232             throw IndexOutOfBoundsException(
233                 "start ($start) offset is outside of text region ${gapBuffer.length}"
234             )
235         }
236         if (end < 0 || end > gapBuffer.length) {
237             throw IndexOutOfBoundsException(
238                 "end ($end) offset is outside of text region ${gapBuffer.length}"
239             )
240         }
241         if (start > end) {
242             throw IllegalArgumentException("Do not set reversed range: $start > $end")
243         }
244 
245         selectionStart = start
246         selectionEnd = end
247     }
248 
249     /**
250      * Mark the specified area of the text as composition text.
251      *
252      * The empty range or reversed range is not allowed. Use clearComposition in case of clearing
253      * composition.
254      *
255      * @param start the inclusive start offset of the composition
256      * @param end the exclusive end offset of the composition
257      * @throws IndexOutOfBoundsException if start or end offset is ouside of current buffer
258      * @throws IllegalArgumentException if start is larger than or equal to end. (reversed or
259      *   collapsed range)
260      */
setCompositionnull261     internal fun setComposition(start: Int, end: Int) {
262         if (start < 0 || start > gapBuffer.length) {
263             throw IndexOutOfBoundsException(
264                 "start ($start) offset is outside of text region ${gapBuffer.length}"
265             )
266         }
267         if (end < 0 || end > gapBuffer.length) {
268             throw IndexOutOfBoundsException(
269                 "end ($end) offset is outside of text region ${gapBuffer.length}"
270             )
271         }
272         if (start >= end) {
273             throw IllegalArgumentException("Do not set reversed or empty range: $start > $end")
274         }
275 
276         compositionStart = start
277         compositionEnd = end
278     }
279 
280     /** Removes the ongoing composition text and reset the composition range. */
cancelCompositionnull281     internal fun cancelComposition() {
282         replace(compositionStart, compositionEnd, "")
283         compositionStart = NOWHERE
284         compositionEnd = NOWHERE
285     }
286 
287     /** Commits the ongoing composition text and reset the composition range. */
commitCompositionnull288     internal fun commitComposition() {
289         compositionStart = NOWHERE
290         compositionEnd = NOWHERE
291     }
292 
toStringnull293     override fun toString(): String = gapBuffer.toString()
294 
295     internal fun toAnnotatedString(): AnnotatedString = AnnotatedString(toString())
296 }
297 
298 /**
299  * Returns the updated TextRange for [target] after the [deleted] TextRange is deleted as a Pair.
300  *
301  * If the [deleted] Range covers the whole target, Pair(-1,-1) is returned.
302  */
303 /*@VisibleForTesting*/
304 internal fun updateRangeAfterDelete(target: TextRange, deleted: TextRange): TextRange {
305     var targetMin = target.min
306     var targetMax = target.max
307 
308     // Following figure shows the deletion range and composition range.
309     // |---| represents deleted range.
310     // |===| represents target range.
311     if (deleted.intersects(target)) {
312         if (deleted.contains(target)) {
313             // Input:
314             //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
315             //   Deleted    :      |-------------|
316             //   Target     :          |======|
317             //
318             // Result:
319             //   Buffer     : ABCDETUVWXYZ
320             //   Target     :
321             targetMin = deleted.min
322             targetMax = targetMin
323         } else if (target.contains(deleted)) {
324             // Input:
325             //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
326             //   Deleted    :          |------|
327             //   Target     :        |==========|
328             //
329             // Result:
330             //   Buffer     : ABCDEFGHIQRSTUVWXYZ
331             //   Target     :        |===|
332             targetMax -= deleted.length
333         } else if (deleted.contains(targetMin)) {
334             // Input:
335             //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
336             //   Deleted    :      |---------|
337             //   Target     :            |========|
338             //
339             // Result:
340             //   Buffer     : ABCDEFPQRSTUVWXYZ
341             //   Target     :       |=====|
342             targetMin = deleted.min
343             targetMax -= deleted.length
344         } else { // deleteRange contains myMax
345             // Input:
346             //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
347             //   Deleted    :         |---------|
348             //   Target     :    |=======|
349             //
350             // Result:
351             //   Buffer     : ABCDEFGHSTUVWXYZ
352             //   Target     :    |====|
353             targetMax = deleted.min
354         }
355     } else {
356         if (targetMax <= deleted.min) {
357             // Input:
358             //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
359             //   Deleted    :            |-------|
360             //   Target     :  |=======|
361             //
362             // Result:
363             //   Buffer     : ABCDEFGHIJKLTUVWXYZ
364             //   Target     :  |=======|
365             // do nothing
366         } else {
367             // Input:
368             //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
369             //   Deleted    :  |-------|
370             //   Target     :            |=======|
371             //
372             // Result:
373             //   Buffer     : AJKLMNOPQRSTUVWXYZ
374             //   Target     :    |=======|
375             targetMin -= deleted.length
376             targetMax -= deleted.length
377         }
378     }
379 
380     return TextRange(targetMin, targetMax)
381 }
382