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