ApplyEditCommand.kt

/*
 * Copyright 2023 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.text2.input.internal

import java.text.BreakIterator

/**
 * Applies a given [EditCommand] on this [EditingBuffer].
 *
 * Usually calls a dedicated extension function for a given subclass of [EditCommand].
 *
 * @throws IllegalArgumentException if EditCommand is not recognized.
 */
internal fun EditingBuffer.update(editCommand: EditCommand) {
    when (editCommand) {
        is BackspaceCommand -> applyBackspaceCommand()
        is CommitTextCommand -> applyCommitTextCommand(editCommand)
        is DeleteAllCommand -> replace(0, length, "")
        is DeleteSurroundingTextCommand -> applyDeleteSurroundingTextCommand(editCommand)
        is DeleteSurroundingTextInCodePointsCommand ->
            applyDeleteSurroundingTextInCodePointsCommand(editCommand)
        is FinishComposingTextCommand -> commitComposition()
        is MoveCursorCommand -> applyMoveCursorCommand(editCommand)
        is SetComposingRegionCommand -> applySetComposingRegionCommand(editCommand)
        is SetComposingTextCommand -> applySetComposingTextCommand(editCommand)
        is SetSelectionCommand -> applySetSelectionCommand(editCommand)
    }
}

private fun EditingBuffer.applySetSelectionCommand(setSelectionCommand: SetSelectionCommand) {
    // Sanitize the input: reverse if reversed, clamped into valid range.
    val clampedStart = setSelectionCommand.start.coerceIn(0, length)
    val clampedEnd = setSelectionCommand.end.coerceIn(0, length)
    if (clampedStart < clampedEnd) {
        setSelection(clampedStart, clampedEnd)
    } else {
        setSelection(clampedEnd, clampedStart)
    }
}

private fun EditingBuffer.applySetComposingTextCommand(
    setComposingTextCommand: SetComposingTextCommand
) {
    val text = setComposingTextCommand.text
    val newCursorPosition = setComposingTextCommand.newCursorPosition

    if (hasComposition()) {
        // API doc says, if there is ongoing composing text, replace it with new text.
        val compositionStart = compositionStart
        replace(compositionStart, compositionEnd, text)
        if (text.isNotEmpty()) {
            setComposition(compositionStart, compositionStart + text.length)
        }
    } else {
        // If there is no composing text, insert composing text into cursor position with
        // removing selected text if any.
        val selectionStart = selectionStart
        replace(selectionStart, selectionEnd, text)
        if (text.isNotEmpty()) {
            setComposition(selectionStart, selectionStart + text.length)
        }
    }

    // After replace function is called, the editing buffer places the cursor at the end of the
    // modified range.
    val newCursor = cursor

    // See above API description for the meaning of newCursorPosition.
    val newCursorInBuffer = if (newCursorPosition > 0) {
        newCursor + newCursorPosition - 1
    } else {
        newCursor + newCursorPosition - text.length
    }

    cursor = newCursorInBuffer.coerceIn(0, length)
}

private fun EditingBuffer.applySetComposingRegionCommand(
    setComposingRegionCommand: SetComposingRegionCommand
) {
    // The API description says, different from SetComposingText, SetComposingRegion must
    // preserve the ongoing composition text and set new composition.
    if (hasComposition()) {
        commitComposition()
    }

    // Sanitize the input: reverse if reversed, clamped into valid range, ignore empty range.
    val clampedStart = setComposingRegionCommand.start.coerceIn(0, length)
    val clampedEnd = setComposingRegionCommand.end.coerceIn(0, length)
    if (clampedStart == clampedEnd) {
        // do nothing. empty composition range is not allowed.
    } else if (clampedStart < clampedEnd) {
        setComposition(clampedStart, clampedEnd)
    } else {
        setComposition(clampedEnd, clampedStart)
    }
}

private fun EditingBuffer.applyMoveCursorCommand(moveCursorCommand: MoveCursorCommand) {
    if (cursor == -1) {
        cursor = selectionStart
    }

    var newCursor = selectionStart
    val bufferText = toString()
    if (moveCursorCommand.amount > 0) {
        for (i in 0 until moveCursorCommand.amount) {
            val next = bufferText.findFollowingBreak(newCursor)
            if (next == -1) break
            newCursor = next
        }
    } else {
        for (i in 0 until -moveCursorCommand.amount) {
            val prev = bufferText.findPrecedingBreak(newCursor)
            if (prev == -1) break
            newCursor = prev
        }
    }

    cursor = newCursor
}

