All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.text.input.TextFieldBuffer.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

The newest version!
/*
 * Copyright 2024 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.foundation.text.input

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.internal.requirePrecondition
import androidx.compose.foundation.text.input.TextFieldBuffer.ChangeList
import androidx.compose.foundation.text.input.internal.ChangeTracker
import androidx.compose.foundation.text.input.internal.OffsetMappingCalculator
import androidx.compose.foundation.text.input.internal.PartialGapBuffer
import androidx.compose.runtime.collection.MutableVector
import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.util.fastForEach
import kotlin.jvm.JvmName

/**
 * A text buffer that can be edited, similar to [StringBuilder].
 *
 * This class provides methods for changing the text, such as:
 * - [replace]
 * - [append]
 * - [insert]
 * - [delete]
 *
 * This class also stores and tracks the cursor position or selection range. The cursor position is
 * just a selection range with zero length. The cursor and selection can be changed using methods
 * such as:
 * - [placeCursorAfterCharAt]
 * - [placeCursorBeforeCharAt]
 * - [placeCursorAtEnd]
 * - [selectAll]
 *
 * To get one of these, and for usage samples, see [TextFieldState.edit]. Every change to the buffer
 * is tracked in a [ChangeList] which you can access via the [changes] property.
 */
class TextFieldBuffer
internal constructor(
    initialValue: TextFieldCharSequence,
    initialChanges: ChangeTracker? = null,
    internal val originalValue: TextFieldCharSequence = initialValue,
    private val offsetMappingCalculator: OffsetMappingCalculator? = null,
) : Appendable {

    private val buffer = PartialGapBuffer(initialValue)

    private var backingChangeTracker: ChangeTracker? =
        initialChanges?.let { ChangeTracker(initialChanges) }

    /** Lazily-allocated [ChangeTracker], initialized on the first access. */
    internal val changeTracker: ChangeTracker
        get() = backingChangeTracker ?: ChangeTracker().also { backingChangeTracker = it }

    /** The number of characters in the text field. */
    val length: Int
        get() = buffer.length

    /**
     * Original text content of the buffer before any changes were applied. Calling
     * [revertAllChanges] will set the contents of this buffer to this value.
     */
    val originalText: CharSequence
        get() = originalValue.text

    /**
     * Original selection before the changes. Calling [revertAllChanges] will set the selection to
     * this value.
     */
    val originalSelection: TextRange
        get() = originalValue.selection

    /**
     * The [ChangeList] represents the changes made to this value and is inherently mutable. This
     * means that the returned [ChangeList] always reflects the complete list of changes made to
     * this value at any given time, even those made after reading this property.
     *
     * @sample androidx.compose.foundation.samples.BasicTextFieldChangeIterationSample
     * @sample androidx.compose.foundation.samples.BasicTextFieldChangeReverseIterationSample
     */
    @ExperimentalFoundationApi
    val changes: ChangeList
        get() = changeTracker

    // region selection

    /**
     * True if the selection range has non-zero length. If this is false, then the selection
     * represents the cursor.
     *
     * @see selection
     */
    @get:JvmName("hasSelection")
    val hasSelection: Boolean
        get() = !selection.collapsed

    /**
     * Backing TextRange for [selection]. Each method that updates selection has its own validation.
     * This backing field does not further validate its own state.
     */
    private var selectionInChars: TextRange = initialValue.selection

    /**
     * The selected range of characters.
     *
     * Places the selection around the given range in characters.
     *
     * If the start or end of TextRange fall inside surrogate pairs or other invalid runs, the
     * values will be adjusted to the nearest earlier and later characters, respectively.
     *
     * To place the start of the selection at the beginning of the field, set this value to
     * [TextRange.Zero]. To place the end of the selection at the end of the field, after the last
     * character, pass [TextFieldBuffer.length]. Passing a zero-length range is the same as calling
     * [placeCursorBeforeCharAt].
     */
    var selection: TextRange
        get() = selectionInChars
        set(value) {
            requireValidRange(value)
            selectionInChars = value
            highlight = null
        }

    // endregion

    // region composition

    /**
     * Returns the composition information as TextRange. Returns null if no composition is set.
     *
     * Evaluates to null if it is set to a collapsed TextRange. Clears [composingAnnotations] when
     * set to null, including collapsed TextRange.
     */
    internal var composition: TextRange? = initialValue.composition
        private set(value) {
            // collapsed composition region is equivalent to no composition
            if (value == null || value.collapsed) {
                field = null
                // Do not deallocate an existing list. We will probably use it again.
                composingAnnotations?.clear()
            } else {
                field = value
            }
        }

    /**
     * List of annotations that are attached to the composing region. These are usually styling cues
     * like underline or different background colors.
     */
    internal var composingAnnotations:
        MutableVector>? =
        if (!initialValue.composingAnnotations.isNullOrEmpty()) {
            MutableVector(initialValue.composingAnnotations.size) {
                initialValue.composingAnnotations[it]
            }
        } else {
            null
        }
        private set

    /** Helper function that returns true if the buffer has composing region */
    internal fun hasComposition(): Boolean = composition != null

    /** Clears current composition. */
    internal fun commitComposition() {
        composition = null
    }

    /**
     * Mark the specified area of the text as composition text.
     *
     * The empty range or reversed range is not allowed. Use [commitComposition] in case if you want
     * to clear composition.
     *
     * @param start the inclusive start offset of the composition
     * @param end the exclusive end offset of the composition
     * @param annotations Annotations that are attached to the composing region of text. This
     *   function does not check whether the given annotations are inside the composing region. It
     *   simply adds them to the current buffer while adjusting their range according to where the
     *   new composition region is set.
     * @throws IndexOutOfBoundsException if start or end offset is outside 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, annotations: List? = null) {
        if (start < 0 || start > buffer.length) {
            throw IndexOutOfBoundsException(
                "start ($start) offset is outside of text region ${buffer.length}"
            )
        }
        if (end < 0 || end > buffer.length) {
            throw IndexOutOfBoundsException(
                "end ($end) offset is outside of text region ${buffer.length}"
            )
        }
        if (start >= end) {
            throw IllegalArgumentException("Do not set reversed or empty range: $start > $end")
        }

        composition = TextRange(start, end)

        this.composingAnnotations?.clear()
        if (!annotations.isNullOrEmpty()) {
            if (this.composingAnnotations == null) {
                this.composingAnnotations = mutableVectorOf()
            }
            annotations.fastForEach {
                // place the annotations at the correct indices in the buffer.
                this.composingAnnotations?.add(
                    it.copy(start = it.start + start, end = it.end + start)
                )
            }
        }
    }

    // endregion

    // region highlight

    /**
     * A highlighted range of text. This may be used to display handwriting gesture previews from
     * the IME.
     */
    internal var highlight: Pair? = null
        private set

    /**
     * Mark a range of text to be highlighted. This may be used to display handwriting gesture
     * previews from the IME.
     *
     * An empty or reversed range is not allowed.
     *
     * @param type the highlight type
     * @param start the inclusive start offset of the highlight
     * @param end the exclusive end offset of the highlight
     */
    internal fun setHighlight(type: TextHighlightType, start: Int, end: Int) {
        if (start >= end) {
            throw IllegalArgumentException("Do not set reversed or empty range: $start > $end")
        }
        val clampedStart = start.coerceIn(0, length)
        val clampedEnd = end.coerceIn(0, length)

        highlight = Pair(type, TextRange(clampedStart, clampedEnd))
    }

    /** Clear the highlighted text range. */
    internal fun clearHighlight() {
        highlight = null
    }

    // endregion

    // region editing

    /**
     * Replaces the text between [start] (inclusive) and [end] (exclusive) in this value with
     * [text], and records the change in [changes].
     *
     * @param start The character offset of the first character to replace.
     * @param end The character offset of the first character after the text to replace.
     * @param text The text to replace the range `[start, end)` with.
     * @see append
     * @see insert
     * @see delete
     */
    fun replace(start: Int, end: Int, text: CharSequence) {
        replace(start, end, text, 0, text.length)
    }

    /**
     * Replaces the text between [start] (inclusive) and [end] (exclusive) in this value with
     * [text], and records the change in [changes].
     *
     * @param start The character offset of the first character to replace.
     * @param end The character offset of the first character after the text to replace.
     * @param text The text to replace the range `[start, end)` with.
     * @param textStart The character offset of the first character in [text] to copy.
     * @param textEnd The character offset after the last character in [text] to copy.
     * @see append
     * @see insert
     * @see delete
     */
    internal fun replace(
        start: Int,
        end: Int,
        text: CharSequence,
        textStart: Int = 0,
        textEnd: Int = text.length
    ) {
        requirePrecondition(start <= end) { "Expected start=$start <= end=$end" }
        requirePrecondition(textStart <= textEnd) {
            "Expected textStart=$textStart <= textEnd=$textEnd"
        }
        onTextWillChange(start, end, textEnd - textStart)
        buffer.replace(start, end, text, textStart, textEnd)

        commitComposition()
        highlight = null
    }

    /**
     * Similar to `replace(0, length, newText)` but only records a change if [newText] is actually
     * different from the current buffer value.
     */
    internal fun setTextIfChanged(newText: CharSequence) {
        findCommonPrefixAndSuffix(buffer, newText) { thisStart, thisEnd, newStart, newEnd ->
            replace(thisStart, thisEnd, newText, newStart, newEnd)
        }
    }

    // Doc inherited from Appendable.
    // This append overload should be first so it ends up being the target of links to this method.
    @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
    override fun append(text: CharSequence?): Appendable = apply {
        if (text != null) {
            onTextWillChange(length, length, text.length)
            buffer.replace(buffer.length, buffer.length, text)
        }
    }

    // Doc inherited from Appendable.
    @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
    override fun append(text: CharSequence?, start: Int, end: Int): Appendable = apply {
        if (text != null) {
            onTextWillChange(length, length, end - start)
            buffer.replace(buffer.length, buffer.length, text.subSequence(start, end))
        }
    }

    // Doc inherited from Appendable.
    @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
    override fun append(char: Char): Appendable = apply {
        onTextWillChange(length, length, 1)
        buffer.replace(buffer.length, buffer.length, char.toString())
    }

    /**
     * Called just before the text contents are about to change.
     *
     * @param replaceStart The first offset to be replaced (inclusive).
     * @param replaceEnd The last offset to be replaced (exclusive).
     * @param newLength The length of the replacement.
     */
    private fun onTextWillChange(replaceStart: Int, replaceEnd: Int, newLength: Int) {
        changeTracker.trackChange(replaceStart, replaceEnd, newLength)
        offsetMappingCalculator?.recordEditOperation(replaceStart, replaceEnd, newLength)
        // On Android, IME calls are usually followed with an explicit change to selection.
        // Therefore it might seem unnecessary to adjust the selection here. However, this sort of
        // behavior is not expected for edits that are coming from the developer programmatically
        // or desktop APIs. So, we make sure that the selection is placed at a reasonable place
        // after any kind of edit.
        selectionInChars = adjustTextRange(selection, replaceStart, replaceEnd, newLength)
    }

    // endregion

    /** Returns the [Char] at [index] in this buffer. */
    fun charAt(index: Int): Char = buffer[index]

    override fun toString(): String = buffer.toString()

    /**
     * Returns a [CharSequence] backed by this buffer. Any subsequent changes to this buffer will be
     * visible in the returned sequence as well.
     */
    fun asCharSequence(): CharSequence = buffer

    private fun clearChangeList() {
        changeTracker.clearChanges()
    }

    /**
     * Revert all changes made to this value since it was created.
     *
     * After calling this method, this object will be in the same state it was when it was initially
     * created, and [changes] will be empty.
     */
    fun revertAllChanges() {
        replace(0, length, originalValue.toString())
        selection = originalValue.selection
        clearChangeList()
    }

    /**
     * Places the cursor before the character at the given index.
     *
     * If [index] is inside a surrogate pair or other invalid run, the cursor will be placed at the
     * nearest earlier index.
     *
     * To place the cursor at the beginning of the field, pass index 0. To place the cursor at the
     * end of the field, after the last character, pass index [TextFieldBuffer.length] or call
     * [placeCursorAtEnd].
     *
     * @param index Character index to place cursor before, should be in range 0 to
     *   [TextFieldBuffer.length], inclusive.
     * @see placeCursorAfterCharAt
     */
    fun placeCursorBeforeCharAt(index: Int) {
        requireValidIndex(index, startExclusive = true, endExclusive = false)
        // skip further validation
        selectionInChars = TextRange(index)
    }

    /**
     * Places the cursor after the character at the given index.
     *
     * If [index] is inside a surrogate pair or other invalid run, the cursor will be placed at the
     * nearest later index.
     *
     * To place the cursor at the end of the field, after the last character, pass index
     * [TextFieldBuffer.length] or call [placeCursorAtEnd].
     *
     * @param index Character index to place cursor after, should be in range 0 (inclusive) to
     *   [TextFieldBuffer.length] (exclusive).
     * @see placeCursorBeforeCharAt
     */
    fun placeCursorAfterCharAt(index: Int) {
        requireValidIndex(index, startExclusive = false, endExclusive = true)
        // skip further validation
        selectionInChars = TextRange((index + 1).coerceAtMost(length))
    }

    /**
     * Returns an immutable [TextFieldCharSequence] that has the same contents of this buffer.
     *
     * @param selection The selection for the returned [TextFieldCharSequence]. Default value is
     *   this buffer's selection. Passing a different value in here _only_ affects the return value,
     *   it does not change the current selection in the buffer.
     * @param composition The composition range for the returned [TextFieldCharSequence]. Default
     *   value is this buffer's current composition.
     */
    internal fun toTextFieldCharSequence(
        selection: TextRange = this.selection,
        composition: TextRange? = this.composition
    ): TextFieldCharSequence =
        TextFieldCharSequence(buffer.toString(), selection = selection, composition = composition)

    private fun requireValidIndex(index: Int, startExclusive: Boolean, endExclusive: Boolean) {
        val start = if (startExclusive) 0 else -1
        val end = if (endExclusive) length else length + 1

        requirePrecondition(index in start until end) { "Expected $index to be in [$start, $end)" }
    }

    private fun requireValidRange(range: TextRange) {
        val validRange = TextRange(0, length)
        requirePrecondition(range in validRange) { "Expected $range to be in $validRange" }
    }

    /**
     * The ordered list of non-overlapping and discontinuous changes performed on a
     * [TextFieldBuffer] during the current [edit][TextFieldState.edit] or
     * [filter][InputTransformation.transformInput] operation. Changes are listed in the order they
     * appear in the text, not the order in which they were made. Overlapping changes are
     * represented as a single change.
     */
    interface ChangeList {
        /** The number of changes that have been performed. */
        val changeCount: Int

        /**
         * Returns the range in the [TextFieldBuffer] that was changed.
         *
         * @throws IndexOutOfBoundsException If [changeIndex] is not in [0, [changeCount]).
         */
        fun getRange(changeIndex: Int): TextRange

        /**
         * Returns the range in the original text that was replaced.
         *
         * @throws IndexOutOfBoundsException If [changeIndex] is not in [0, [changeCount]).
         */
        fun getOriginalRange(changeIndex: Int): TextRange
    }
}

