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

/*
 * 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.text.input.InputTransformation.Companion.transformInput
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.ui.text.TextRange
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.
 */
@OptIn(ExperimentalFoundationApi::class)
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.
     */
    private 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

    /**
     * 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
        }

    /**
     * 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
    ) {
        require(start <= end) { "Expected start=$start <= end=$end" }
        require(textStart <= textEnd) { "Expected textStart=$textStart <= textEnd=$textEnd" }
        onTextWillChange(start, end, textEnd - textStart)
        buffer.replace(start, end, text, textStart, textEnd)
    }

    /**
     * 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.
    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.
    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.
    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)

        // Adjust selection.
        val start = minOf(replaceStart, replaceEnd)
        val end = maxOf(replaceStart, replaceEnd)
        var selStart = selection.min
        var selEnd = selection.max

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

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

    /**
     * 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 no composition (null).
     */
    internal fun toTextFieldCharSequence(
        selection: TextRange = this.selection,
        composition: TextRange? = null
    ): 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

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

    private fun requireValidRange(range: TextRange) {
        val validRange = TextRange(0, length)
        require(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.
     */
    @ExperimentalFoundationApi
    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
    }
}

/**
 * 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