/* * UndoRedoSupport.java * * Created on August 8, 2007, 7:44 PM * */ package org.autoplot.state; import java.awt.event.ActionEvent; import java.awt.image.BufferedImage; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.StringTokenizer; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.GZIPOutputStream; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.ImageIcon; import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.SwingUtilities; import org.das2.datum.TimeParser; import org.das2.datum.TimeUtil; import org.das2.system.RequestProcessor; import org.autoplot.ApplicationModel; import org.autoplot.AutoplotUtil; import org.autoplot.dom.Application; import org.autoplot.dom.BindingModel; import org.autoplot.dom.Diff; import org.autoplot.dom.DomUtil; import org.autoplot.dom.Plot; import org.autoplot.datasource.AutoplotSettings; /** * UndoRedoSupport keeps track of a series of application states, providing * "undo" and "redo" operations. * @author jbf */ public class UndoRedoSupport { private static final Logger logger= org.das2.util.LoggerManager.getLogger("autoplot.dom.vap"); ApplicationModel applicationModel; /** * Creates a new instance of UndoRedoSupport * @param applicationModel the model which contains basic information about any Autoplot application. */ public UndoRedoSupport(ApplicationModel applicationModel) { this.applicationModel = applicationModel; applicationModel.addPropertyChangeListener(new PropertyChangeListener() { @Override public void propertyChange(PropertyChangeEvent ev) { if (ev.getPropertyName().equals(ApplicationModel.PROP_VAPFILE)) { resetHistory(); } } }); } /** * create all the menu items for a JMenu offering undo targets. * @param undoMultipleMenu the menu */ public void refreshUndoMultipleMenu(JMenu undoMultipleMenu) { undoMultipleMenu.removeAll(); int lstateStackPos; List lstateStack; synchronized (this) { lstateStack= new ArrayList( stateStack ); } lstateStackPos= lstateStack.size(); for (int i = lstateStackPos - 1; i > Math.max(0, lstateStackPos - 10); i--) { StateStackElement prevState = lstateStack.get(i); String label = prevState.deltaDesc; final int ii = lstateStackPos - i; JMenuItem item= new JMenuItem(new AbstractAction(label) { @Override public void actionPerformed(ActionEvent e) { org.das2.util.LoggerManager.logGuiEvent(e); Runnable run= () -> { undo(ii); }; new Thread( run, "undoLaterThread" ).start(); } }); item.setToolTipText(prevState.docString); if ( lstateStack.get(i-1).thumb!=null ) item.setIcon( new ImageIcon( lstateStack.get(i-1).thumb ) ); // not sure why, but... undoMultipleMenu.add(item); } } /** * A model containing a state, including a description of what changed and * a thumbnail. */ public static class StateStackElement { private final Application state; String deltaDesc; // one-line description String docString; // more verbose description, describing the transition to this state. BufferedImage thumb; /** * create an element * @param state the new state * @param deltaDesc what changed to get to this state * @param docString documentation string */ public StateStackElement(Application state, String deltaDesc, String docString ) { this.state = state; this.deltaDesc = deltaDesc; this.docString= docString; } @Override public String toString() { return deltaDesc; } /** * return the longer description used in tooltips. * @return */ public String getDocString() { return docString; } /** * return the shorter description of the change. * @return */ public String getDeltaDesc() { return deltaDesc; } } /** * this is the stack of states, with the last element being the most recent state */ private final LinkedList stateStack = new LinkedList<>(); /** * these are the states which were undone, so that undo can be redone. */ private final LinkedList redoStack = new LinkedList<>(); private String redoLabel = null; public static final String PROP_REDOLABEL = "redoLabel"; private PropertyChangeSupport propertyChangeSupport = new PropertyChangeSupport(this); public synchronized void removePropertyChangeListener(String propertyName, PropertyChangeListener listener) { propertyChangeSupport.removePropertyChangeListener(propertyName, listener); } public synchronized void removePropertyChangeListener(PropertyChangeListener listener) { propertyChangeSupport.removePropertyChangeListener(listener); } public synchronized void addPropertyChangeListener(String propertyName, PropertyChangeListener listener) { propertyChangeSupport.addPropertyChangeListener(propertyName, listener); } public synchronized void addPropertyChangeListener(PropertyChangeListener listener) { propertyChangeSupport.addPropertyChangeListener(listener); } /** * get the action to trigger undo. * @return action that can be attached to button. */ public Action getUndoAction() { return new AbstractAction("Undo") { @Override public void actionPerformed(ActionEvent e) { org.das2.util.LoggerManager.logGuiEvent(e); Runnable run= () -> { undo(); }; new Thread( run, "undoLaterThread" ).start(); } }; } /** * undo the last state change. */ public void undo() { undo(1); } /** * reset the application to an old state from the state stack. * @param level the number of states to undo (1 is jump to the last state). */ public void undo(int level) { logger.log(Level.FINE, "undo {0}", level); String oldRedoLabel= getRedoLabel(); if ( SwingUtilities.isEventDispatchThread() ) { logger.warning("undo called from event thread"); } int oldDepth= stateStack.size(); if ( level>(oldDepth-1) ) { level= oldDepth-1; } if ( level==0 ) { return; } if ( oldDepth>0 ) { StateStackElement elephant; while ( level>0 ) { elephant = stateStack.removeLast(); redoStack.add( 0, elephant ); level=level-1; } elephant= stateStack.peekLast(); assert elephant!=null; ignoringUpdates = true; applicationModel.setRestoringState(true); applicationModel.restoreState(elephant.state); applicationModel.setRestoringState(false); ignoringUpdates = false; RequestProcessor.invokeLater(() -> { AutoplotUtil.reloadAll( applicationModel.getDom() ); }); } redoLabel= getRedoLabel(); propertyChangeSupport.firePropertyChange( PROP_REDOLABEL, oldRedoLabel, redoLabel ); propertyChangeSupport.firePropertyChange( PROP_DEPTH, oldDepth, oldDepth+1 ); } /** * get the action to trigger redo. * @return action that can be attached to button. */ public Action getRedoAction() { return new AbstractAction("redo") { @Override public void actionPerformed(ActionEvent e) { org.das2.util.LoggerManager.logGuiEvent(e); Runnable run= () -> { redo(); }; new Thread( run, "redoLaterThread" ).start(); } }; } /** * redo the state change that was undone, popping up the state stack one position. */ public void redo() { logger.fine("redo"); String oldRedoLabel= getRedoLabel(); int oldDepth= stateStack.size(); if ( !redoStack.isEmpty() ) { StateStackElement elephant = redoStack.pop(); ignoringUpdates = true; applicationModel.setRestoringState(true); applicationModel.restoreState(elephant.state); applicationModel.setRestoringState(false); ignoringUpdates = false; stateStack.add(elephant); } redoLabel= getRedoLabel(); propertyChangeSupport.firePropertyChange( PROP_REDOLABEL, oldRedoLabel, redoLabel ); propertyChangeSupport.firePropertyChange( PROP_DEPTH, oldDepth, stateStack.size() ); } /** * request that a state be pushed onto the state stack. * @param ev */ public void pushState(PropertyChangeEvent ev) { pushState(ev,null); } /** * remove extra xaxis differences that are redundant because of bindings to dom.timerange. * @param dom the model, presumably containing the same plots and bindings. * @param diffs * @return */ private static List removeTimeRangeBindings( Application dom, List diffs ) { dom= (Application) dom.copy(); diffs= new ArrayList( diffs ); List timeRangeBound= new ArrayList(); for (Diff s : diffs) { Pattern pattern= Pattern.compile("plots\\[(\\d+)\\].xaxis.range"); Matcher m= pattern.matcher(s.propertyName()); if ( m.matches() ) { try { Plot p= dom.getPlots( Integer.parseInt(m.group(1) ) ); BindingModel bm= DomUtil.findBinding( dom, p.getXaxis(), "range", dom, "timeRange" ); //BindingModel bm= dom.getController().findBinding( p.getXaxis(), "range", dom, "timeRange" ); if ( bm!=null ) { timeRangeBound.add(s); } } catch ( IndexOutOfBoundsException ex ) { logger.severe("IndexOutOfBounds error that needs to be fixed because needs synchronization"); } } } diffs.removeAll(timeRangeBound); return diffs; } /** * remove extra xaxis differences that are redundant because of scale change. * @param dom the model, presumably containing the same plots and bindings. * @param diffs * @return */ private static List removeRedundantDiffs( Application dom, List diffs ) { diffs= new ArrayList( diffs ); List removeUs= new ArrayList(); for (Diff s : diffs) { Pattern pattern= Pattern.compile("plots\\[(\\d+)\\].([xyz])axis.range"); Matcher m= pattern.matcher(s.propertyName()); if ( m.matches() ) { for (Diff s2 : diffs) { if ( s2.propertyName().equals( "plots["+m.group(1)+"]."+m.group(2)+"axis.scale" ) ) { removeUs.add(s); } } } } diffs.removeAll(removeUs); return diffs; } /** * provide a human-readable description of the given diffs. * @param diffs list of differences to describe. * @param element really only provided to contain the output. * @return */ private StateStackElement describeChanges( List diffs, StateStackElement element ) { String docString; String labelStr; StringBuilder docBuf = new StringBuilder(); int count = 0; boolean axisRangeOnly = true; boolean zaxisRangeOnly = true; boolean axisAuto = false; boolean timeRange= false; String focus=null; diffs= removeTimeRangeBindings( this.applicationModel.getDocumentModel(), diffs ); diffs= removeRedundantDiffs( this.applicationModel.getDocumentModel(), diffs ); for (Diff s : diffs) { if (s.getDescription().contains("plotDefaults")) { continue; } String thisDiffFocus=null; int i= s.propertyName().indexOf('.'); if ( i>-1 ) thisDiffFocus= s.propertyName().substring(0,i); if ( focus==null ) { focus= thisDiffFocus; } else { if ( !focus.equals(thisDiffFocus) && !s.propertyName().equals("timeRange") ) { focus=""; // indicate there is no one element } else if ( s.propertyName().equals("timeRange") ) { timeRange= true; } } count++; docBuf.append("
"); docBuf.append(s.getDescription()); if (s.propertyName().endsWith("axis.range") || s.propertyName().equals("timeRange") ) { if (s.propertyName().endsWith("zaxis.range")) { axisRangeOnly = false; } else { zaxisRangeOnly = false; } } else { if (s.propertyName().endsWith("autoRange")) { axisAuto = true; } else { axisRangeOnly = false; zaxisRangeOnly = false; } } } if (focus==null ) focus= ""; docString = docBuf.length() > 4 ? docBuf.substring(4) : ""; docString = "" + docString + ""; if (diffs.isEmpty()) { //state.diffs(elephant.state); for debugging element.deltaDesc = "unidentified change"; element.docString = "change was detected but could not be identified."; return element; } else if (zaxisRangeOnly && focus.length()>0 && count > 1) { if (axisAuto) { labelStr = focus + " first Z range change"; } else { labelStr = focus + " Z range change"; // (this shouldn't happen, because count will equal 1.) } } else if (axisRangeOnly && focus.length()>0 && count > 1) { if (axisAuto) { labelStr = focus + " first range change"; } else { labelStr = focus + " range changes"; } } else if (count > 3) { labelStr = "" + count + " changes"; } else { StringBuilder buf = new StringBuilder(); for (Diff s : diffs) { if (s.getDescription().contains("plotDefaults")) { continue; } buf.append(", ").append(s.getLabel()); } labelStr = buf.length() > 2 ? buf.substring(2) : ""; } if (labelStr.length() > 30) { StringTokenizer tok = new StringTokenizer(labelStr, ".,[", true); StringBuilder buf = new StringBuilder(); while (tok.hasMoreTokens()) { String ss = tok.nextToken(); buf.append(ss.substring(0, Math.min(ss.length(), 12))); } labelStr = buf.toString(); } element.deltaDesc = labelStr; element.docString = docString; return element; } /** * name of the property containing the number of states kept */ public static final String PROP_SIZE_LIMIT="sizeLimit"; private int sizeLimit=50; /** * get the number of states which will be kept * @return size */ public int getSizeLimit() { return sizeLimit; } /** * set the number of states which will be kept * @param size */ public void setSizeLimit(int size) { int oldSize= this.sizeLimit; this.sizeLimit = size; removeOldStates(); this.propertyChangeSupport.firePropertyChange( PROP_SIZE_LIMIT, oldSize, size ); } /** * remove old states from the bottom of the stack, adjusting stateStackPos as well. */ private void removeOldStates( ) { int len=sizeLimit; while ( stateStack.size()>len ) { stateStack.remove(0); } } /** * push the current state of the application onto the stack. * @param ev * @param label */ public void pushState( PropertyChangeEvent ev, String label ) { logger.log(Level.FINE, "pushState: {0}", label); synchronized ( this ) { if (ignoringUpdates) { logger.info("ignoring updates in undo stack"); return; } } Application state = applicationModel.createState(false); BufferedImage thumb= applicationModel.getThumbnail(50); StateStackElement elephant; elephant = stateStack.peekLast(); if (elephant != null && state.equals(elephant.state)) { return; } int oldDepth= stateStack.size(); String labelStr = "initial"; String docString= "initial state of application"; StateStackElement element= new StateStackElement( state, labelStr, docString ); if (elephant != null) { List diffss = elephant.state.diffs(state); //TODO: documentation/getDescription seem to be inverses. All state changes should be described in the forward direction. if ( diffss.isEmpty() ) return; element= describeChanges( diffss, element ); if ( label!=null && element.deltaDesc.endsWith(" changes" ) ) { element.deltaDesc= label; } } element.thumb= thumb; stateStack.add( element ); int newDepth= stateStack.size(); if ( label!=null ) { redoStack.clear(); } removeOldStates(); if ( saveStateDepth>0 ) { long t0= System.currentTimeMillis(); File f2= new File( AutoplotSettings.settings().resolveProperty(AutoplotSettings.PROP_AUTOPLOTDATA), "state/" ); if ( !f2.exists() ) { boolean ok= f2.mkdirs(); if ( !ok ) { throw new RuntimeException("unable to create folder "+ f2 ); } } File f3= new File( f2, TimeParser.create( "state_$Y$m$d_$H$M$S.vap.gz" ).format( TimeUtil.now(), null ) ); try ( OutputStream out= new GZIPOutputStream( new FileOutputStream(f3) ) ) { StatePersistence.saveState( out, state, ""); } catch (IOException ex) { logger.log(Level.SEVERE, ex.getMessage(), ex); } logger.fine( String.format( "saved state file in %d ms", ( System.currentTimeMillis()-t0 ) ) ); } propertyChangeSupport.firePropertyChange(PROP_DEPTH, oldDepth, newDepth); } /** * get the longer description for the action, intended to be used for the tooltip. * @return a human-readable description */ public String getUndoDescription() { if ( stateStack.size()<2 ) return null; //first 99% StateStackElement undo= stateStack.peekLast(); if ( undo!=null ) { if ( undo.equals(stateStack.peekFirst()) ) { return null; //very unlucky case where another thread emptied stack } else { return "Undo " + undo.docString; } } else { return null; } } /** * returns a label describing the undo operation, or null if the operation * doesn't exist. * @return the label */ public String getUndoLabel() { if ( stateStack.size()<2 ) return null; //first 99% StateStackElement undo= stateStack.peekLast(); if ( undo!=null ) { if ( undo.equals(stateStack.peekFirst()) ) { return null; //very unlucky case where another thread emptied stack } else { return "Undo " + undo.deltaDesc; } } else { return null; } } /** * get the longer description for the action, intended to be used for the tooltip. * @return */ public String getRedoDescription() { StateStackElement redo= redoStack.peekFirst(); if ( redo!=null ) { return "Redo " + redo.docString; } else { return null; } } /** * returns a label describing the redo operation, or null if the operation * doesn't exist. * @return the label */ public String getRedoLabel() { StateStackElement redo= redoStack.peekFirst(); if ( redo!=null ) { return "Redo " + redo.deltaDesc; } else { return null; } } /** * reset the history, for example after a vap file is loaded. */ public void resetHistory() { int oldDepth; synchronized (this) { oldDepth= stateStack.size(); stateStack.clear(); redoStack.clear(); } propertyChangeSupport.firePropertyChange( PROP_DEPTH, oldDepth, 0 ); } /** * Holds value of property ignoringUpdates. */ private boolean ignoringUpdates; /** * Getter for property ignoringUpdates. * @return Value of property ignoringUpdates. */ public synchronized boolean isIgnoringUpdates() { return this.ignoringUpdates; } /** * Setter for property ignoringUpdates. * @param ignoringUpdates New value of property ignoringUpdates. */ public synchronized void setIgnoringUpdates(boolean ignoringUpdates) { this.ignoringUpdates = ignoringUpdates; } /** * property name of the current depth */ public static final String PROP_DEPTH = "depth"; /** * get the current depth, where depth can be lower than the number of * states held, when redos can be done. * @return the current depth. */ public synchronized int getDepth() { return stateStack.size(); } /** * the number of states to keep in the states folder. Presently this is only implemented so that 0 disables the feature. */ public static final String PROP_SAVE_STATE_DEPTH= "saveStateDepth"; private int saveStateDepth= 0; /** * get the current number of persistent states stored in autoplot_data/state * @return */ public int getSaveStateDepth() { return saveStateDepth; } /** * set the number of persistent states to keep, or 0 will disable the feature. * @param depth zero or the number of states to keep. */ public void setSaveStateDepth( int depth ) { this.saveStateDepth= depth; } /** * allow scripts to peek into undo/redo stack for debugging. * @param pos * @return */ public synchronized StateStackElement peekAt( int pos ) { return stateStack.get(pos); } /** * used for feedback. * @param i * @return */ public String getLongUndoDescription( int i ) { StateStackElement e1; StateStackElement e0; synchronized (this) { e1= stateStack.get(i); e0= stateStack.get(i-1); } List diffss = e1.state.diffs(e0.state); diffss= removeTimeRangeBindings( e0.state, diffss ); StringBuilder docBuf= new StringBuilder(); for ( int j=0; j0 ) docBuf.append(";\n"); docBuf.append(s.getDescription()); } return docBuf.toString(); } }