/**
 * Given [originalRange], calculates its new placement in the buffer after a region starting from
 * [replaceStart] (inclusive) ending at [replaceEnd] (exclusive) is deleted and [insertedTextLength]
 * number of characters are inserted at [replaceStart]. The rules of the adjustment are as follows;
 * - '||'; denotes the [originalRange]
 * - '\/'; denotes the [replaceStart], [replaceEnd]
 *
 * If the [originalRange]
 * - is before the replaced region, it remains in the same place.
 *     - abcd|efg|hijk\lmno/pqrs => abcd|efg|hijkxyzpqrs
 *     - TextRange(4, 7) => TextRange(4, 7)
 * - is after the replaced region, it is moved by the difference in length after replacement,
 *   essentially corresponding to the same part of the text.
 *     - abcd\efg/hijk|lmno|pqrs => abcdxyzxyzxyzhijk|lmno|pqrs
 *     - TextRange(11, 15) => TextRange(17, 21)
 * - fully wraps the replaced region, only the end is adjusted.
 *     - ab|cd\efg/hijklmno|pqrs => ab|cdxyzxyzxyzhijklmno|pqrs
 *     - TextRange(2, 15) => TextRange(2, 21)
 * - is inside the replaced region, range is collapsed and moved to the end of the replaced region.
 *     - ab\cd|efg|hijklmno/pqrs => abxyzxyz|pqrs
 *     - TextRange(4, 7) => TextRange(8, 8)
 * - collides with the replaced region at the start or at the end, it is adjusted so that the
 *   colliding range is not included anymore.
 *     - abcd|efg\hijk|lm/nopqrs => abcd|efg|xyzxyznopqrs
 *     - TextRange(4, 11) => TextRange(4, 7)
 */
