org.pepsoft.util.undo.UndoManager Maven / Gradle / Ivy
/*
* 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 synchronized void registerActions(Action undoAction, Action redoAction) {
this.undoAction = undoAction;
this.redoAction = redoAction;
updateActions();
}
public synchronized void unregisterActions() {
disableActions();
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.
*
* 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() {
boolean savePointWasArmed = true;
synchronized (this) {
if ((!savePointArmed) /*&& ((currentFrame < (history.size() - 1)) || isDirty())*/) {
savePointArmed = true;
savePointWasArmed = false;
}
}
if (! savePointWasArmed) {
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() {
synchronized (this) {
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 synchronized 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} if the current history frame is dirty.
*/
public synchronized boolean isDirty() {
return ! writeableBufferCache.isEmpty();
}
/**
* Rolls back all buffers to the previous save point, if there is one still
* available.
*
* @return {@code true} if the undo was succesful.
*/
public boolean undo() {
final boolean undoPerformed;
final Map, Object> previousHistoryFrame;
synchronized (this) {
if (currentFrame > 0) {
currentFrame--;
readOnlyBufferCache.clear();
writeableBufferCache.clear();
previousHistoryFrame = history.get(currentFrame + 1);
undoPerformed = true;
} else {
undoPerformed = false;
previousHistoryFrame = null;
}
}
if (undoPerformed) {
listeners.forEach(UndoListener::undoPerformed);
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} if the redo was succesful.
*/
public boolean redo() {
final boolean redoPerformed;
final Map, Object> currentHistoryFrame;
synchronized (this) {
if (currentFrame < (history.size() - 1)) {
currentFrame++;
readOnlyBufferCache.clear();
writeableBufferCache.clear();
currentHistoryFrame = history.get(currentFrame);
redoPerformed = true;
} else {
redoPerformed = false;
currentHistoryFrame = null;
}
}
if (redoPerformed) {
listeners.forEach(UndoListener::redoPerformed);
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 synchronized 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 synchronized 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 void addBuffer(BufferKey key, T buffer) {
addBuffer(key, buffer, null);
}
public synchronized void addBuffer(BufferKey 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 synchronized void removeBuffer(BufferKey> key) {
writeableBufferCache.remove(key);
readOnlyBufferCache.remove(key);
for (Map, Object> historyFrame: history) {
historyFrame.remove(key);
}
keyListeners.remove(key);
if (logger.isTraceEnabled()) {
logger.trace("Buffer removed: " + key);
}
}
@SuppressWarnings("unchecked")
public synchronized T getBuffer(BufferKey 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 synchronized T getBufferForEditing(BufferKey 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, 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 synchronized void addListener(UndoListener listener) {
if (logger.isTraceEnabled()) {
logger.trace("Adding listener " + listener);
}
listeners.add(listener);
}
public synchronized void removeListener(UndoListener listener) {
if (logger.isTraceEnabled()) {
logger.trace("Removing listener " + listener);
}
listeners.remove(listener);
}
public synchronized Class>[] getStopAtClasses() {
return stopAt.toArray(new Class>[stopAt.size()]);
}
public synchronized void setStopAtClasses(Class>... stopAt) {
this.stopAt = new HashSet<>(Arrays.asList(stopAt));
}
public synchronized long getDataSize() {
return MemoryUtils.getSize(history, stopAt);
}
private void updateSnapshots(int delta) {
if (logger.isDebugEnabled()) {
logger.debug("Updating snapshots");
}
int frameCount = history.size();
for (Iterator> 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, Object> oldestFrame = history.removeFirst();
// Move all buffers from the previous oldest frame to the new
// oldest frame, except the ones that already exist
Map, 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 findMostRecentCopy(BufferKey key) {
return findMostRecentCopy(key, currentFrame);
}
@SuppressWarnings("unchecked")
synchronized T findMostRecentCopy(BufferKey key, int frame) {
for (ListIterator