1 /*
2 * Copyright 2023 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.internal.undo
18
19 import androidx.compose.foundation.text.input.TextFieldState
20 import androidx.compose.foundation.text.input.setSelectionCoerced
21 import androidx.compose.foundation.text.timeNowMillis
22 import androidx.compose.runtime.saveable.Saver
23 import androidx.compose.runtime.saveable.SaverScope
24 import androidx.compose.ui.text.TextRange
25
26 /**
27 * An undo identifier designed for text editors. Defines a single atomic change that can be applied
28 * directly or in reverse to modify the contents of a text editor.
29 *
30 * @param index Start point of [preText] and [postText].
31 * @param preText Previously written text that's deleted starting from [index].
32 * @param postText New text that's inserted at [index]
33 * @param preSelection Previous selection before changes are applied
34 * @param postSelection New selection after changes are applied
35 * @param timeInMillis When did this change was first committed
36 * @param canMerge Whether this change can be merged with the next or previous change in an undo
37 * stack. There are many other rules that affect the merging strategy between two
38 * [TextUndoOperation]s but this flag is a sure way to force a non-mergeable property.
39 */
40 internal class TextUndoOperation(
41 val index: Int,
42 val preText: String,
43 val postText: String,
44 val preSelection: TextRange,
45 val postSelection: TextRange,
46 val timeInMillis: Long = timeNowMillis(),
47 val canMerge: Boolean = true
48 ) {
49
50 /**
51 * What kind of edit operation is defined by this change. Edit type is decided by forward the
52 * behavior of this change in forward direction (pre -> post).
53 */
54 val textEditType: TextEditType =
55 when {
56 preText.isEmpty() && postText.isEmpty() ->
57 throw IllegalArgumentException("Either pre or post text must not be empty")
58 preText.isEmpty() && postText.isNotEmpty() -> TextEditType.Insert
59 preText.isNotEmpty() && postText.isEmpty() -> TextEditType.Delete
60 else -> TextEditType.Replace
61 }
62
63 /** Only required while deciding whether to merge two deletion type undo operations. */
64 val deletionType: TextDeleteType
65 get() {
66 if (textEditType != TextEditType.Delete) return TextDeleteType.NotByUser
67 if (!postSelection.collapsed) return TextDeleteType.NotByUser
68 if (preSelection.collapsed) {
69 return if (preSelection.start > postSelection.start) {
70 TextDeleteType.Start
71 } else {
72 TextDeleteType.End
73 }
74 } else if (preSelection.start == postSelection.start && preSelection.start == index) {
75 return TextDeleteType.Inner
76 }
77 return TextDeleteType.NotByUser
78 }
79
80 companion object {
81
82 val Saver =
83 object : Saver<TextUndoOperation, Any> {
savenull84 override fun SaverScope.save(value: TextUndoOperation): Any =
85 listOf(
86 value.index,
87 value.preText,
88 value.postText,
89 value.preSelection.start,
90 value.preSelection.end,
91 value.postSelection.start,
92 value.postSelection.end,
93 value.timeInMillis
94 )
95
96 override fun restore(value: Any): TextUndoOperation {
97 return with((value as List<*>)) {
98 TextUndoOperation(
99 index = get(0) as Int,
100 preText = get(1) as String,
101 postText = get(2) as String,
102 preSelection = TextRange(get(3) as Int, get(4) as Int),
103 postSelection = TextRange(get(5) as Int, get(6) as Int),
104 timeInMillis = get(7) as Long,
105 )
106 }
107 }
108 }
109 }
110 }
111
112 /** Apply a given [TextUndoOperation] in reverse to undo this [TextFieldState]. */
undonull113 internal fun TextFieldState.undo(op: TextUndoOperation) {
114 editWithNoSideEffects {
115 replace(op.index, op.index + op.postText.length, op.preText)
116 setSelectionCoerced(op.preSelection.start, op.preSelection.end)
117 }
118 }
119
120 /** Apply a given [TextUndoOperation] in forward direction to redo this [TextFieldState]. */
redonull121 internal fun TextFieldState.redo(op: TextUndoOperation) {
122 editWithNoSideEffects {
123 replace(op.index, op.index + op.preText.length, op.postText)
124 setSelectionCoerced(op.postSelection.start, op.postSelection.end)
125 }
126 }
127
128 /**
129 * Possible types of a text operation.
130 * 1. Insert; if the edited range has 0 length, and the new text is longer than 0 length
131 * 2. Delete: if the edited range is longer than 0, and the new text has 0 length
132 * 3. Replace: All other changes.
133 */
134 internal enum class TextEditType {
135 Insert,
136 Delete,
137 Replace
138 }
139
140 /**
141 * When a delete occurs during text editing, it can happen in various shapes.
142 * 1. Start; When a single character is removed to the start (towards 0) of the cursor, backspace
143 * key behavior. "abcd|efg" -> "abc|efg"
144 * 2. End; When a single character is removed to the end (towards length) of the cursor, delete key
145 * behavior. "abcd|efg" -> "abcd|fg"
146 * 3. Inner; When a selection of characters are removed, directionless. Both backspace and delete
147 * express the same behavior in this case. "ab|cde|fg" -> "ab|fg"
148 * 4. NotByUser; A text editing operation that cannot be executed via a hardware or software
149 * keyboard. For example when a portion of text is removed but it's not next to a cursor or
150 * selection, or selection remains after removal. "abcd|efg" -> "bcd|efg" "abc|def|g" -> "a|bc|g"
151 */
152 internal enum class TextDeleteType {
153 Start,
154 End,
155 Inner,
156 NotByUser
157 }
158
159 /**
160 * There are multiple strategies while deciding how to add certain edit operations to undo stack.
161 * - Normally, merge is decided by UndoOperation's own merge logic, comparing itself to the latest
162 * operation in the Undo stack.
163 * - Programmatic updates should clear the history completely.
164 * - Some atomic actions like cut, and paste shouldn't merge to previous or next actions.
165 */
166 internal enum class TextFieldEditUndoBehavior {
167 MergeIfPossible,
168 ClearHistory,
169 NeverMerge
170 }
171