/* * To change this template, choose Tools | Templates * and open the template in the editor. */ package org.pepsoft.util.undo; import org.pepsoft.util.MemoryUtils; import javax.swing.*; import java.lang.ref.Reference; import java.lang.ref.WeakReference; import java.util.*; import static org.pepsoft.util.ObjectUtils.copyObject; /** * A data buffer oriented (rather than edit list or operations oriented) manager * of undo and redo data. Uses a copy on write mechanism to maintain a list of * historical versions of a set of data buffers, copying them automatically when * needed after a save point has been executed, and notifying clients when * buffers need to be updated because an undo or redo has been performed. * * @author pepijn */ public class UndoManager { public UndoManager() { this(null, null, DEFAULT_MAX_FRAMES); } public UndoManager(int maxFrames) { this(null, null, maxFrames); } public UndoManager(Action undoAction, Action redoAction) { this(undoAction, redoAction, DEFAULT_MAX_FRAMES); } public UndoManager(Action undoAction, Action redoAction, int maxFrames) { this.maxFrames = maxFrames; history.add(new WeakHashMap<>()); registerActions(undoAction, redoAction); } public void registerActions(Action undoAction, Action redoAction) { this.undoAction = undoAction; this.redoAction = redoAction; updateActions(); } public void unregisterActions() { undoAction = null; redoAction = null; } public int getMaxFrames() { return maxFrames; } /** * Arm a save point. It will be executed the next time a buffer is requested * for editing. Arming a save point instead of executing it immediately * allows a redo to be performed instead. * * <p>Will do nothing if a save point is already armed, or if the current * frame is the last one and it is not dirty. */ public void armSavePoint() { if ((! savePointArmed) /*&& ((currentFrame < (history.size() - 1)) || isDirty())*/) { savePointArmed = true; listeners.forEach(UndoListener::savePointArmed); if (logger.isDebugEnabled()) { logger.debug("Save point armed"); } } } /** * Save the current state of all buffers as an undo point. */ public void savePoint() { clearRedo(); // Add a new frame history.add(new WeakHashMap<>()); // Update the current frame pointer currentFrame++; // If the max undos has been reached, throw away the oldest pruneHistory(); // Clear cache writeableBufferCache.clear(); savePointArmed = false; listeners.forEach(UndoListener::savePointCreated); updateActions(); if (logger.isDebugEnabled()) { logger.debug("Save point set; new current frame: " + currentFrame); if (logger.isTraceEnabled()) { dumpBuffer(); } } } /** * Get a read-only snapshot of the current state of the buffers. If you want * the state to be a static snapshot that will not reflect later changes, * you should execute a save point after getting the snapshot. The snapshot * will remain valid until the corresponding undo history frame disappears, * after which it will throw an exception if you try to use it. * * @return A snapshot of the current undo history frame. */ public Snapshot getSnapshot() { Snapshot snapshot = new Snapshot(this, currentFrame); snapshots.add(new WeakReference<>(snapshot)); return snapshot; } /** * Indicates whether the current history frame is dirty (meaning that * buffers have been checked out for editing from it). * * @return <code>true</code> if the current history frame is dirty. */ public boolean isDirty() { return ! writeableBufferCache.isEmpty(); } /** * Rolls back all buffers to the previous save point, if there is one still * available. * * @return <code>true</code> if the undo was succesful. */ public boolean undo() { if (currentFrame > 0) { currentFrame--; readOnlyBufferCache.clear(); writeableBufferCache.clear(); listeners.forEach(UndoListener::undoPerformed); Map<BufferKey<?>, Object> previousHistoryFrame = history.get(currentFrame + 1); for (BufferKey<?> key: previousHistoryFrame.keySet()) { UndoListener listener = keyListeners.get(key); if (listener != null) { listener.bufferChanged(key); } } updateActions(); if (logger.isDebugEnabled()) { logger.debug("Undo requested; now at frame " + currentFrame + " (total: " + history.size() + ")"); if (logger.isTraceEnabled()) { dumpBuffer(); } } return true; } else { if (logger.isDebugEnabled()) { logger.debug("Undo requested, but no more frames available"); } return false; } } /** * Rolls forward all buffers to the next save point, if there is one * available, and no edits have been performed since the last undo. * * @return <code>true</code> if the redo was succesful. */ public boolean redo() { if (currentFrame < (history.size() - 1)) { currentFrame++; readOnlyBufferCache.clear(); writeableBufferCache.clear(); listeners.forEach(UndoListener::redoPerformed); Map<BufferKey<?>, Object> currentHistoryFrame = history.get(currentFrame); for (BufferKey<?> key: currentHistoryFrame.keySet()) { UndoListener listener = keyListeners.get(key); if (listener != null) { listener.bufferChanged(key); } } updateActions(); if (logger.isDebugEnabled()) { logger.debug("Redo requested; now at frame " + currentFrame + " (total: " + history.size() + ")"); if (logger.isTraceEnabled()) { dumpBuffer(); } } return true; } else { if (logger.isDebugEnabled()) { logger.debug("Redo requested, but no more frames available"); } return false; } } /** * Throw away all undo and redo information. */ public void clear() { clearRedo(); int deletedFrames = 0; while (history.size() > 1) { shrinkHistory(); deletedFrames++; } updateSnapshots(-deletedFrames); updateActions(); savePointArmed = false; if (logger.isTraceEnabled()) { dumpBuffer(); } } /** * Throw away all redo information */ public void clearRedo() { // Make sure there is no history after the current frame (which there // might be if an undo has been performed) if (currentFrame < (history.size() - 1)) { do { history.removeLast(); } while (currentFrame < (history.size() - 1)); updateSnapshots(0); updateActions(); } } public <T> void addBuffer(BufferKey<T> key, T buffer) { addBuffer(key, buffer, null); } public <T> void addBuffer(BufferKey<T> key, T buffer, UndoListener listener) { clearRedo(); history.getLast().put(key, buffer); writeableBufferCache.put(key, buffer); if (listener != null) { keyListeners.put(key, listener); } if (logger.isTraceEnabled()) { logger.trace("Buffer added: " + key); } } public void removeBuffer(BufferKey<?> key) { writeableBufferCache.remove(key); readOnlyBufferCache.remove(key); for (Map<BufferKey<?>, Object> historyFrame: history) { historyFrame.remove(key); } keyListeners.remove(key); if (logger.isTraceEnabled()) { logger.trace("Buffer removed: " + key); } } @SuppressWarnings("unchecked") public <T> T getBuffer(BufferKey<T> key) { if (writeableBufferCache.containsKey(key)) { if (logger.isTraceEnabled()) { logger.trace("Getting buffer " + key + " for reading from writeable buffer cache"); } return (T) writeableBufferCache.get(key); } else if (readOnlyBufferCache.containsKey(key)) { if (logger.isTraceEnabled()) { logger.trace("Getting buffer " + key + " for reading from read-only buffer cache"); } return (T) readOnlyBufferCache.get(key); } else { if (logger.isTraceEnabled()) { logger.trace("Getting buffer " + key + " for reading from history"); } T buffer = findMostRecentCopy(key); readOnlyBufferCache.put(key, buffer); return buffer; } } @SuppressWarnings("unchecked") public <T> T getBufferForEditing(BufferKey<T> key) { if (savePointArmed) { savePoint(); } if (writeableBufferCache.containsKey(key)) { if (logger.isTraceEnabled()) { logger.trace("Getting buffer " + key + " for writing from writeable buffer cache"); } return (T) writeableBufferCache.get(key); } else { clearRedo(); if (readOnlyBufferCache.containsKey(key)) { if (logger.isTraceEnabled()) { logger.trace("Copying buffer " + key + " for writing from read-only buffer cache"); } T buffer = (T) readOnlyBufferCache.remove(key); T copy = copyObject(buffer); history.getLast().put(key, copy); writeableBufferCache.put(key, copy); return copy; } else { if (logger.isTraceEnabled()) { logger.trace("Copying buffer " + key + " for writing from history"); } Map<BufferKey<?>, Object> currentHistoryFrame = history.getLast(); if (currentHistoryFrame.containsKey(key)) { // TODO: this should never happen. Remove? T buffer = (T) currentHistoryFrame.get(key); writeableBufferCache.put(key, buffer); return buffer; } else { // The buffer does not exist in the current history frame yet. Copy // it. T buffer = findMostRecentCopy(key); T copy = copyObject(buffer); currentHistoryFrame.put(key, copy); writeableBufferCache.put(key, copy); return copy; } } } } public void addListener(UndoListener listener) { if (logger.isTraceEnabled()) { logger.trace("Adding listener " + listener); } listeners.add(listener); } public void removeListener(UndoListener listener) { if (logger.isTraceEnabled()) { logger.trace("Removing listener " + listener); } listeners.remove(listener); } public Class<?>[] getStopAtClasses() { return stopAt.toArray(new Class<?>[stopAt.size()]); } public void setStopAtClasses(Class<?>... stopAt) { this.stopAt = new HashSet<>(Arrays.asList(stopAt)); } public int getDataSize() { return MemoryUtils.getSize(history, stopAt); } private void updateSnapshots(int delta) { if (logger.isDebugEnabled()) { logger.debug("Updating snapshots"); } int frameCount = history.size(); for (Iterator<Reference<Snapshot>> i = snapshots.iterator(); i.hasNext(); ) { Snapshot snapshot = i.next().get(); if (snapshot == null) { if (logger.isDebugEnabled()) { logger.debug("Removing garbage collected snapshot"); } i.remove(); } else { snapshot.frame += delta; if ((snapshot.frame < 0) || (snapshot.frame >= frameCount)) { if (logger.isDebugEnabled()) { logger.debug("Disabling and removing snapshot with invalid frame reference"); } snapshot.frame = -1; i.remove(); } } } } private void pruneHistory() { int deletedFrames = 0; while (history.size() > maxFrames) { shrinkHistory(); deletedFrames++; } if (deletedFrames > 0) { updateSnapshots(-deletedFrames); } if (logger.isTraceEnabled()) { dumpBuffer(); } } private void shrinkHistory() { if (logger.isDebugEnabled()) { logger.debug("Removing oldest history frame; moving contents to next oldest frame"); } // Remove oldest frame Map<BufferKey<?>, Object> oldestFrame = history.removeFirst(); // Move all buffers from the previous oldest frame to the new // oldest frame, except the ones that already exist Map<BufferKey<?>, Object> nextOldestFrame = history.getFirst(); oldestFrame.entrySet().stream() .filter(entry -> !nextOldestFrame.containsKey(entry.getKey())) .forEach(entry -> nextOldestFrame.put(entry.getKey(), entry.getValue())); if (currentFrame > 0) { currentFrame--; } } private <T> T findMostRecentCopy(BufferKey<T> key) { return findMostRecentCopy(key, currentFrame); } @SuppressWarnings("unchecked") <T> T findMostRecentCopy(BufferKey<T> key, int frame) { for (ListIterator<Map<BufferKey<?>, Object>> i = history.listIterator(frame + 1); i.hasPrevious(); ) { Map<BufferKey<?>, Object> historyFrame = i.previous(); if (historyFrame.containsKey(key)) { if (logger.isTraceEnabled()) { logger.trace("Most recent copy of buffer " + key + " found in frame " + frame + " of history"); } return (T) historyFrame.get(key); } frame--; } if (logger.isTraceEnabled()) { logger.trace("Buffer " + key + " not present in undo history"); } return null; } private void dumpBuffer() { int index = 0; long totalDataSize = 0; for (Map<BufferKey<?>, Object> frame: history) { int frameSize = MemoryUtils.getSize(frame, stopAt); totalDataSize += frameSize; logger.debug(((index == currentFrame) ? "* " : " ") + " " + ((index < 10) ? "0" : "") + index + ": " + frame.size() + " buffers (size: " + (frameSize / 1024) + " KB)"); index++; } logger.debug(" Total data size: " + (totalDataSize / 1024) + " KB"); } private void updateActions() { if (undoAction != null) { undoAction.setEnabled(currentFrame > 0); } if (redoAction != null) { redoAction.setEnabled(currentFrame < (history.size() - 1)); } } private Action undoAction, redoAction; private final int maxFrames; private final LinkedList<Map<BufferKey<?>, Object>> history = new LinkedList<>(); private int currentFrame; private final Map<BufferKey<?>, Object> readOnlyBufferCache = new WeakHashMap<>(); private final Map<BufferKey<?>, Object> writeableBufferCache = new WeakHashMap<>(); private final List<UndoListener> listeners = new ArrayList<>(); private final Map<BufferKey<?>, UndoListener> keyListeners = new WeakHashMap<>(); private boolean savePointArmed; private final Set<Reference<Snapshot>> snapshots = new HashSet<>(); private Set<Class<?>> stopAt; private static final int DEFAULT_MAX_FRAMES = 25; private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(UndoManager.class); }