diff options
author | 2013-04-26 16:39:17 -0700 | |
---|---|---|
committer | 2013-05-07 15:05:09 -0700 | |
commit | 3aa49b6fece334ace7525d42c1f6d0b7cdc1fbfb (patch) | |
tree | 8b04d1e7dff2b31f68a60bc43949344f24ba564a | |
parent | 2ae118d1077bcec1c407b6ecb9acf14e2152fe93 (diff) |
New UndoManager.
Basic implementation of an undo manager. Supports
multi-level undo/redo, building on the top undo state
as edits occur, managing multiple distinct entities in
the undo state (such as embedded objects in a document),
and saving/restoring the full undo state. Still some
work remaining on correctly dealing with dependencies
between undo states that hold multiple owners.
Also do a simple implementation of undo state in TextView
to see how things actually work. The implementation here
is very primitive: it needs a lot more work to correctly
identify when to merge undo ops, is not trying to do
anything smart with style spans, etc.
Change-Id: Ie30f4e133351e2f569ffb48c6c44a2b19cadee27
-rw-r--r-- | api/current.txt | 55 | ||||
-rw-r--r-- | core/java/android/content/UndoManager.java | 932 | ||||
-rw-r--r-- | core/java/android/content/UndoOperation.java | 110 | ||||
-rw-r--r-- | core/java/android/content/UndoOwner.java | 55 | ||||
-rw-r--r-- | core/java/android/os/ParcelableParcel.java | 75 | ||||
-rw-r--r-- | core/java/android/widget/Editor.java | 178 | ||||
-rw-r--r-- | core/java/android/widget/TextView.java | 72 |
7 files changed, 1467 insertions, 10 deletions
diff --git a/api/current.txt b/api/current.txt index 5de6d3d60e8d..6c52a175fab0 100644 --- a/api/current.txt +++ b/api/current.txt @@ -6610,6 +6610,59 @@ package android.content { method public abstract void onStatusChanged(int); } + public class UndoManager { + ctor public UndoManager(); + method public void addOperation(android.content.UndoOperation<?>, int); + method public void beginUpdate(java.lang.CharSequence); + method public int commitState(android.content.UndoOwner); + method public int countRedos(android.content.UndoOwner[]); + method public int countUndos(android.content.UndoOwner[]); + method public void endUpdate(); + method public int forgetRedos(android.content.UndoOwner[], int); + method public int forgetUndos(android.content.UndoOwner[], int); + method public int getHistorySize(); + method public android.content.UndoOperation<?> getLastOperation(int); + method public android.content.UndoOperation<?> getLastOperation(android.content.UndoOwner, int); + method public T getLastOperation(java.lang.Class<T>, android.content.UndoOwner, int); + method public android.content.UndoOwner getOwner(java.lang.String, java.lang.Object); + method public java.lang.CharSequence getRedoLabel(android.content.UndoOwner[]); + method public java.lang.CharSequence getUndoLabel(android.content.UndoOwner[]); + method public int getUpdateNestingLevel(); + method public boolean hasOperation(android.content.UndoOwner); + method public boolean isInUndo(); + method public boolean isInUpdate(); + method public int redo(android.content.UndoOwner[], int); + method public void restoreInstanceState(android.os.Parcelable); + method public android.os.Parcelable saveInstanceState(); + method public void setHistorySize(int); + method public void setUndoLabel(java.lang.CharSequence); + method public void suggestUndoLabel(java.lang.CharSequence); + method public boolean uncommitState(int, android.content.UndoOwner); + method public int undo(android.content.UndoOwner[], int); + field public static final int MERGE_MODE_ANY = 2; // 0x2 + field public static final int MERGE_MODE_NONE = 0; // 0x0 + field public static final int MERGE_MODE_UNIQUE = 1; // 0x1 + } + + public abstract class UndoOperation implements android.os.Parcelable { + ctor public UndoOperation(android.content.UndoOwner); + ctor protected UndoOperation(android.os.Parcel, java.lang.ClassLoader); + method public boolean allowMerge(); + method public abstract void commit(); + method public int describeContents(); + method public android.content.UndoOwner getOwner(); + method public DATA getOwnerData(); + method public boolean hasData(); + method public boolean matchOwner(android.content.UndoOwner); + method public abstract void redo(); + method public abstract void undo(); + } + + public class UndoOwner { + method public java.lang.Object getData(); + method public java.lang.String getTag(); + } + public class UriMatcher { ctor public UriMatcher(int); method public void addURI(java.lang.String, java.lang.String, int); @@ -30873,6 +30926,7 @@ package android.widget { method public int getTotalPaddingTop(); method public final android.text.method.TransformationMethod getTransformationMethod(); method public android.graphics.Typeface getTypeface(); + method public final android.content.UndoManager getUndoManager(); method public android.text.style.URLSpan[] getUrls(); method public boolean hasSelection(); method public boolean isCursorVisible(); @@ -30971,6 +31025,7 @@ package android.widget { method public final void setTransformationMethod(android.text.method.TransformationMethod); method public void setTypeface(android.graphics.Typeface, int); method public void setTypeface(android.graphics.Typeface); + method public final void setUndoManager(android.content.UndoManager, java.lang.String); method public void setWidth(int); } diff --git a/core/java/android/content/UndoManager.java b/core/java/android/content/UndoManager.java new file mode 100644 index 000000000000..1c2db47ad7d6 --- /dev/null +++ b/core/java/android/content/UndoManager.java @@ -0,0 +1,932 @@ +/* + * Copyright (C) 2013 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 android.content; + +import android.os.Parcel; +import android.os.Parcelable; +import android.os.ParcelableParcel; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.HashMap; + +/** + * Top-level class for managing and interacting with the global undo state for + * a document or application. This class supports both undo and redo and has + * helpers for merging undoable operations together as they are performed. + * + * <p>A single undoable operation is represented by {@link UndoOperation} which + * apps implement to define their undo/redo behavior. The UndoManager keeps + * a stack of undo states; each state can have one or more undo operations + * inside of it.</p> + * + * <p>Updates to the stack must be done inside of a {@link #beginUpdate}/{@link #endUpdate()} + * pair. During this time you can add new operations to the stack with + * {@link #addOperation}, retrieve and modify existing operations with + * {@link #getLastOperation}, control the label shown to the user for this operation + * with {@link #setUndoLabel} and {@link #suggestUndoLabel}, etc.</p> + * + * <p>Every {link UndoOperation} is associated with an {@link UndoOwner}, which identifies + * the data it belongs to. The owner is used to indicate how operations are dependent + * on each other -- operations with the same owner are dependent on others with the + * same owner. For example, you may have a document with multiple embedded objects. If the + * document itself and each embedded object use different owners, then you + * can provide undo semantics appropriate to the user's context: while within + * an embedded object, only edits to that object are seen and the user can + * undo/redo them without needing to impact edits in other objects; while + * within the larger document, all edits can be seen and the user must + * undo/redo them as a single stream.</p> + */ +public class UndoManager { + private final HashMap<String, UndoOwner> mOwners = new HashMap<String, UndoOwner>(); + private final ArrayList<UndoState> mUndos = new ArrayList<UndoState>(); + private final ArrayList<UndoState> mRedos = new ArrayList<UndoState>(); + private int mUpdateCount; + private int mHistorySize = 20; + private UndoState mWorking; + private int mCommitId = 1; + private boolean mInUndo; + private boolean mMerged; + + private int mStateSeq; + private int mNextSavedIdx; + private UndoOwner[] mStateOwners; + + /** + * Never merge with the last undo state. + */ + public static final int MERGE_MODE_NONE = 0; + + /** + * Allow merge with the last undo state only if it contains + * operations with the caller's owner. + */ + public static final int MERGE_MODE_UNIQUE = 1; + + /** + * Always allow merge with the last undo state, if possible. + */ + public static final int MERGE_MODE_ANY = 2; + + public UndoOwner getOwner(String tag, Object data) { + if (tag == null) { + throw new NullPointerException("tag can't be null"); + } + if (data == null) { + throw new NullPointerException("data can't be null"); + } + UndoOwner owner = mOwners.get(tag); + if (owner != null) { + if (owner.mData != data) { + if (owner.mData != null) { + throw new IllegalStateException("Owner " + owner + " already exists with data " + + owner.mData + " but giving different data " + data); + } + owner.mData = data; + } + return owner; + } + + owner = new UndoOwner(tag); + owner.mManager = this; + owner.mData = data; + mOwners.put(tag, owner); + return owner; + } + + void removeOwner(UndoOwner owner) { + // XXX need to figure out how to prune. + if (false) { + mOwners.remove(owner.mTag); + owner.mManager = null; + } + } + + /** + * Flatten the current undo state into a Parcelable object, which can later be restored + * with {@link #restoreInstanceState(android.os.Parcelable)}. + */ + public Parcelable saveInstanceState() { + if (mUpdateCount > 0) { + throw new IllegalStateException("Can't save state while updating"); + } + ParcelableParcel pp = new ParcelableParcel(getClass().getClassLoader()); + Parcel p = pp.getParcel(); + mStateSeq++; + if (mStateSeq <= 0) { + mStateSeq = 0; + } + mNextSavedIdx = 0; + p.writeInt(mHistorySize); + p.writeInt(mOwners.size()); + // XXX eventually we need to be smart here about limiting the + // number of undo states we write to not exceed X bytes. + int i = mUndos.size(); + while (i > 0) { + p.writeInt(1); + i--; + mUndos.get(i).writeToParcel(p); + } + i = mRedos.size(); + p.writeInt(i); + while (i > 0) { + p.writeInt(2); + i--; + mRedos.get(i).writeToParcel(p); + } + p.writeInt(0); + return pp; + } + + void saveOwner(UndoOwner owner, Parcel out) { + if (owner.mStateSeq == mStateSeq) { + out.writeInt(owner.mSavedIdx); + } else { + owner.mStateSeq = mStateSeq; + owner.mSavedIdx = mNextSavedIdx; + out.writeInt(owner.mSavedIdx); + out.writeString(owner.mTag); + mNextSavedIdx++; + } + } + + /** + * Restore an undo state previously created with {@link #saveInstanceState()}. This will + * restore the UndoManager's state to almost exactly what it was at the point it had + * been previously saved; the only information not restored is the data object + * associated with each {@link UndoOwner}, which requires separate calls to + * {@link #getOwner(String, Object)} to re-associate the owner with its data. + */ + public void restoreInstanceState(Parcelable state) { + if (mUpdateCount > 0) { + throw new IllegalStateException("Can't save state while updating"); + } + forgetUndos(null, -1); + forgetRedos(null, -1); + ParcelableParcel pp = (ParcelableParcel)state; + Parcel p = pp.getParcel(); + mHistorySize = p.readInt(); + mStateOwners = new UndoOwner[p.readInt()]; + + int stype; + while ((stype=p.readInt()) != 0) { + UndoState ustate = new UndoState(this, p, pp.getClassLoader()); + if (stype == 1) { + mUndos.add(0, ustate); + } else { + mRedos.add(0, ustate); + } + } + } + + UndoOwner restoreOwner(Parcel in) { + int idx = in.readInt(); + UndoOwner owner = mStateOwners[idx]; + if (owner == null) { + String tag = in.readString(); + owner = new UndoOwner(tag); + mStateOwners[idx] = owner; + mOwners.put(tag, owner); + } + return owner; + } + + /** + * Set the maximum number of undo states that will be retained. + */ + public void setHistorySize(int size) { + mHistorySize = size; + if (mHistorySize >= 0 && countUndos(null) > mHistorySize) { + forgetUndos(null, countUndos(null) - mHistorySize); + } + } + + /** + * Return the current maximum number of undo states. + */ + public int getHistorySize() { + return mHistorySize; + } + + /** + * Perform undo of last/top <var>count</var> undo states. The states impacted + * by this can be limited through <var>owners</var>. + * @param owners Optional set of owners that should be impacted. If null, all + * undo states will be visible and available for undo. If non-null, only those + * states that contain one of the owners specified here will be visible. + * @param count Number of undo states to pop. + * @return Returns the number of undo states that were actually popped. + */ + public int undo(UndoOwner[] owners, int count) { + if (mWorking != null) { + throw new IllegalStateException("Can't be called during an update"); + } + + int num = 0; + int i = -1; + + mInUndo = true; + + UndoState us = getTopUndo(null); + if (us != null) { + us.makeExecuted(); + } + + while (count > 0 && (i=findPrevState(mUndos, owners, i)) >= 0) { + UndoState state = mUndos.remove(i); + state.undo(); + mRedos.add(state); + count--; + num++; + } + + mInUndo = false; + + return num; + } + + /** + * Perform redo of last/top <var>count</var> undo states in the transient redo stack. + * The states impacted by this can be limited through <var>owners</var>. + * @param owners Optional set of owners that should be impacted. If null, all + * undo states will be visible and available for undo. If non-null, only those + * states that contain one of the owners specified here will be visible. + * @param count Number of undo states to pop. + * @return Returns the number of undo states that were actually redone. + */ + public int redo(UndoOwner[] owners, int count) { + if (mWorking != null) { + throw new IllegalStateException("Can't be called during an update"); + } + + int num = 0; + int i = -1; + + mInUndo = true; + + while (count > 0 && (i=findPrevState(mRedos, owners, i)) >= 0) { + UndoState state = mRedos.remove(i); + state.redo(); + mUndos.add(state); + count--; + num++; + } + + mInUndo = false; + + return num; + } + + /** + * Returns true if we are currently inside of an undo/redo operation. This is + * useful for editors to know whether they should be generating new undo state + * when they see edit operations happening. + */ + public boolean isInUndo() { + return mInUndo; + } + + public int forgetUndos(UndoOwner[] owners, int count) { + if (count < 0) { + count = mUndos.size(); + } + + int removed = 0; + for (int i=0; i<mUndos.size() && removed < count; i++) { + UndoState state = mUndos.get(i); + if (count > 0 && matchOwners(state, owners)) { + state.destroy(); + mUndos.remove(i); + removed++; + } + } + + return removed; + } + + public int forgetRedos(UndoOwner[] owners, int count) { + if (count < 0) { + count = mRedos.size(); + } + + int removed = 0; + for (int i=0; i<mRedos.size() && removed < count; i++) { + UndoState state = mRedos.get(i); + if (count > 0 && matchOwners(state, owners)) { + state.destroy(); + mRedos.remove(i); + removed++; + } + } + + return removed; + } + + /** + * Return the number of undo states on the undo stack. + * @param owners If non-null, only those states containing an operation with one of + * the owners supplied here will be counted. + */ + public int countUndos(UndoOwner[] owners) { + if (owners == null) { + return mUndos.size(); + } + + int count=0; + int i=0; + while ((i=findNextState(mUndos, owners, i)) >= 0) { + count++; + i++; + } + return count; + } + + /** + * Return the number of redo states on the undo stack. + * @param owners If non-null, only those states containing an operation with one of + * the owners supplied here will be counted. + */ + public int countRedos(UndoOwner[] owners) { + if (owners == null) { + return mRedos.size(); + } + + int count=0; + int i=0; + while ((i=findNextState(mRedos, owners, i)) >= 0) { + count++; + i++; + } + return count; + } + + /** + * Return the user-visible label for the top undo state on the stack. + * @param owners If non-null, will select the top-most undo state containing an + * operation with one of the owners supplied here. + */ + public CharSequence getUndoLabel(UndoOwner[] owners) { + UndoState state = getTopUndo(owners); + return state != null ? state.getLabel() : null; + } + + /** + * Return the user-visible label for the top redo state on the stack. + * @param owners If non-null, will select the top-most undo state containing an + * operation with one of the owners supplied here. + */ + public CharSequence getRedoLabel(UndoOwner[] owners) { + UndoState state = getTopRedo(owners); + return state != null ? state.getLabel() : null; + } + + /** + * Start creating a new undo state. Multiple calls to this function will nest until + * they are all matched by a later call to {@link #endUpdate}. + * @param label Optional user-visible label for this new undo state. + */ + public void beginUpdate(CharSequence label) { + if (mInUndo) { + throw new IllegalStateException("Can't being update while performing undo/redo"); + } + if (mUpdateCount <= 0) { + createWorkingState(); + mMerged = false; + mUpdateCount = 0; + } + + mWorking.updateLabel(label); + mUpdateCount++; + } + + private void createWorkingState() { + mWorking = new UndoState(this, mCommitId++); + if (mCommitId < 0) { + mCommitId = 1; + } + } + + /** + * Returns true if currently inside of a {@link #beginUpdate}. + */ + public boolean isInUpdate() { + return mUpdateCount > 0; + } + + /** + * Forcibly set a new for the new undo state being built within a {@link #beginUpdate}. + * Any existing label will be replaced with this one. + */ + public void setUndoLabel(CharSequence label) { + if (mWorking == null) { + throw new IllegalStateException("Must be called during an update"); + } + mWorking.setLabel(label); + } + + /** + * Set a new for the new undo state being built within a {@link #beginUpdate}, but + * only if there is not a label currently set for it. + */ + public void suggestUndoLabel(CharSequence label) { + if (mWorking == null) { + throw new IllegalStateException("Must be called during an update"); + } + mWorking.updateLabel(label); + } + + /** + * Return the number of times {@link #beginUpdate} has been called without a matching + * {@link #endUpdate} call. + */ + public int getUpdateNestingLevel() { + return mUpdateCount; + } + + /** + * Check whether there is an {@link UndoOperation} in the current {@link #beginUpdate} + * undo state. + * @param owner Optional owner of the operation to look for. If null, will succeed + * if there is any operation; if non-null, will only succeed if there is an operation + * with the given owner. + * @return Returns true if there is a matching operation in the current undo state. + */ + public boolean hasOperation(UndoOwner owner) { + if (mWorking == null) { + throw new IllegalStateException("Must be called during an update"); + } + return mWorking.hasOperation(owner); + } + + /** + * Return the most recent {@link UndoOperation} that was added to the update. + * @param mergeMode May be either {@link #MERGE_MODE_NONE} or {@link #MERGE_MODE_ANY}. + */ + public UndoOperation<?> getLastOperation(int mergeMode) { + return getLastOperation(null, null, mergeMode); + } + + /** + * Return the most recent {@link UndoOperation} that was added to the update and + * has the given owner. + * @param owner Optional owner of last operation to retrieve. If null, the last + * operation regardless of owner will be retrieved; if non-null, the last operation + * matching the given owner will be retrieved. + * @param mergeMode May be either {@link #MERGE_MODE_NONE}, {@link #MERGE_MODE_UNIQUE}, + * or {@link #MERGE_MODE_ANY}. + */ + public UndoOperation<?> getLastOperation(UndoOwner owner, int mergeMode) { + return getLastOperation(null, owner, mergeMode); + } + + /** + * Return the most recent {@link UndoOperation} that was added to the update and + * has the given owner. + * @param clazz Optional class of the last operation to retrieve. If null, the + * last operation regardless of class will be retrieved; if non-null, the last + * operation whose class is the same as the given class will be retrieved. + * @param owner Optional owner of last operation to retrieve. If null, the last + * operation regardless of owner will be retrieved; if non-null, the last operation + * matching the given owner will be retrieved. + * @param mergeMode May be either {@link #MERGE_MODE_NONE}, {@link #MERGE_MODE_UNIQUE}, + * or {@link #MERGE_MODE_ANY}. + */ + public <T extends UndoOperation> T getLastOperation(Class<T> clazz, UndoOwner owner, + int mergeMode) { + if (mWorking == null) { + throw new IllegalStateException("Must be called during an update"); + } + if (mergeMode != MERGE_MODE_NONE && !mMerged && !mWorking.hasData()) { + UndoState state = getTopUndo(null); + UndoOperation<?> last; + if (state != null && (mergeMode == MERGE_MODE_ANY || !state.hasMultipleOwners()) + && state.canMerge() && (last=state.getLastOperation(clazz, owner)) != null) { + if (last.allowMerge()) { + mWorking.destroy(); + mWorking = state; + mUndos.remove(state); + mMerged = true; + return (T)last; + } + } + } + + return mWorking.getLastOperation(clazz, owner); + } + + /** + * Add a new UndoOperation to the current update. + * @param op The new operation to add. + * @param mergeMode May be either {@link #MERGE_MODE_NONE}, {@link #MERGE_MODE_UNIQUE}, + * or {@link #MERGE_MODE_ANY}. + */ + public void addOperation(UndoOperation<?> op, int mergeMode) { + if (mWorking == null) { + throw new IllegalStateException("Must be called during an update"); + } + UndoOwner owner = op.getOwner(); + if (owner.mManager != this) { + throw new IllegalArgumentException( + "Given operation's owner is not in this undo manager."); + } + if (mergeMode != MERGE_MODE_NONE && !mMerged && !mWorking.hasData()) { + UndoState state = getTopUndo(null); + if (state != null && (mergeMode == MERGE_MODE_ANY || !state.hasMultipleOwners()) + && state.canMerge() && state.hasOperation(op.getOwner())) { + mWorking.destroy(); + mWorking = state; + mUndos.remove(state); + mMerged = true; + } + } + mWorking.addOperation(op); + } + + /** + * Finish the creation of an undo state, matching a previous call to + * {@link #beginUpdate}. + */ + public void endUpdate() { + if (mWorking == null) { + throw new IllegalStateException("Must be called during an update"); + } + mUpdateCount--; + + if (mUpdateCount == 0) { + pushWorkingState(); + } + } + + private void pushWorkingState() { + int N = mUndos.size() + 1; + + if (mWorking.hasData()) { + mUndos.add(mWorking); + forgetRedos(null, -1); + mWorking.commit(); + if (N >= 2) { + // The state before this one can no longer be merged, ever. + // The only way to get back to it is for the user to perform + // an undo. + mUndos.get(N-2).makeExecuted(); + } + } else { + mWorking.destroy(); + } + mWorking = null; + + if (mHistorySize >= 0 && N > mHistorySize) { + forgetUndos(null, N - mHistorySize); + } + } + + /** + * Commit the last finished undo state. This undo state can no longer be + * modified with further {@link #MERGE_MODE_UNIQUE} or + * {@link #MERGE_MODE_ANY} merge modes. If called while inside of an update, + * this will push any changes in the current update on to the undo stack + * and result with a fresh undo state, behaving as if {@link #endUpdate()} + * had been called enough to unwind the current update, then the last state + * committed, and {@link #beginUpdate} called to restore the update nesting. + * @param owner The optional owner to determine whether to perform the commit. + * If this is non-null, the commit will only execute if the current top undo + * state contains an operation with the given owner. + * @return Returns an integer identifier for the committed undo state, which + * can later be used to try to uncommit the state to perform further edits on it. + */ + public int commitState(UndoOwner owner) { + if (mWorking != null && mWorking.hasData()) { + if (owner == null || mWorking.hasOperation(owner)) { + mWorking.setCanMerge(false); + int commitId = mWorking.getCommitId(); + pushWorkingState(); + createWorkingState(); + mMerged = true; + return commitId; + } + } else { + UndoState state = getTopUndo(null); + if (state != null && (owner == null || state.hasOperation(owner))) { + state.setCanMerge(false); + return state.getCommitId(); + } + } + return -1; + } + + /** + * Attempt to undo a previous call to {@link #commitState}. This will work + * if the undo state at the top of the stack has the given id, and has not been + * involved in an undo operation. Otherwise false is returned. + * @param commitId The identifier for the state to be uncommitted, as returned + * by {@link #commitState}. + * @param owner Optional owner that must appear in the committed state. + * @return Returns true if the uncommit is successful, else false. + */ + public boolean uncommitState(int commitId, UndoOwner owner) { + if (mWorking != null && mWorking.getCommitId() == commitId) { + if (owner == null || mWorking.hasOperation(owner)) { + return mWorking.setCanMerge(true); + } + } else { + UndoState state = getTopUndo(null); + if (state != null && (owner == null || state.hasOperation(owner))) { + if (state.getCommitId() == commitId) { + return state.setCanMerge(true); + } + } + } + return false; + } + + UndoState getTopUndo(UndoOwner[] owners) { + if (mUndos.size() <= 0) { + return null; + } + int i = findPrevState(mUndos, owners, -1); + return i >= 0 ? mUndos.get(i) : null; + } + + UndoState getTopRedo(UndoOwner[] owners) { + if (mRedos.size() <= 0) { + return null; + } + int i = findPrevState(mRedos, owners, -1); + return i >= 0 ? mRedos.get(i) : null; + } + + boolean matchOwners(UndoState state, UndoOwner[] owners) { + if (owners == null) { + return true; + } + for (int i=0; i<owners.length; i++) { + if (state.matchOwner(owners[i])) { + return true; + } + } + return false; + } + + int findPrevState(ArrayList<UndoState> states, UndoOwner[] owners, int from) { + final int N = states.size(); + + if (from == -1) { + from = N-1; + } + if (from >= N) { + return -1; + } + if (owners == null) { + return from; + } + + while (from >= 0) { + UndoState state = states.get(from); + if (matchOwners(state, owners)) { + return from; + } + from--; + } + + return -1; + } + + int findNextState(ArrayList<UndoState> states, UndoOwner[] owners, int from) { + final int N = states.size(); + + if (from < 0) { + from = 0; + } + if (from >= N) { + return -1; + } + if (owners == null) { + return from; + } + + while (from < N) { + UndoState state = states.get(from); + if (matchOwners(state, owners)) { + return from; + } + from++; + } + + return -1; + } + + final static class UndoState { + private final UndoManager mManager; + private final int mCommitId; + private final ArrayList<UndoOperation<?>> mOperations = new ArrayList<UndoOperation<?>>(); + private ArrayList<UndoOperation<?>> mRecent; + private CharSequence mLabel; + private boolean mCanMerge = true; + private boolean mExecuted; + + UndoState(UndoManager manager, int commitId) { + mManager = manager; + mCommitId = commitId; + } + + UndoState(UndoManager manager, Parcel p, ClassLoader loader) { + mManager = manager; + mCommitId = p.readInt(); + mCanMerge = p.readInt() != 0; + mExecuted = p.readInt() != 0; + mLabel = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(p); + final int N = p.readInt(); + for (int i=0; i<N; i++) { + UndoOwner owner = mManager.restoreOwner(p); + UndoOperation op = (UndoOperation)p.readParcelable(loader); + op.mOwner = owner; + mOperations.add(op); + } + } + + void writeToParcel(Parcel p) { + if (mRecent != null) { + throw new IllegalStateException("Can't save state before committing"); + } + p.writeInt(mCommitId); + p.writeInt(mCanMerge ? 1 : 0); + p.writeInt(mExecuted ? 1 : 0); + TextUtils.writeToParcel(mLabel, p, 0); + final int N = mOperations.size(); + p.writeInt(N); + for (int i=0; i<N; i++) { + UndoOperation op = mOperations.get(i); + mManager.saveOwner(op.mOwner, p); + p.writeParcelable(op, 0); + } + } + + int getCommitId() { + return mCommitId; + } + + void setLabel(CharSequence label) { + mLabel = label; + } + + void updateLabel(CharSequence label) { + if (mLabel != null) { + mLabel = label; + } + } + + CharSequence getLabel() { + return mLabel; + } + + boolean setCanMerge(boolean state) { + // Don't allow re-enabling of merging if state has been executed. + if (state && mExecuted) { + return false; + } + mCanMerge = state; + return true; + } + + void makeExecuted() { + mExecuted = true; + } + + boolean canMerge() { + return mCanMerge && !mExecuted; + } + + int countOperations() { + return mOperations.size(); + } + + boolean hasOperation(UndoOwner owner) { + final int N = mOperations.size(); + if (owner == null) { + return N != 0; + } + for (int i=0; i<N; i++) { + if (mOperations.get(i).getOwner() == owner) { + return true; + } + } + return false; + } + + boolean hasMultipleOwners() { + final int N = mOperations.size(); + if (N <= 1) { + return false; + } + UndoOwner owner = mOperations.get(0).getOwner(); + for (int i=1; i<N; i++) { + if (mOperations.get(i).getOwner() != owner) { + return true; + } + } + return false; + } + + void addOperation(UndoOperation<?> op) { + if (mOperations.contains(op)) { + throw new IllegalStateException("Already holds " + op); + } + mOperations.add(op); + if (mRecent == null) { + mRecent = new ArrayList<UndoOperation<?>>(); + mRecent.add(op); + } + op.mOwner.mOpCount++; + } + + <T extends UndoOperation> T getLastOperation(Class<T> clazz, UndoOwner owner) { + final int N = mOperations.size(); + if (clazz == null && owner == null) { + return N > 0 ? (T)mOperations.get(N-1) : null; + } + // First look for the top-most operation with the same owner. + for (int i=N-1; i>=0; i--) { + UndoOperation<?> op = mOperations.get(i); + if (owner != null && op.getOwner() != owner) { + continue; + } + // Return this operation if it has the same class that the caller wants. + // Note that we don't search deeper for the class, because we don't want + // to end up with a different order of operations for the same owner. + if (clazz != null && op.getClass() != clazz) { + return null; + } + return (T)op; + } + + return null; + } + + boolean matchOwner(UndoOwner owner) { + for (int i=mOperations.size()-1; i>=0; i--) { + if (mOperations.get(i).matchOwner(owner)) { + return true; + } + } + return false; + } + + boolean hasData() { + for (int i=mOperations.size()-1; i>=0; i--) { + if (mOperations.get(i).hasData()) { + return true; + } + } + return false; + } + + void commit() { + final int N = mRecent != null ? mRecent.size() : 0; + for (int i=0; i<N; i++) { + mRecent.get(i).commit(); + } + mRecent = null; + } + + void undo() { + for (int i=mOperations.size()-1; i>=0; i--) { + mOperations.get(i).undo(); + } + } + + void redo() { + final int N = mOperations.size(); + for (int i=0; i<N; i++) { + mOperations.get(i).redo(); + } + } + + void destroy() { + for (int i=mOperations.size()-1; i>=0; i--) { + UndoOwner owner = mOperations.get(i).mOwner; + owner.mOpCount--; + if (owner.mOpCount <= 0) { + if (owner.mOpCount < 0) { + throw new IllegalStateException("Underflow of op count on owner " + owner + + " in op " + mOperations.get(i)); + } + mManager.removeOwner(owner); + } + } + } + } +} diff --git a/core/java/android/content/UndoOperation.java b/core/java/android/content/UndoOperation.java new file mode 100644 index 000000000000..8084b1f5946e --- /dev/null +++ b/core/java/android/content/UndoOperation.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2013 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 android.content; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * A single undoable operation. You must subclass this to implement the state + * and behavior for your operation. Instances of this class are placed and + * managed in an {@link UndoManager}. + */ +public abstract class UndoOperation<DATA> implements Parcelable { + UndoOwner mOwner; + + /** + * Create a new instance of the operation. + * @param owner Who owns the data being modified by this undo state; must be + * returned by {@link UndoManager#getOwner(String, Object) UndoManager.getOwner}. + */ + public UndoOperation(UndoOwner owner) { + mOwner = owner; + } + + /** + * Construct from a Parcel. + */ + protected UndoOperation(Parcel src, ClassLoader loader) { + } + + /** + * Owning object as given to {@link #UndoOperation(UndoOwner)}. + */ + public UndoOwner getOwner() { + return mOwner; + } + + /** + * Synonym for {@link #getOwner()}.{@link android.content.UndoOwner#getData()}. + */ + public DATA getOwnerData() { + return (DATA)mOwner.getData(); + } + + /** + * Return true if this undo operation is a member of the given owner. + * The default implementation is <code>owner == getOwner()</code>. You + * can override this to provide more sophisticated dependencies between + * owners. + */ + public boolean matchOwner(UndoOwner owner) { + return owner == getOwner(); + } + + /** + * Return true if this operation actually contains modification data. The + * default implementation always returns true. If you return false, the + * operation will be dropped when the final undo state is being built. + */ + public boolean hasData() { + return true; + } + + /** + * Return true if this operation can be merged with a later operation. + * The default implementation always returns true. + */ + public boolean allowMerge() { + return true; + } + + /** + * Called when this undo state is being committed to the undo stack. + * The implementation should perform the initial edits and save any state that + * may be needed to undo them. + */ + public abstract void commit(); + + /** + * Called when this undo state is being popped off the undo stack (in to + * the temporary redo stack). The implementation should remove the original + * edits and thus restore the target object to its prior value. + */ + public abstract void undo(); + + /** + * Called when this undo state is being pushed back from the transient + * redo stack to the main undo stack. The implementation should re-apply + * the edits that were previously removed by {@link #undo}. + */ + public abstract void redo(); + + public int describeContents() { + return 0; + } +} diff --git a/core/java/android/content/UndoOwner.java b/core/java/android/content/UndoOwner.java new file mode 100644 index 000000000000..a279de68635e --- /dev/null +++ b/core/java/android/content/UndoOwner.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2013 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 android.content; + +/** + * Representation of an owner of {@link UndoOperation} objects in an {@link UndoManager}. + */ +public class UndoOwner { + final String mTag; + + UndoManager mManager; + Object mData; + int mOpCount; + + // For saving/restoring state. + int mStateSeq; + int mSavedIdx; + + UndoOwner(String tag) { + mTag = tag; + } + + /** + * Return the unique tag name identifying this owner. This is the tag + * supplied to {@link UndoManager#getOwner(String, Object) UndoManager.getOwner} + * and is immutable. + */ + public String getTag() { + return mTag; + } + + /** + * Return the actual data object of the owner. This is the data object + * supplied to {@link UndoManager#getOwner(String, Object) UndoManager.getOwner}. An + * owner may have a null data if it was restored from a previously saved state with + * no getOwner call to associate it with its data. + */ + public Object getData() { + return mData; + } +} diff --git a/core/java/android/os/ParcelableParcel.java b/core/java/android/os/ParcelableParcel.java new file mode 100644 index 000000000000..11785f1072fd --- /dev/null +++ b/core/java/android/os/ParcelableParcel.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2013 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 android.os; + +/** + * Parcelable containing a raw Parcel of data. + * @hide + */ +public class ParcelableParcel implements Parcelable { + final Parcel mParcel; + final ClassLoader mClassLoader; + + public ParcelableParcel(ClassLoader loader) { + mParcel = Parcel.obtain(); + mClassLoader = loader; + } + + public ParcelableParcel(Parcel src, ClassLoader loader) { + mParcel = Parcel.obtain(); + mClassLoader = loader; + int size = src.readInt(); + int pos = src.dataPosition(); + mParcel.appendFrom(src, src.dataPosition(), size); + src.setDataPosition(pos + size); + } + + public Parcel getParcel() { + mParcel.setDataPosition(0); + return mParcel; + } + + public ClassLoader getClassLoader() { + return mClassLoader; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mParcel.dataSize()); + dest.appendFrom(mParcel, 0, mParcel.dataSize()); + } + + public static final Parcelable.ClassLoaderCreator<ParcelableParcel> CREATOR + = new Parcelable.ClassLoaderCreator<ParcelableParcel>() { + public ParcelableParcel createFromParcel(Parcel in) { + return new ParcelableParcel(in, null); + } + + public ParcelableParcel createFromParcel(Parcel in, ClassLoader loader) { + return new ParcelableParcel(in, loader); + } + + public ParcelableParcel[] newArray(int size) { + return new ParcelableParcel[size]; + } + }; +} diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java index 4312dee848f9..91c989dd8615 100644 --- a/core/java/android/widget/Editor.java +++ b/core/java/android/widget/Editor.java @@ -16,6 +16,13 @@ package android.widget; +import android.content.UndoManager; +import android.content.UndoOperation; +import android.content.UndoOwner; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.InputFilter; +import android.text.SpannableString; import com.android.internal.util.ArrayUtils; import com.android.internal.widget.EditableInputConnection; @@ -107,11 +114,16 @@ import java.util.HashMap; */ public class Editor { private static final String TAG = "Editor"; + static final boolean DEBUG_UNDO = false; static final int BLINK = 500; private static final float[] TEMP_POSITION = new float[2]; private static int DRAG_SHADOW_MAX_TEXT_LENGTH = 20; + UndoManager mUndoManager; + UndoOwner mUndoOwner; + InputFilter mUndoInputFilter; + // Cursor Controllers. InsertionPointCursorController mInsertionPointCursorController; SelectionModifierCursorController mSelectionModifierCursorController; @@ -1884,7 +1896,7 @@ public class Editor { /** * Controls the {@link EasyEditSpan} monitoring when it is added, and when the related * pop-up should be displayed. - * Also monitors {@link SelectionSpan} to call back to the attached input method. + * Also monitors {@link Selection} to call back to the attached input method. */ class SpanController implements SpanWatcher { @@ -2014,7 +2026,7 @@ public class Editor { /** * Displays the actions associated to an {@link EasyEditSpan}. The pop-up is controlled - * by {@link EasyEditSpanController}. + * by {@link SpanController}. */ private class EasyEditPopupWindow extends PinnedPopupWindow implements OnClickListener { @@ -3959,4 +3971,166 @@ public class Editor { mTextView.setCursorPosition_internal(newCursorPosition, newCursorPosition); } } + + public static class UndoInputFilter implements InputFilter { + final Editor mEditor; + + public UndoInputFilter(Editor editor) { + mEditor = editor; + } + + @Override + 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 + ")"); + } + final UndoManager um = mEditor.mUndoManager; + if (um.isInUndo()) { + if (DEBUG_UNDO) Log.d(TAG, "*** skipping, currently performing undo/redo"); + return null; + } + + 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. + 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); + } else { + op.mRangeEnd = dstart; + } + 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; + } + } + + public static class TextModifyOperation extends UndoOperation<TextView> { + int mRangeStart, mRangeEnd; + CharSequence mOldText; + + public TextModifyOperation(UndoOwner owner) { + super(owner); + } + + public TextModifyOperation(Parcel src, ClassLoader loader) { + super(src, loader); + mRangeStart = src.readInt(); + mRangeEnd = src.readInt(); + mOldText = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(src); + } + + @Override + public void commit() { + } + + @Override + public void undo() { + swapText(); + } + + @Override + public void redo() { + swapText(); + } + + private void swapText() { + // Both undo and redo involves swapping the contents of the range + // in the text view with our local text. + TextView tv = getOwnerData(); + Editable editable = (Editable)tv.getText(); + CharSequence curText; + if (mRangeStart >= mRangeEnd) { + curText = null; + } else { + curText = editable.subSequence(mRangeStart, mRangeEnd); + } + if (DEBUG_UNDO) { + Log.d(TAG, "Swap: range=(" + mRangeStart + "-" + mRangeEnd + + "), oldText=" + mOldText); + Log.d(TAG, "Swap: curText=" + curText); + } + 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 static final Parcelable.ClassLoaderCreator<TextModifyOperation> CREATOR + = new Parcelable.ClassLoaderCreator<TextModifyOperation>() { + public TextModifyOperation createFromParcel(Parcel in) { + return new TextModifyOperation(in, null); + } + + public TextModifyOperation createFromParcel(Parcel in, ClassLoader loader) { + return new TextModifyOperation(in, loader); + } + + public TextModifyOperation[] newArray(int size) { + return new TextModifyOperation[size]; + } + }; + } } diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java index 698f101d8230..8d909735b3ac 100644 --- a/core/java/android/widget/TextView.java +++ b/core/java/android/widget/TextView.java @@ -20,6 +20,7 @@ import android.R; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.content.UndoManager; import android.content.res.ColorStateList; import android.content.res.CompatibilityInfo; import android.content.res.Resources; @@ -1510,6 +1511,47 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener } /** + * Retrieve the {@link android.content.UndoManager} that is currently associated + * with this TextView. By default there is no associated UndoManager, so null + * is returned. One can be associated with the TextView through + * {@link #setUndoManager(android.content.UndoManager, String)} + */ + public final UndoManager getUndoManager() { + return mEditor == null ? null : mEditor.mUndoManager; + } + + /** + * Associate an {@link android.content.UndoManager} with this TextView. Once + * done, all edit operations on the TextView will result in appropriate + * {@link android.content.UndoOperation} objects pushed on the given UndoManager's + * stack. + * + * @param undoManager The {@link android.content.UndoManager} to associate with + * this TextView, or null to clear any existing association. + * @param tag String tag identifying this particular TextView owner in the + * UndoManager. This is used to keep the correct association with the + * {@link android.content.UndoOwner} of any operations inside of the UndoManager. + */ + public final void setUndoManager(UndoManager undoManager, String tag) { + if (undoManager != null) { + createEditorIfNeeded(); + mEditor.mUndoManager = undoManager; + mEditor.mUndoOwner = undoManager.getOwner(tag, this); + mEditor.mUndoInputFilter = new Editor.UndoInputFilter(mEditor); + if (!(mText instanceof Editable)) { + setText(mText, BufferType.EDITABLE); + } + + setFilters((Editable) mText, mFilters); + } else if (mEditor != null) { + // XXX need to destroy all associated state. + mEditor.mUndoManager = null; + mEditor.mUndoOwner = null; + mEditor.mUndoInputFilter = null; + } + } + + /** * @return the current key listener for this TextView. * This will frequently be null for non-EditText TextViews. * @@ -4401,16 +4443,30 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener * and includes mInput in the list if it is an InputFilter. */ private void setFilters(Editable e, InputFilter[] filters) { - if (mEditor != null && mEditor.mKeyListener instanceof InputFilter) { - InputFilter[] nf = new InputFilter[filters.length + 1]; - - System.arraycopy(filters, 0, nf, 0, filters.length); - nf[filters.length] = (InputFilter) mEditor.mKeyListener; + if (mEditor != null) { + final boolean undoFilter = mEditor.mUndoInputFilter != null; + final boolean keyFilter = mEditor.mKeyListener instanceof InputFilter; + int num = 0; + if (undoFilter) num++; + if (keyFilter) num++; + if (num > 0) { + InputFilter[] nf = new InputFilter[filters.length + num]; + + System.arraycopy(filters, 0, nf, 0, filters.length); + num = 0; + if (undoFilter) { + nf[filters.length] = mEditor.mUndoInputFilter; + num++; + } + if (keyFilter) { + nf[filters.length + num] = (InputFilter) mEditor.mKeyListener; + } - e.setFilters(nf); - } else { - e.setFilters(filters); + e.setFilters(nf); + return; + } } + e.setFilters(filters); } /** |