internal fun adjustTextRange(
    originalRange: TextRange,
    replaceStart: Int,
    replaceEnd: Int,
    insertedTextLength: Int
): TextRange {
    var selStart = originalRange.min
    var selEnd = originalRange.max

    if (selEnd < replaceStart) {
        // The entire originalRange is before the insertion point – we don't have to adjust
        // the mark at all, so skip the math.
        return originalRange
    }

    if (selStart <= replaceStart && replaceEnd <= selEnd) {
        // The insertion is entirely inside the originalRange, move the end only.
        val diff = insertedTextLength - (replaceEnd - replaceStart)
        // Preserve "cursorness".
        if (selStart == selEnd) {
            selStart += diff
        }
        selEnd += diff
    } else if (selStart > replaceStart && selEnd < replaceEnd) {
        // originalRange is entirely inside replacement, move it to the end.
        selStart = replaceStart + insertedTextLength
        selEnd = replaceStart + insertedTextLength
    } else if (selStart >= replaceEnd) {
        // The entire originalRange is after the insertion, so shift everything forward.
        val diff = insertedTextLength - (replaceEnd - replaceStart)
        selStart += diff
        selEnd += diff
    } else if (replaceStart < selStart) {
        // Insertion is around start of originalRange, truncate start of originalRange.
        selStart = replaceStart + insertedTextLength
        selEnd += insertedTextLength - (replaceEnd - replaceStart)
    } else {
        // Insertion is around end of originalRange, truncate end of originalRange.
        selEnd = replaceStart
    }
    // should not validate
    return TextRange(selStart, selEnd)
}

