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