private fun EditingBuffer.applyDeleteSurroundingTextInCodePointsCommand(
    deleteSurroundingTextInCodePointsCommand: DeleteSurroundingTextInCodePointsCommand
) {
    // Convert code point length into character length. Then call the common logic of the
    // DeleteSurroundingTextEditOp
    var beforeLenInChars = 0
    for (i in 0 until deleteSurroundingTextInCodePointsCommand.lengthBeforeCursor) {
        beforeLenInChars++
        if (selectionStart > beforeLenInChars) {
            val lead = this[selectionStart - beforeLenInChars - 1]
            val trail = this[selectionStart - beforeLenInChars]

            if (isSurrogatePair(lead, trail)) {
                beforeLenInChars++
            }
        }
        if (beforeLenInChars == selectionStart) break
    }

    var afterLenInChars = 0
    for (i in 0 until deleteSurroundingTextInCodePointsCommand.lengthAfterCursor) {
        afterLenInChars++
        if (selectionEnd + afterLenInChars < length) {
            val lead = this[selectionEnd + afterLenInChars - 1]
            val trail = this[selectionEnd + afterLenInChars]

            if (isSurrogatePair(lead, trail)) {
                afterLenInChars++
            }
        }
        if (selectionEnd + afterLenInChars == length) break
    }

    delete(selectionEnd, selectionEnd + afterLenInChars)
    delete(selectionStart - beforeLenInChars, selectionStart)
}

private fun EditingBuffer.applyDeleteSurroundingTextCommand(
    deleteSurroundingTextCommand: DeleteSurroundingTextCommand
) {
    // calculate the end with safe addition since lengthAfterCursor can be set to e.g. Int.MAX
    // by the input
    val end = selectionEnd.addExactOrElse(deleteSurroundingTextCommand.lengthAfterCursor) { length }
    delete(selectionEnd, minOf(end, length))

    // calculate the start with safe subtraction since lengthBeforeCursor can be set to e.g.
    // Int.MAX by the input
    val start = selectionStart.subtractExactOrElse(
        deleteSurroundingTextCommand.lengthBeforeCursor
    ) { 0 }
    delete(maxOf(0, start), selectionStart)
}

private fun EditingBuffer.applyBackspaceCommand() {
    if (hasComposition()) {
        delete(compositionStart, compositionEnd)
        return
    }

    if (cursor == -1) {
        val delStart = selectionStart
        val delEnd = selectionEnd
        cursor = selectionStart
        delete(delStart, delEnd)
        return
    }

    if (cursor == 0) {
        return
    }

    val prevCursorPos = toString().findPrecedingBreak(cursor)
    delete(prevCursorPos, cursor)
}

private fun EditingBuffer.applyCommitTextCommand(commitTextCommand: CommitTextCommand) {
    // API description says replace ongoing composition text if there. Then, if there is no
    // composition text, insert text into cursor position or replace selection.
    if (hasComposition()) {
        replace(compositionStart, compositionEnd, commitTextCommand.text)
    } else {
        // In this editing buffer, insert into cursor or replace selection are equivalent.
        replace(selectionStart, selectionEnd, commitTextCommand.text)
    }

    // After replace function is called, the editing buffer places the cursor at the end of the
    // modified range.
    val newCursor = cursor

    // See above API description for the meaning of newCursorPosition.
    val newCursorInBuffer = if (commitTextCommand.newCursorPosition > 0) {
        newCursor + commitTextCommand.newCursorPosition - 1
    } else {
        newCursor + commitTextCommand.newCursorPosition - commitTextCommand.text.length
    }

    cursor = newCursorInBuffer.coerceIn(0, length)
}

/**
 * Helper function that returns true when [high] is a Unicode high-surrogate code unit and [low]
 * is a Unicode low-surrogate code unit.
 */
private fun isSurrogatePair(high: Char, low: Char): Boolean =
    high.isHighSurrogate() && low.isLowSurrogate()

// TODO(halilibo): Remove when migrating back to foundation
private fun String.findPrecedingBreak(index: Int): Int {
    val it = BreakIterator.getCharacterInstance()
    it.setText(this)
    return it.preceding(index)
}

// TODO(halilibo): Remove when migrating back to foundation
private fun String.findFollowingBreak(index: Int): Int {
    val it = BreakIterator.getCharacterInstance()
    it.setText(this)
    return it.following(index)
}