/**
 * Insert [text] at the given [index] in this value. Pass 0 to insert [text] at the beginning of
 * this buffer, and pass [TextFieldBuffer.length] to insert [text] at the end of this buffer.
 *
 * This is equivalent to calling `replace(index, index, text)`.
 *
 * @param index The character offset at which to insert [text].
 * @param text The text to insert.
 * @see TextFieldBuffer.replace
 * @see TextFieldBuffer.append
 * @see TextFieldBuffer.delete
 */
fun TextFieldBuffer.insert(index: Int, text: String) {
    replace(index, index, text)
}

/**
 * Delete the text between [start] (inclusive) and [end] (exclusive). Pass 0 as [start] and
 * [TextFieldBuffer.length] as [end] to delete everything in this buffer.
 *
 * @param start The character offset of the first character to delete.
 * @param end The character offset of the first character after the deleted range.
 * @see TextFieldBuffer.replace
 * @see TextFieldBuffer.append
 * @see TextFieldBuffer.insert
 */
fun TextFieldBuffer.delete(start: Int, end: Int) {
    replace(start, end, "")
}

/** Places the cursor at the end of the text. */
fun TextFieldBuffer.placeCursorAtEnd() {
    placeCursorBeforeCharAt(length)
}

/** Places the selection around all the text. */
fun TextFieldBuffer.selectAll() {
    selection = TextRange(0, length)
}

