1 /*
2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.foundation.text.input
18 
19 import androidx.compose.foundation.text.SNAPSHOTS_INTERVAL_MILLIS
20 import androidx.compose.foundation.text.input.internal.undo.TextDeleteType
21 import androidx.compose.foundation.text.input.internal.undo.TextEditType
22 import androidx.compose.foundation.text.input.internal.undo.TextUndoOperation
23 import androidx.compose.foundation.text.input.internal.undo.UndoManager
24 import androidx.compose.foundation.text.input.internal.undo.redo
25 import androidx.compose.foundation.text.input.internal.undo.undo
26 import androidx.compose.runtime.getValue
27 import androidx.compose.runtime.mutableStateOf
28 import androidx.compose.runtime.saveable.SaverScope
29 import androidx.compose.runtime.setValue
30 import androidx.compose.runtime.snapshots.Snapshot
31 import androidx.compose.ui.text.substring
32 
33 /**
34  * An undo manager designed specifically for text editing. This class is mostly responsible for
35  * delegating the incoming undo/redo/record/clear requests to its own [undoManager]. Its most
36  * important task is to keep a separate staging area for incoming text edit operations to possibly
37  * merge them before committing as a single undoable action.
38  */
39 internal class TextUndoManager(
40     initialStagingUndo: TextUndoOperation? = null,
41     private val undoManager: UndoManager<TextUndoOperation> =
42         UndoManager(capacity = TEXT_UNDO_CAPACITY)
43 ) {
44     private var stagingUndo by mutableStateOf<TextUndoOperation?>(initialStagingUndo)
45 
46     val canUndo: Boolean
47         get() = undoManager.canUndo || stagingUndo != null
48 
49     val canRedo: Boolean
50         get() = undoManager.canRedo && stagingUndo == null
51 
undonull52     fun undo(state: TextFieldState) {
53         if (!canUndo) {
54             return
55         }
56 
57         flush()
58         state.undo(undoManager.undo())
59     }
60 
redonull61     fun redo(state: TextFieldState) {
62         if (!canRedo) {
63             return
64         }
65 
66         state.redo(undoManager.redo())
67     }
68 
recordnull69     fun record(op: TextUndoOperation) {
70         val unobservedStagingUndo = Snapshot.withoutReadObservation { stagingUndo }
71         if (unobservedStagingUndo == null) {
72             stagingUndo = op
73             return
74         }
75 
76         val mergedOp = unobservedStagingUndo.merge(op)
77 
78         if (mergedOp != null) {
79             // merged operation should replace the top op
80             stagingUndo = mergedOp
81             return
82         }
83 
84         // no merge, flush the staging area and put the new operation in
85         flush()
86         stagingUndo = op
87     }
88 
clearHistorynull89     fun clearHistory() {
90         stagingUndo = null
91         undoManager.clearHistory()
92     }
93 
flushnull94     private fun flush() {
95         val unobservedStagingUndo = Snapshot.withoutReadObservation { stagingUndo }
96         unobservedStagingUndo?.let { undoManager.record(it) }
97         stagingUndo = null
98     }
99 
100     companion object {
101         object Saver : androidx.compose.runtime.saveable.Saver<TextUndoManager, Any> {
102             private val undoManagerSaver = UndoManager.createSaver(TextUndoOperation.Saver)
103 
savenull104             override fun SaverScope.save(value: TextUndoManager): Any {
105                 return listOf(
106                     value.stagingUndo?.let { with(TextUndoOperation.Saver) { save(it) } },
107                     with(undoManagerSaver) { save(value.undoManager) }
108                 )
109             }
110 
restorenull111             override fun restore(value: Any): TextUndoManager? {
112                 val (savedStagingUndo, savedUndoManager) = value as List<*>
113                 return TextUndoManager(
114                     savedStagingUndo?.let { with(TextUndoOperation.Saver) { restore(it) } },
115                     with(undoManagerSaver) { restore(savedUndoManager!!) }!!
116                 )
117             }
118         }
119     }
120 }
121 
122 /**
123  * Try to merge this [TextUndoOperation] with the [next]. Chronologically the [next] op must come
124  * after this one. If merge is not possible, this function returns null.
125  *
126  * There are many rules that govern the grouping logic of successive undo operations. Here we try to
127  * cover the most basic requirements but this is certainly not an exhaustive list.
128  * 1. Each action defines whether they can be merged at all. For example, text edits that are caused
129  *    by cut or paste define themselves as unmergeable no matter what comes before or next.
130  * 2. If certain amount of time has passed since the latest grouping has begun.
131  * 3. Enter key (hard line break) is unmergeable.
132  * 4. Only same type of text edits can be merged. An insertion must be grouped by other insertions,
133  *    a deletion by other deletions. Replace type of edit is never mergeable. 4.a. Two insertions
134  *    can only be merged if the chronologically next one is a suffix of the previous insertion. In
135  *    other words, cursor should always be moving forwards. 4.b. Deletions have directionality.
136  *    Cursor can only insert in place and move forwards but deletion can be requested either
137  *    forwards (delete) or backwards (backspace). Only deletions that have the same direction can be
138  *    merged. They also have to share a boundary.
139  */
mergenull140 internal fun TextUndoOperation.merge(next: TextUndoOperation): TextUndoOperation? {
141     if (!canMerge || !next.canMerge) return null
142     // Do not merge if [other] came before this op, or if certain amount of time has passed
143     // between these ops
144     if (
145         next.timeInMillis < timeInMillis ||
146             next.timeInMillis - timeInMillis >= SNAPSHOTS_INTERVAL_MILLIS
147     )
148         return null
149     // Do not merge undo history when one of the ops is a new line insertion
150     if (isNewLineInsert || next.isNewLineInsert) return null
151     // Only same type of ops can be merged together
152     if (textEditType != next.textEditType) return null
153 
154     // only merge insertions if the chronologically next one continuous from the end of
155     // this previous insertion
156     if (textEditType == TextEditType.Insert && index + postText.length == next.index) {
157         return TextUndoOperation(
158             index = index,
159             preText = "",
160             postText = postText + next.postText,
161             preSelection = this.preSelection,
162             postSelection = next.postSelection,
163             timeInMillis = timeInMillis
164         )
165     } else if (textEditType == TextEditType.Delete) {
166         // only merge consecutive deletions if both have the same directionality
167         if (
168             deletionType == next.deletionType &&
169                 (deletionType == TextDeleteType.Start || deletionType == TextDeleteType.End)
170         ) {
171             // This op deletes
172             if (index == next.index + next.preText.length) {
173                 return TextUndoOperation(
174                     index = next.index,
175                     preText = next.preText + preText,
176                     postText = "",
177                     preSelection = preSelection,
178                     postSelection = next.postSelection,
179                     timeInMillis = timeInMillis
180                 )
181             } else if (index == next.index) {
182                 return TextUndoOperation(
183                     index = index,
184                     preText = preText + next.preText,
185                     postText = "",
186                     preSelection = preSelection,
187                     postSelection = next.postSelection,
188                     timeInMillis = timeInMillis
189                 )
190             }
191         }
192     }
193     return null
194 }
195 
196 /**
197  * Adds the [changes] to this [UndoManager] by converting from [TextFieldBuffer.ChangeList] space to
198  * [TextUndoOperation] space.
199  *
200  * @param pre State of the [TextFieldBuffer] before any changes are applied
201  * @param post State of the [TextFieldBuffer] after all the changes are applied
202  * @param changes List of changes that are applied on [pre] that transforms it to [post].
203  * @param allowMerge Whether to allow merging the calculated operation with the last operation in
204  *   the stack.
205  */
recordChangesnull206 internal fun TextUndoManager.recordChanges(
207     pre: TextFieldCharSequence,
208     post: TextFieldCharSequence,
209     changes: TextFieldBuffer.ChangeList,
210     allowMerge: Boolean = true
211 ) {
212     // if there are unmerged changes coming from a single edit, force merge all of them to
213     // create a single replace undo operation
214     if (changes.changeCount > 1) {
215         record(
216             TextUndoOperation(
217                 index = 0,
218                 preText = pre.toString(),
219                 postText = post.toString(),
220                 preSelection = pre.selection,
221                 postSelection = post.selection,
222                 canMerge = false
223             )
224         )
225     } else if (changes.changeCount == 1) {
226         val preRange = changes.getOriginalRange(0)
227         val postRange = changes.getRange(0)
228         if (!preRange.collapsed || !postRange.collapsed) {
229             record(
230                 TextUndoOperation(
231                     index = preRange.min,
232                     preText = pre.substring(preRange),
233                     postText = post.substring(postRange),
234                     preSelection = pre.selection,
235                     postSelection = post.selection,
236                     canMerge = allowMerge
237                 )
238             )
239         }
240     }
241 }
242 
243 /**
244  * Determines whether this operation is adding a new hard line break. This type of change produces
245  * unmergable [TextUndoOperation].
246  */
247 private val TextUndoOperation.isNewLineInsert
248     get() = this.postText == "\n" || this.postText == "\r\n"
249 
250 private const val TEXT_UNDO_CAPACITY = 100
251