using System; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Windows.Input; namespace HavenSoft.HexManiac.Core.ViewModels { public interface IChangeToken { bool HasDataChange { get; } bool HasAnyChange { get; } event EventHandler OnNewChange; } /// /// Represents a history of changes that can undo / redo. /// The change can be reperesented by any class with an empty constructor. /// New change objects will be created automatically as needed. /// The user is responsible for using the change object to revert a change via the constructor delegate call. /// The user is responsible for converting from a backward change object to a forward change (redo) object. /// The user is responsible for assigning boundaries between changes by calling ChangeCompleted. /// /// /// Aside from undo/redo, the ChangeHistory can also track whether the file has been changed since the last save. /// However, since ChangeHistory is not responsible for saving, you have to tell it whenever the data is saved. /// This is accomplished via the TagAsSaved() method. /// public class ChangeHistory : ViewModelCore where T : class, IChangeToken, new() { private readonly Func revert; private readonly StubCommand undo, redo; private readonly Stack undoStack = new Stack(), redoStack = new Stack(); private bool revertInProgress; private bool customChangeInProgress; private T currentChange; private int undoStackSizeAtSaveTag; public ICommand Undo => undo; public ICommand Redo => redo; public T CurrentChange { get { VerifyRevertNotInProgress(); ClearRedoStack(); if (customChangeInProgress) ChangeCompleted(); if (currentChange == null) { PrepareNewToken(new T()); } return currentChange; } } private void ClearRedoStack() { if (redoStack.Count > 0) { redoStack.Clear(); if (undoStack.Count < undoStackSizeAtSaveTag) undoStackSizeAtSaveTag = -1; redo.RaiseCanExecuteChanged(); } } private void PrepareNewToken(T token) { bool notifyIsSavedChanged = IsSaved; currentChange = token; currentChange.OnNewChange += OnCurrentTokenDataChanged; ClearRedoStack(); if (notifyIsSavedChanged) NotifyPropertyChanged(nameof(IsSaved)); } public bool IsSaved => undoStackSizeAtSaveTag == undoStack.Count && currentChange == null; public bool HasDataChange { get { if (IsSaved) return false; var addedElements = undoStack.Count - undoStackSizeAtSaveTag; var undoItems = undoStack.ToArray(); var redoItems = redoStack.ToArray(); if (undoStackSizeAtSaveTag == -1) return true; for (int i = 0; i < addedElements; i++) { if (undoItems[undoStackSizeAtSaveTag + i].HasDataChange) return true; } for (int i = 0; i < -addedElements; i++) { if (redoItems[redoItems.Length - 1 - i].HasDataChange) return true; } return currentChange?.HasDataChange ?? false; } } public ChangeHistory(Func revertChange) { revert = revertChange; undo = new StubCommand { Execute = arg => UndoExecuted(), CanExecute = arg => undoStack.Count > 0 || (currentChange != null && currentChange.HasAnyChange), }; redo = new StubCommand { Execute = arg => RedoExecuted(), CanExecute = arg => redoStack.Count > 0, }; } public void ChangeCompleted() { if (!continueCurrentTransaction) customChangeInProgress = false; if (currentChange == null) return; if (!currentChange.HasAnyChange) { currentChange = null; return; } VerifyRevertNotInProgress(); if (continueCurrentTransaction) return; undoStack.Push(currentChange); currentChange.OnNewChange -= OnCurrentTokenDataChanged; currentChange = null; } public T InsertCustomChange(T change) { if (continueCurrentTransaction) throw new InvalidOperationException("Inserting a change during a CurrentTransactionScope will cause changes to be lost."); ChangeCompleted(); PrepareNewToken(change); OnCurrentTokenDataChanged(default, default); customChangeInProgress = true; return change; } public void TagAsSaved() { ChangeCompleted(); if (TryUpdate(ref undoStackSizeAtSaveTag, undoStack.Count, nameof(IsSaved))) { NotifyPropertyChanged(nameof(HasDataChange)); } } private bool continueCurrentTransaction; public IDisposable ContinueCurrentTransaction() { continueCurrentTransaction = true; return new StubDisposable { Dispose = () => continueCurrentTransaction = false }; } private void OnCurrentTokenDataChanged(object sender, EventArgs e) { if (undoStack.Count == 0) undo.RaiseCanExecuteChanged(); NotifyPropertyChanged(nameof(HasDataChange)); } private void UndoExecuted() { ChangeCompleted(); if (undoStack.Count == 0) return; bool previouslyWasSaved = IsSaved; bool previouslyHadDataChanged = HasDataChange; using (CreateRevertScope()) { var originalChange = undoStack.Pop(); if (undoStack.Count == 0) undo.RaiseCanExecuteChanged(); var reverseChange = revert(originalChange); redoStack.Push(reverseChange); if (redoStack.Count == 1) redo.RaiseCanExecuteChanged(); } if (previouslyWasSaved != IsSaved) NotifyPropertyChanged(nameof(IsSaved)); if (previouslyHadDataChanged != HasDataChange) NotifyPropertyChanged(nameof(HasDataChange)); Debug.Assert(redoStack.Count > 0, "Redo should always be available directly after an Undo!"); } private void RedoExecuted() { if (redoStack.Count == 0) return; bool previouslyWasSaved = IsSaved; bool previouslyHadDataChanged = HasDataChange; VerifyRevertNotInProgress(); using (CreateRevertScope()) { var reverseChange = redoStack.Pop(); if (redoStack.Count == 0) redo.RaiseCanExecuteChanged(); var originalChange = revert(reverseChange); undoStack.Push(originalChange); if (undoStack.Count == 1) undo.RaiseCanExecuteChanged(); } if (previouslyWasSaved != IsSaved) NotifyPropertyChanged(nameof(IsSaved)); if (previouslyHadDataChanged != HasDataChange) NotifyPropertyChanged(nameof(HasDataChange)); } private void VerifyRevertNotInProgress([CallerMemberName]string caller = null) { if (!revertInProgress) return; throw new InvalidOperationException($"Cannot execute member {caller} while a revert is in progress."); } private IDisposable CreateRevertScope() { revertInProgress = true; var stub = new StubDisposable { Dispose = () => revertInProgress = false }; return stub; } public void ClearHistory(bool needsSave) { VerifyRevertNotInProgress(); if (!IsSaved || needsSave) undoStackSizeAtSaveTag = -1; undoStack.Clear(); redoStack.Clear(); currentChange = null; NotifyPropertyChanged(nameof(HasDataChange)); NotifyPropertyChanged(nameof(IsSaved)); undo.RaiseCanExecuteChanged(); redo.RaiseCanExecuteChanged(); } } }