/**
 * Iterates over all the changes in this [ChangeList].
 *
 * Changes are iterated by index, so any changes made by [block] after the current one will be
 * visited by [block]. [block] should not make any new changes _before_ the current one or changes
 * will be visited more than once. If you need to make changes, consider using
 * [forEachChangeReversed].
 *
 * @sample androidx.compose.foundation.samples.BasicTextFieldChangeIterationSample
 * @see forEachChangeReversed
 */
@ExperimentalFoundationApi
inline fun ChangeList.forEachChange(block: (range: TextRange, originalRange: TextRange) -> Unit) {
    var i = 0
    // Check the size every iteration in case more changes were performed.
    while (i < changeCount) {
        block(getRange(i), getOriginalRange(i))
        i++
    }
}

/**
 * Iterates over all the changes in this [ChangeList] in reverse order.
 *
 * Changes are iterated by index, so [block] should not perform any new changes before the current
 * one or changes may be skipped. [block] may make non-overlapping changes after the current one
 * safely, such changes will not be visited.
 *
 * @sample androidx.compose.foundation.samples.BasicTextFieldChangeReverseIterationSample
 * @see forEachChange
 */
@ExperimentalFoundationApi
inline fun ChangeList.forEachChangeReversed(
    block: (range: TextRange, originalRange: TextRange) -> Unit
) {
    var i = changeCount - 1
    while (i >= 0) {
        block(getRange(i), getOriginalRange(i))
        i--
    }
}

