Merge "Improvements to TextView Ctrl-Z undo support"

This commit is contained in:
James Cook
2015-02-23 20:31:13 +00:00
committed by Android (Google) Code Review

View File

@@ -4198,6 +4198,13 @@ public class Editor {
int mChangedStart, mChangedEnd, mChangedDelta;
}
/**
* @return True iff (start, end) is a valid range within the text.
*/
private static boolean isValidRange(CharSequence text, int start, int end) {
return 0 <= start && start <= end && end <= text.length();
}
/**
* An InputFilter that monitors text input to maintain undo history. It does not modify the
* text being typed (and hence always returns null from the filter() method).
@@ -4213,97 +4220,123 @@ public class Editor {
public CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend) {
if (DEBUG_UNDO) {
Log.d(TAG, "filter: source=" + source + " (" + start + "-" + end + ")");
Log.d(TAG, "filter: dest=" + dest + " (" + dstart + "-" + dend + ")");
Log.d(TAG, "filter: source=" + source + " (" + start + "-" + end + ") " +
"dest=" + dest + " (" + dstart + "-" + dend + ")");
}
final UndoManager um = mEditor.mUndoManager;
if (um.isInUndo()) {
if (DEBUG_UNDO) Log.d(TAG, "*** skipping, currently performing undo/redo");
if (DEBUG_UNDO) Log.d(TAG, "filter: skipping, currently performing undo/redo");
return null;
}
// Text filters run before input operations are applied. However, some input operations
// are invalid and will throw exceptions when applied. This is common in tests. Don't
// attempt to undo invalid operations.
if (!isValidRange(source, start, end) || !isValidRange(dest, dstart, dend)) {
if (DEBUG_UNDO) Log.d(TAG, "filter: invalid op");
return null;
}
// Earlier filters can rewrite input to be a no-op, for example due to a length limit
// on an input field. Skip no-op changes.
if (start == end && dstart == dend) {
if (DEBUG_UNDO) Log.d(TAG, "filter: skipping no-op");
return null;
}
// Build a new operation with all the information from this edit.
EditOperation edit = new EditOperation(mEditor, source, start, end, dest, dstart, dend);
// Fetch the last edit operation and attempt to merge in the new edit.
um.beginUpdate("Edit text");
TextModifyOperation op = um.getLastOperation(
TextModifyOperation.class, mEditor.mUndoOwner, UndoManager.MERGE_MODE_UNIQUE);
if (op != null) {
if (DEBUG_UNDO) Log.d(TAG, "Last op: range=(" + op.mRangeStart + "-" + op.mRangeEnd
+ "), oldText=" + op.mOldText);
// See if we can continue modifying this operation.
if (op.mOldText == null) {
// The current operation is an add... are we adding more? We are adding
// more if we are either appending new text to the end of the last edit or
// completely replacing some or all of the last edit.
// TODO: This sequence doesn't work right: a, left-arrow, b, undo, undo.
// The two edits are incorrectly merged, so there is only one undo available.
if (start < end && ((dstart >= op.mRangeStart && dend <= op.mRangeEnd)
|| (dstart == op.mRangeEnd && dend == op.mRangeEnd))) {
op.mRangeEnd = dstart + (end-start);
um.endUpdate();
if (DEBUG_UNDO) Log.d(TAG, "*** merging with last op, mRangeEnd="
+ op.mRangeEnd);
return null;
}
} else {
// The current operation is a delete... can we delete more?
if (start == end && dend == op.mRangeStart-1) {
SpannableStringBuilder str;
if (op.mOldText instanceof SpannableString) {
str = (SpannableStringBuilder)op.mOldText;
} else {
str = new SpannableStringBuilder(op.mOldText);
}
str.insert(0, dest, dstart, dend);
op.mRangeStart = dstart;
op.mOldText = str;
um.endUpdate();
if (DEBUG_UNDO) Log.d(TAG, "*** merging with last op, range=("
+ op.mRangeStart + "-" + op.mRangeEnd
+ "), oldText=" + op.mOldText);
return null;
}
}
// Couldn't add to the current undo operation, need to start a new
// undo state for a new undo operation.
um.commitState(null);
um.setUndoLabel("Edit text");
}
// Create a new undo state reflecting the operation being performed.
op = new TextModifyOperation(mEditor.mUndoOwner);
op.mRangeStart = dstart;
if (start < end) {
op.mRangeEnd = dstart + (end-start);
EditOperation lastEdit = um.getLastOperation(
EditOperation.class, mEditor.mUndoOwner, UndoManager.MERGE_MODE_UNIQUE);
if (lastEdit == null) {
// Add this as the first edit.
if (DEBUG_UNDO) Log.d(TAG, "filter: adding first op " + edit);
um.addOperation(edit, UndoManager.MERGE_MODE_NONE);
} else if (lastEdit.mergeWith(edit)) {
// Merge succeeded, nothing else to do.
if (DEBUG_UNDO) Log.d(TAG, "filter: merge succeeded, created " + lastEdit);
} else {
op.mRangeEnd = dstart;
// Could not merge with the last edit, so commit the last edit and add this edit.
if (DEBUG_UNDO) Log.d(TAG, "filter: merge failed, adding " + edit);
um.commitState(mEditor.mUndoOwner);
um.addOperation(edit, UndoManager.MERGE_MODE_NONE);
}
if (dstart < dend) {
op.mOldText = dest.subSequence(dstart, dend);
}
if (DEBUG_UNDO) Log.d(TAG, "*** adding new op, range=(" + op.mRangeStart
+ "-" + op.mRangeEnd + "), oldText=" + op.mOldText);
um.addOperation(op, UndoManager.MERGE_MODE_NONE);
um.endUpdate();
return null;
return null; // Text not changed.
}
}
/**
* An operation to undo a single "edit" to a text view.
*/
public static class TextModifyOperation extends UndoOperation<Editor> {
int mRangeStart, mRangeEnd;
CharSequence mOldText;
public static class EditOperation extends UndoOperation<Editor> {
private static final int TYPE_INSERT = 0;
private static final int TYPE_DELETE = 1;
private static final int TYPE_REPLACE = 2;
public TextModifyOperation(UndoOwner owner) {
super(owner);
private int mType;
private String mOldText;
private int mOldTextStart;
private String mNewText;
private int mNewTextStart;
private int mOldCursorPos;
private int mNewCursorPos;
/**
* Constructs an edit operation from a text input operation that replaces the range
* (dstart, dend) of dest with (start, end) of source. See {@link InputFilter#filter}.
*/
public EditOperation(Editor editor, CharSequence source, int start, int end,
Spanned dest, int dstart, int dend) {
super(editor.mUndoOwner);
mOldText = dest.subSequence(dstart, dend).toString();
mNewText = source.subSequence(start, end).toString();
// Determine the type of the edit and store where it occurred. Avoid storing
// irrevelant data (e.g. mNewTextStart for a delete) because that makes the
// merging logic more complex (e.g. merging deletes could lead to mNewTextStart being
// outside the bounds of the final text).
if (mNewText.length() > 0 && mOldText.length() == 0) {
mType = TYPE_INSERT;
mNewTextStart = dstart;
} else if (mNewText.length() == 0 && mOldText.length() > 0) {
mType = TYPE_DELETE;
mOldTextStart = dstart;
} else {
mType = TYPE_REPLACE;
mOldTextStart = mNewTextStart = dstart;
}
// Store cursor data.
mOldCursorPos = editor.mTextView.getSelectionStart();
mNewCursorPos = dstart + (end - start);
}
public TextModifyOperation(Parcel src, ClassLoader loader) {
public EditOperation(Parcel src, ClassLoader loader) {
super(src, loader);
mRangeStart = src.readInt();
mRangeEnd = src.readInt();
mOldText = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(src);
mType = src.readInt();
mOldText = src.readString();
mOldTextStart = src.readInt();
mNewText = src.readString();
mNewTextStart = src.readInt();
mOldCursorPos = src.readInt();
mNewCursorPos = src.readInt();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(mType);
dest.writeString(mOldText);
dest.writeInt(mOldTextStart);
dest.writeString(mNewText);
dest.writeInt(mNewTextStart);
dest.writeInt(mOldCursorPos);
dest.writeInt(mNewCursorPos);
}
@Override
@@ -4312,62 +4345,139 @@ public class Editor {
@Override
public void undo() {
swapText();
if (DEBUG_UNDO) Log.d(TAG, "undo");
// Remove the new text and insert the old.
modifyText(mNewTextStart, getNewTextEnd(), mOldText, mOldTextStart, mOldCursorPos);
}
@Override
public void redo() {
swapText();
if (DEBUG_UNDO) Log.d(TAG, "redo");
// Remove the old text and insert the new.
modifyText(mOldTextStart, getOldTextEnd(), mNewText, mNewTextStart, mNewCursorPos);
}
private void swapText() {
// Both undo and redo involves swapping the contents of the range
// in the text view with our local text.
/**
* Attempts to merge this existing operation with a new edit.
* @param edit The new edit operation.
* @return If the merge succeeded, returns true. Otherwise returns false and leaves this
* object unchanged.
*/
private boolean mergeWith(EditOperation edit) {
switch (mType) {
case TYPE_INSERT:
return mergeInsertWith(edit);
case TYPE_DELETE:
return mergeDeleteWith(edit);
case TYPE_REPLACE:
return mergeReplaceWith(edit);
default:
return false;
}
}
private boolean mergeInsertWith(EditOperation edit) {
if (DEBUG_UNDO) Log.d(TAG, "mergeInsertWith " + edit);
// Only merge continuous insertions.
if (edit.mType != TYPE_INSERT) {
return false;
}
// Only merge insertions that are contiguous.
if (getNewTextEnd() != edit.mNewTextStart) {
return false;
}
mNewText += edit.mNewText;
mNewCursorPos = edit.mNewCursorPos;
return true;
}
// TODO: Support forward delete.
private boolean mergeDeleteWith(EditOperation edit) {
if (DEBUG_UNDO) Log.d(TAG, "mergeDeleteWith " + edit);
// Only merge continuous deletes.
if (edit.mType != TYPE_DELETE) {
return false;
}
// Only merge deletions that are contiguous.
if (mOldTextStart != edit.getOldTextEnd()) {
return false;
}
mOldTextStart = edit.mOldTextStart;
mOldText = edit.mOldText + mOldText;
mNewCursorPos = edit.mNewCursorPos;
return true;
}
private boolean mergeReplaceWith(EditOperation edit) {
if (DEBUG_UNDO) Log.d(TAG, "mergeReplaceWith " + edit);
// Replacements can merge only with adjacent inserts and adjacent replacements.
if (edit.mType == TYPE_DELETE ||
getNewTextEnd() != edit.mOldTextStart ||
edit.mOldTextStart != edit.mNewTextStart) {
return false;
}
mOldText += edit.mOldText;
mNewText += edit.mNewText;
mNewCursorPos = edit.mNewCursorPos;
return true;
}
private int getNewTextEnd() {
return mNewTextStart + mNewText.length();
}
private int getOldTextEnd() {
return mOldTextStart + mOldText.length();
}
private void modifyText(int deleteFrom, int deleteTo, CharSequence newText,
int newTextInsertAt, int newCursorPos) {
Editor editor = getOwnerData();
Editable editable = (Editable)editor.mTextView.getText();
CharSequence curText;
if (mRangeStart >= mRangeEnd) {
curText = null;
} else {
curText = editable.subSequence(mRangeStart, mRangeEnd);
Editable text = (Editable) editor.mTextView.getText();
// Apply the edit if it is still valid.
if (isValidRange(text, deleteFrom, deleteTo) &&
newTextInsertAt <= text.length() - (deleteTo - deleteFrom)) {
if (deleteFrom != deleteTo) {
text.delete(deleteFrom, deleteTo);
}
if (newText.length() != 0) {
text.insert(newTextInsertAt, newText);
}
}
if (DEBUG_UNDO) {
Log.d(TAG, "Swap: range=(" + mRangeStart + "-" + mRangeEnd
+ "), oldText=" + mOldText);
Log.d(TAG, "Swap: curText=" + curText);
// Restore the cursor position.
// TODO: Select all the text that was undone.
if (newCursorPos <= text.length()) {
Selection.setSelection(text, newCursorPos);
}
if (mOldText == null) {
editable.delete(mRangeStart, mRangeEnd);
mRangeEnd = mRangeStart;
} else {
editable.replace(mRangeStart, mRangeEnd, mOldText);
mRangeEnd = mRangeStart + mOldText.length();
}
mOldText = curText;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(mRangeStart);
dest.writeInt(mRangeEnd);
TextUtils.writeToParcel(mOldText, dest, flags);
public String toString() {
return "EditOperation: [" +
"mType=" + mType + ", " +
"mOldText=" + mOldText + ", " +
"mOldTextStart=" + mOldTextStart + ", " +
"mNewText=" + mNewText + ", " +
"mNewTextStart=" + mNewTextStart + ", " +
"mOldCursorPos=" + mOldCursorPos + ", " +
"mNewCursorPos=" + mNewCursorPos + "]";
}
public static final Parcelable.ClassLoaderCreator<TextModifyOperation> CREATOR
= new Parcelable.ClassLoaderCreator<TextModifyOperation>() {
public static final Parcelable.ClassLoaderCreator<EditOperation> CREATOR
= new Parcelable.ClassLoaderCreator<EditOperation>() {
@Override
public TextModifyOperation createFromParcel(Parcel in) {
return new TextModifyOperation(in, null);
public EditOperation createFromParcel(Parcel in) {
return new EditOperation(in, null);
}
@Override
public TextModifyOperation createFromParcel(Parcel in, ClassLoader loader) {
return new TextModifyOperation(in, loader);
public EditOperation createFromParcel(Parcel in, ClassLoader loader) {
return new EditOperation(in, loader);
}
@Override
public TextModifyOperation[] newArray(int size) {
return new TextModifyOperation[size];
public EditOperation[] newArray(int size) {
return new EditOperation[size];
}
};
}