blob: af7438ba636be78297cbc32bc2addb28e0be151f [file] [log] [blame]
/*
* 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.InternalTextApi
import androidx.compose.ui.text.TextRange
/**
* The editing buffer
*
* This class manages the all editing relate states, editing buffers, selection, styles, etc.
*/
@OptIn(InternalTextApi::class)
class EditingBuffer(
/**
* The initial text of this editing buffer
*/
text: AnnotatedString,
/**
* The initial selection range of this buffer.
* If you provide collapsed selection, it is treated as the cursor position. The cursor and
* selection cannot exists at the same time.
* The selection must points the valid index of the initialText, otherwise
* IndexOutOfBoundsException will be thrown.
*/
selection: TextRange
) {
internal companion object {
internal const val NOWHERE = -1
}
private val gapBuffer = PartialGapBuffer(text.text)
/**
* The inclusive selection start offset
*/
internal var selectionStart = selection.min
private set(value) {
require(value >= 0) { "Cannot set selectionStart to a negative value: $value" }
field = value
}
/**
* The exclusive selection end offset
*/
internal var selectionEnd = selection.max
private set(value) {
require(value >= 0) { "Cannot set selectionEnd to a negative value: $value" }
field = value
}
/**
* The inclusive composition start offset
*
* If there is no composing text, returns -1
*/
internal var compositionStart = NOWHERE
private set
/**
* The exclusive composition end offset
*
* If there is no composing text, returns -1
*/
internal var compositionEnd = NOWHERE
private set
/**
* Helper function that returns true if the editing buffer has composition text
*/
internal fun hasComposition(): Boolean = compositionStart != NOWHERE
/**
* Returns the composition information as TextRange. Returns null if no
* composition is set.
*/
internal val composition: TextRange?
get() = if (hasComposition()) {
TextRange(compositionStart, compositionEnd)
} else null
/**
* Returns the selection information as TextRange
*/
internal val selection: TextRange
get() = TextRange(selectionStart, selectionEnd)
/**
* Helper accessor for cursor offset
*/
/*VisibleForTesting*/
internal var cursor: Int
/**
* Return the cursor offset.
*
* Since selection and cursor cannot exist at the same time, return -1 if there is a
* selection.
*/
get() = if (selectionStart == selectionEnd) selectionEnd else -1
/**
* Set the cursor offset.
*
* Since selection and cursor cannot exist at the same time, cancel selection if there is.
*/
set(cursor) = setSelection(cursor, cursor)
/**
* [] operator for the character at the index.
*/
internal operator fun get(index: Int): Char = gapBuffer[index]
/**
* Returns the length of the buffer.
*/
internal val length: Int get() = gapBuffer.length
internal constructor(
text: String,
selection: TextRange
) : this(AnnotatedString(text), selection)
init {
val start = selection.min
val end = selection.max
if (start < 0 || start > text.length) {
throw IndexOutOfBoundsException(
"start ($start) offset is outside of text region ${text.length}"
)
}
if (end < 0 || end > text.length) {
throw IndexOutOfBoundsException(
"end ($end) offset is outside of text region ${text.length}"
)
}
if (start > end) {
throw IllegalArgumentException("Do not set reversed range: $start > $end")
}
}
internal fun replace(start: Int, end: Int, text: AnnotatedString) {
replace(start, end, text.text)
}
/**
* Replace the text and move the cursor to the end of inserted text.
*
* This function cancels selection if there.
*
* @throws IndexOutOfBoundsException if start or end offset is outside of current buffer
* @throws IllegalArgumentException if start is larger than end. (reversed range)
*/
internal fun replace(start: Int, end: Int, text: String) {
if (start < 0 || start > gapBuffer.length) {
throw IndexOutOfBoundsException(
"start ($start) offset is outside of text region ${gapBuffer.length}"
)
}
if (end < 0 || end > gapBuffer.length) {
throw IndexOutOfBoundsException(
"end ($end) offset is outside of text region ${gapBuffer.length}"
)
}
if (start > end) {
throw IllegalArgumentException("Do not set reversed range: $start > $end")
}
gapBuffer.replace(start, end, text)
// On Android, all text modification APIs also provides explicit cursor location. On the
// hand, desktop application usually doesn't. So, here tentatively move the cursor to the
// end offset of the editing area for desktop like application. In case of Android,
// implementation will call setSelection immediately after replace function to update this
// tentative cursor location.
selectionStart = start + text.length
selectionEnd = start + text.length
// Similarly, if text modification happens, cancel ongoing composition. If caller want to
// change the composition text, it is caller responsibility to call setComposition again
// to set composition range after replace function.
compositionStart = NOWHERE
compositionEnd = NOWHERE
}
/**
* Remove the given range of text.
*
* Different from replace method, this doesn't move cursor location to the end of modified text.
* Instead, preserve the selection with adjusting the deleted text.
*/
internal fun delete(start: Int, end: Int) {
val deleteRange = TextRange(start, end)
gapBuffer.replace(start, end, "")
val newSelection = updateRangeAfterDelete(
TextRange(selectionStart, selectionEnd),
deleteRange
)
selectionStart = newSelection.min
selectionEnd = newSelection.max
if (hasComposition()) {
val compositionRange = TextRange(compositionStart, compositionEnd)
val newComposition = updateRangeAfterDelete(compositionRange, deleteRange)
if (newComposition.collapsed) {
commitComposition()
} else {
compositionStart = newComposition.min
compositionEnd = newComposition.max
}
}
}
/**
* Mark the specified area of the text as selected text.
*
* You can set cursor by specifying the same value to `start` and `end`.
* The reversed range is not allowed.
* @param start the inclusive start offset of the selection
* @param end the exclusive end offset of the selection
*
* @throws IndexOutOfBoundsException if start or end offset is outside of current buffer.
* @throws IllegalArgumentException if start is larger than end. (reversed range)
*/
internal fun setSelection(start: Int, end: Int) {
if (start < 0 || start > gapBuffer.length) {
throw IndexOutOfBoundsException(
"start ($start) offset is outside of text region ${gapBuffer.length}"
)
}
if (end < 0 || end > gapBuffer.length) {
throw IndexOutOfBoundsException(
"end ($end) offset is outside of text region ${gapBuffer.length}"
)
}
if (start > end) {
throw IllegalArgumentException("Do not set reversed range: $start > $end")
}
selectionStart = start
selectionEnd = end
}
/**
* Mark the specified area of the text as composition text.
*
* The empty range or reversed range is not allowed.
* Use clearComposition in case of clearing composition.
*
* @param start the inclusive start offset of the composition
* @param end the exclusive end offset of the composition
*
* @throws IndexOutOfBoundsException if start or end offset is ouside of current buffer
* @throws IllegalArgumentException if start is larger than or equal to end. (reversed or
* collapsed range)
*/
internal fun setComposition(start: Int, end: Int) {
if (start < 0 || start > gapBuffer.length) {
throw IndexOutOfBoundsException(
"start ($start) offset is outside of text region ${gapBuffer.length}"
)
}
if (end < 0 || end > gapBuffer.length) {
throw IndexOutOfBoundsException(
"end ($end) offset is outside of text region ${gapBuffer.length}"
)
}
if (start >= end) {
throw IllegalArgumentException("Do not set reversed or empty range: $start > $end")
}
compositionStart = start
compositionEnd = end
}
/**
* Removes the ongoing composition text and reset the composition range.
*/
internal fun cancelComposition() {
replace(compositionStart, compositionEnd, "")
compositionStart = NOWHERE
compositionEnd = NOWHERE
}
/**
* Commits the ongoing composition text and reset the composition range.
*/
internal fun commitComposition() {
compositionStart = NOWHERE
compositionEnd = NOWHERE
}
override fun toString(): String = gapBuffer.toString()
internal fun toAnnotatedString(): AnnotatedString = AnnotatedString(toString())
}
/**
* Returns the updated TextRange for [target] after the [deleted] TextRange is deleted as a Pair.
*
* If the [deleted] Range covers the whole target, Pair(-1,-1) is returned.
*/
/*@VisibleForTesting*/
internal fun updateRangeAfterDelete(target: TextRange, deleted: TextRange): TextRange {
var targetMin = target.min
var targetMax = target.max
// Following figure shows the deletion range and composition range.
// |---| represents deleted range.
// |===| represents target range.
if (deleted.intersects(target)) {
if (deleted.contains(target)) {
// Input:
// Buffer : ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Deleted : |-------------|
// Target : |======|
//
// Result:
// Buffer : ABCDETUVWXYZ
// Target :
targetMin = deleted.min
targetMax = targetMin
} else if (target.contains(deleted)) {
// Input:
// Buffer : ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Deleted : |------|
// Target : |==========|
//
// Result:
// Buffer : ABCDEFGHIQRSTUVWXYZ
// Target : |===|
targetMax -= deleted.length
} else if (deleted.contains(targetMin)) {
// Input:
// Buffer : ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Deleted : |---------|
// Target : |========|
//
// Result:
// Buffer : ABCDEFPQRSTUVWXYZ
// Target : |=====|
targetMin = deleted.min
targetMax -= deleted.length
} else { // deleteRange contains myMax
// Input:
// Buffer : ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Deleted : |---------|
// Target : |=======|
//
// Result:
// Buffer : ABCDEFGHSTUVWXYZ
// Target : |====|
targetMax = deleted.min
}
} else {
if (targetMax <= deleted.min) {
// Input:
// Buffer : ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Deleted : |-------|
// Target : |=======|
//
// Result:
// Buffer : ABCDEFGHIJKLTUVWXYZ
// Target : |=======|
// do nothing
} else {
// Input:
// Buffer : ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Deleted : |-------|
// Target : |=======|
//
// Result:
// Buffer : AJKLMNOPQRSTUVWXYZ
// Target : |=======|
targetMin -= deleted.length
targetMax -= deleted.length
}
}
return TextRange(targetMin, targetMax)
}