/**
 * Finds the common prefix and suffix between [a] and [b] and then reports the ranges of each that
 * excludes those. The values are reported via an (inline) callback instead of a return value to
 * avoid having to allocate something to hold them. If the [CharSequence]s are identical, the
 * callback is not invoked.
 *
 * E.g. given `a="abcde"` and `b="abbbdefe"`, the middle diff for `a` is `"ab|cd|e"` and for `b` is
 * `ab|bbdef|e`, so reports `aMiddle=TextRange(2, 4)` and `bMiddle=TextRange(2, 7)`.
 */
internal inline fun findCommonPrefixAndSuffix(
    a: CharSequence,
    b: CharSequence,
    onFound: (aPrefixStart: Int, aSuffixStart: Int, bPrefixStart: Int, bSuffixStart: Int) -> Unit
) {
    var aStart = 0
    var aEnd = a.length
    var bStart = 0
    var bEnd = b.length

    // If either one is empty, the diff range is the entire non-empty one.
    if (a.isNotEmpty() && b.isNotEmpty()) {
        var prefixFound = false
        var suffixFound = false

        do {
            if (!prefixFound) {
                if (a[aStart] == b[bStart]) {
                    aStart += 1
                    bStart += 1
                } else {
                    prefixFound = true
                }
            }
            if (!suffixFound) {
                if (a[aEnd - 1] == b[bEnd - 1]) {
                    aEnd -= 1
                    bEnd -= 1
                } else {
                    suffixFound = true
                }
            }
        } while (
            // As soon as we've completely traversed one of the strings, if the other hasn't also
            // finished being traversed then we've found the diff region.
            aStart < aEnd &&
                bStart < bEnd &&
                // If we've found the end of the common prefix and the start of the common suffix
                // we're
                // done.
                !(prefixFound && suffixFound)
        )
    }

    if (aStart >= aEnd && bStart >= bEnd) {
        return
    }

    onFound(aStart, aEnd, bStart, bEnd)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy