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

commonMain.androidx.compose.foundation.text.input.TextUndoManager.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.text.SNAPSHOTS_INTERVAL_MILLIS
import androidx.compose.foundation.text.input.internal.undo.TextDeleteType
import androidx.compose.foundation.text.input.internal.undo.TextEditType
import androidx.compose.foundation.text.input.internal.undo.TextUndoOperation
import androidx.compose.foundation.text.input.internal.undo.UndoManager
import androidx.compose.foundation.text.input.internal.undo.redo
import androidx.compose.foundation.text.input.internal.undo.undo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.text.substring

/**
 * An undo manager designed specifically for text editing. This class is mostly responsible for
 * delegating the incoming undo/redo/record/clear requests to its own [undoManager]. Its most
 * important task is to keep a separate staging area for incoming text edit operations to possibly
 * merge them before committing as a single undoable action.
 */
internal class TextUndoManager(
    initialStagingUndo: TextUndoOperation? = null,
    private val undoManager: UndoManager =
        UndoManager(capacity = TEXT_UNDO_CAPACITY)
) {
    private var stagingUndo by mutableStateOf(initialStagingUndo)

    val canUndo: Boolean
        get() = undoManager.canUndo || stagingUndo != null

    val canRedo: Boolean
        get() = undoManager.canRedo && stagingUndo == null

    fun undo(state: TextFieldState) {
        if (!canUndo) {
            return
        }

        flush()
        state.undo(undoManager.undo())
    }

    fun redo(state: TextFieldState) {
        if (!canRedo) {
            return
        }

        state.redo(undoManager.redo())
    }

    fun record(op: TextUndoOperation) {
        val unobservedStagingUndo = Snapshot.withoutReadObservation { stagingUndo }
        if (unobservedStagingUndo == null) {
            stagingUndo = op
            return
        }

        val mergedOp = unobservedStagingUndo.merge(op)

        if (mergedOp != null) {
            // merged operation should replace the top op
            stagingUndo = mergedOp
            return
        }

        // no merge, flush the staging area and put the new operation in
        flush()
        stagingUndo = op
    }

    fun clearHistory() {
        stagingUndo = null
        undoManager.clearHistory()
    }

    private fun flush() {
        val unobservedStagingUndo = Snapshot.withoutReadObservation { stagingUndo }
        unobservedStagingUndo?.let { undoManager.record(it) }
        stagingUndo = null
    }

    companion object {
        object Saver : androidx.compose.runtime.saveable.Saver {
            private val undoManagerSaver = UndoManager.createSaver(TextUndoOperation.Saver)

            override fun SaverScope.save(value: TextUndoManager): Any {
                return listOf(
                    value.stagingUndo?.let { with(TextUndoOperation.Saver) { save(it) } },
                    with(undoManagerSaver) { save(value.undoManager) }
                )
            }

            override fun restore(value: Any): TextUndoManager? {
                val (savedStagingUndo, savedUndoManager) = value as List<*>
                return TextUndoManager(
                    savedStagingUndo?.let { with(TextUndoOperation.Saver) { restore(it) } },
                    with(undoManagerSaver) { restore(savedUndoManager!!) }!!
                )
            }
        }
    }
}

/**
 * Try to merge this [TextUndoOperation] with the [next]. Chronologically the [next] op must come
 * after this one. If merge is not possible, this function returns null.
 *
 * There are many rules that govern the grouping logic of successive undo operations. Here we try to
 * cover the most basic requirements but this is certainly not an exhaustive list.
 * 1. Each action defines whether they can be merged at all. For example, text edits that are caused
 *    by cut or paste define themselves as unmergeable no matter what comes before or next.
 * 2. If certain amount of time has passed since the latest grouping has begun.
 * 3. Enter key (hard line break) is unmergeable.
 * 4. Only same type of text edits can be merged. An insertion must be grouped by other insertions,
 *    a deletion by other deletions. Replace type of edit is never mergeable. 4.a. Two insertions
 *    can only be merged if the chronologically next one is a suffix of the previous insertion. In
 *    other words, cursor should always be moving forwards. 4.b. Deletions have directionality.
 *    Cursor can only insert in place and move forwards but deletion can be requested either
 *    forwards (delete) or backwards (backspace). Only deletions that have the same direction can be
 *    merged. They also have to share a boundary.
 */
internal fun TextUndoOperation.merge(next: TextUndoOperation): TextUndoOperation? {
    if (!canMerge || !next.canMerge) return null
    // Do not merge if [other] came before this op, or if certain amount of time has passed
    // between these ops
    if (
        next.timeInMillis < timeInMillis ||
            next.timeInMillis - timeInMillis >= SNAPSHOTS_INTERVAL_MILLIS
    )
        return null
    // Do not merge undo history when one of the ops is a new line insertion
    if (isNewLineInsert || next.isNewLineInsert) return null
    // Only same type of ops can be merged together
    if (textEditType != next.textEditType) return null

    // only merge insertions if the chronologically next one continuous from the end of
    // this previous insertion
    if (textEditType == TextEditType.Insert && index + postText.length == next.index) {
        return TextUndoOperation(
            index = index,
            preText = "",
            postText = postText + next.postText,
            preSelection = this.preSelection,
            postSelection = next.postSelection,
            timeInMillis = timeInMillis
        )
    } else if (textEditType == TextEditType.Delete) {
        // only merge consecutive deletions if both have the same directionality
        if (
            deletionType == next.deletionType &&
                (deletionType == TextDeleteType.Start || deletionType == TextDeleteType.End)
        ) {
            // This op deletes
            if (index == next.index + next.preText.length) {
                return TextUndoOperation(
                    index = next.index,
                    preText = next.preText + preText,
                    postText = "",
                    preSelection = preSelection,
                    postSelection = next.postSelection,
                    timeInMillis = timeInMillis
                )
            } else if (index == next.index) {
                return TextUndoOperation(
                    index = index,
                    preText = preText + next.preText,
                    postText = "",
                    preSelection = preSelection,
                    postSelection = next.postSelection,
                    timeInMillis = timeInMillis
                )
            }
        }
    }
    return null
}

/**
 * Adds the [changes] to this [UndoManager] by converting from [TextFieldBuffer.ChangeList] space to
 * [TextUndoOperation] space.
 *
 * @param pre State of the [TextFieldBuffer] before any changes are applied
 * @param post State of the [TextFieldBuffer] after all the changes are applied
 * @param changes List of changes that are applied on [pre] that transforms it to [post].
 * @param allowMerge Whether to allow merging the calculated operation with the last operation in
 *   the stack.
 */
internal fun TextUndoManager.recordChanges(
    pre: TextFieldCharSequence,
    post: TextFieldCharSequence,
    changes: TextFieldBuffer.ChangeList,
    allowMerge: Boolean = true
) {
    // if there are unmerged changes coming from a single edit, force merge all of them to
    // create a single replace undo operation
    if (changes.changeCount > 1) {
        record(
            TextUndoOperation(
                index = 0,
                preText = pre.toString(),
                postText = post.toString(),
                preSelection = pre.selection,
                postSelection = post.selection,
                canMerge = false
            )
        )
    } else if (changes.changeCount == 1) {
        val preRange = changes.getOriginalRange(0)
        val postRange = changes.getRange(0)
        if (!preRange.collapsed || !postRange.collapsed) {
            record(
                TextUndoOperation(
                    index = preRange.min,
                    preText = pre.substring(preRange),
                    postText = post.substring(postRange),
                    preSelection = pre.selection,
                    postSelection = post.selection,
                    canMerge = allowMerge
                )
            )
        }
    }
}

/**
 * Determines whether this operation is adding a new hard line break. This type of change produces
 * unmergable [TextUndoOperation].
 */
private val TextUndoOperation.isNewLineInsert
    get() = this.postText == "\n" || this.postText == "\r\n"

private const val TEXT_UNDO_CAPACITY = 100




© 2015 - 2025 Weber Informatics LLC | Privacy Policy