package io.github.zeleven.mua; import android.content.SharedPreferences; import android.text.Editable; import android.text.Selection; import android.text.TextWatcher; import android.text.style.UnderlineSpan; import android.widget.TextView; import java.util.LinkedList; public class UndoRedoHelper { /** * Is undo/redo being performed? This member signals if an undo/redo * operation is currently being performed. Changes in the text during * undo/redo are not recorded because it would mess up the undo history. */ private boolean mIsUndoOrRedo = false; /** * The edit history. */ private EditHistory mEditHistory; /** * The change listener. */ private EditTextChangeListener mChangeListener; /** * The edit text. */ private TextView mTextView; // =================================================================== // /** * Create a new TextViewUndoRedo and attach it to the specified TextView. * * @param textView * The text view for which the undo/redo is implemented. */ public UndoRedoHelper(TextView textView) { mTextView = textView; mEditHistory = new EditHistory(); mChangeListener = new EditTextChangeListener(); mTextView.addTextChangedListener(mChangeListener); } // =================================================================== // /** * Disconnect this undo/redo from the text view. */ public void disconnect() { mTextView.removeTextChangedListener(mChangeListener); } /** * Set the maximum history size. If size is negative, then history size is * only limited by the device memory. */ public void setMaxHistorySize(int maxHistorySize) { mEditHistory.setMaxHistorySize(maxHistorySize); } /** * Clear history. */ public void clearHistory() { mEditHistory.clear(); } /** * Can undo be performed? */ public boolean getCanUndo() { return (mEditHistory.mmPosition > 0); } /** * Perform undo. */ public void undo() { EditItem edit = mEditHistory.getPrevious(); if (edit == null) { return; } Editable text = mTextView.getEditableText(); int start = edit.mmStart; int end = start + (edit.mmAfter != null ? edit.mmAfter.length() : 0); mIsUndoOrRedo = true; text.replace(start, end, edit.mmBefore); mIsUndoOrRedo = false; // This will get rid of underlines inserted when editor tries to come // up with a suggestion. for (Object o : text.getSpans(0, text.length(), UnderlineSpan.class)) { text.removeSpan(o); } Selection.setSelection(text, edit.mmBefore == null ? start : (start + edit.mmBefore.length())); } /** * Can redo be performed? */ public boolean getCanRedo() { return (mEditHistory.mmPosition < mEditHistory.mmHistory.size()); } /** * Perform redo. */ public void redo() { EditItem edit = mEditHistory.getNext(); if (edit == null) { return; } Editable text = mTextView.getEditableText(); int start = edit.mmStart; int end = start + (edit.mmBefore != null ? edit.mmBefore.length() : 0); mIsUndoOrRedo = true; text.replace(start, end, edit.mmAfter); mIsUndoOrRedo = false; // This will get rid of underlines inserted when editor tries to come // up with a suggestion. for (Object o : text.getSpans(0, text.length(), UnderlineSpan.class)) { text.removeSpan(o); } Selection.setSelection(text, edit.mmAfter == null ? start : (start + edit.mmAfter.length())); } /** * Store preferences. */ public void storePersistentState(SharedPreferences.Editor editor, String prefix) { // Store hash code of text in the editor so that we can check if the // editor contents has changed. editor.putString(prefix + ".hash", String.valueOf(mTextView.getText().toString().hashCode())); editor.putInt(prefix + ".maxSize", mEditHistory.mmMaxHistorySize); editor.putInt(prefix + ".position", mEditHistory.mmPosition); editor.putInt(prefix + ".size", mEditHistory.mmHistory.size()); int i = 0; for (EditItem ei : mEditHistory.mmHistory) { String pre = prefix + "." + i; editor.putInt(pre + ".start", ei.mmStart); editor.putString(pre + ".before", ei.mmBefore.toString()); editor.putString(pre + ".after", ei.mmAfter.toString()); i++; } } /** * Restore preferences. * * @param prefix * The preference key prefix used when state was stored. * @return did restore succeed? If this is false, the undo history will be * empty. */ public boolean restorePersistentState(SharedPreferences sp, String prefix) throws IllegalStateException { boolean ok = doRestorePersistentState(sp, prefix); if (!ok) { mEditHistory.clear(); } return ok; } private boolean doRestorePersistentState(SharedPreferences sp, String prefix) { String hash = sp.getString(prefix + ".hash", null); if (hash == null) { // No state to be restored. return true; } if (Integer.valueOf(hash) != mTextView.getText().toString().hashCode()) { return false; } mEditHistory.clear(); mEditHistory.mmMaxHistorySize = sp.getInt(prefix + ".maxSize", -1); int count = sp.getInt(prefix + ".size", -1); if (count == -1) { return false; } for (int i = 0; i < count; i++) { String pre = prefix + "." + i; int start = sp.getInt(pre + ".start", -1); String before = sp.getString(pre + ".before", null); String after = sp.getString(pre + ".after", null); if (start == -1 || before == null || after == null) { return false; } mEditHistory.add(new EditItem(start, before, after)); } mEditHistory.mmPosition = sp.getInt(prefix + ".position", -1); if (mEditHistory.mmPosition == -1) { return false; } return true; } // =================================================================== // /** * Keeps track of all the edit history of a text. */ private final class EditHistory { /** * The position from which an EditItem will be retrieved when getNext() * is called. If getPrevious() has not been called, this has the same * value as mmHistory.size(). */ private int mmPosition = 0; /** * Maximum undo history size. */ private int mmMaxHistorySize = -1; /** * The list of edits in chronological order. */ private final LinkedList<EditItem> mmHistory = new LinkedList<EditItem>(); /** * Clear history. */ private void clear() { mmPosition = 0; mmHistory.clear(); } /** * Adds a new edit operation to the history at the current position. If * executed after a call to getPrevious() removes all the future history * (elements with positions >= current history position). */ private void add(EditItem item) { while (mmHistory.size() > mmPosition) { mmHistory.removeLast(); } mmHistory.add(item); mmPosition++; if (mmMaxHistorySize >= 0) { trimHistory(); } } /** * Set the maximum history size. If size is negative, then history size * is only limited by the device memory. */ private void setMaxHistorySize(int maxHistorySize) { mmMaxHistorySize = maxHistorySize; if (mmMaxHistorySize >= 0) { trimHistory(); } } /** * Trim history when it exceeds max history size. */ private void trimHistory() { while (mmHistory.size() > mmMaxHistorySize) { mmHistory.removeFirst(); mmPosition--; } if (mmPosition < 0) { mmPosition = 0; } } /** * Traverses the history backward by one position, returns and item at * that position. */ private EditItem getPrevious() { if (mmPosition == 0) { return null; } mmPosition--; return mmHistory.get(mmPosition); } /** * Traverses the history forward by one position, returns and item at * that position. */ private EditItem getNext() { if (mmPosition >= mmHistory.size()) { return null; } EditItem item = mmHistory.get(mmPosition); mmPosition++; return item; } } /** * Represents the changes performed by a single edit operation. */ private final class EditItem { private final int mmStart; private final CharSequence mmBefore; private final CharSequence mmAfter; /** * Constructs EditItem of a modification that was applied at position * start and replaced CharSequence before with CharSequence after. */ public EditItem(int start, CharSequence before, CharSequence after) { mmStart = start; mmBefore = before; mmAfter = after; } } /** * Class that listens to changes in the text. */ private final class EditTextChangeListener implements TextWatcher { /** * The text that will be removed by the change event. */ private CharSequence mBeforeChange; /** * The text that was inserted by the change event. */ private CharSequence mAfterChange; public void beforeTextChanged(CharSequence s, int start, int count, int after) { if (mIsUndoOrRedo) { return; } mBeforeChange = s.subSequence(start, start + count); } public void onTextChanged(CharSequence s, int start, int before, int count) { if (mIsUndoOrRedo) { return; } mAfterChange = s.subSequence(start, start + count); mEditHistory.add(new EditItem(start, mBeforeChange, mAfterChange)); } public void afterTextChanged(Editable s) { } } }