Undo/redo system enhancements, DnD for component tree, bug fixes
[debian/openrocket] / src / net / sf / openrocket / document / OpenRocketDocument.java
1 package net.sf.openrocket.document;
2
3 import java.awt.event.ActionEvent;
4 import java.io.File;
5 import java.util.ArrayList;
6 import java.util.LinkedList;
7 import java.util.List;
8
9 import javax.swing.AbstractAction;
10 import javax.swing.Action;
11
12 import net.sf.openrocket.document.events.DocumentChangeEvent;
13 import net.sf.openrocket.document.events.DocumentChangeListener;
14 import net.sf.openrocket.document.events.SimulationChangeEvent;
15 import net.sf.openrocket.gui.main.ExceptionHandler;
16 import net.sf.openrocket.logging.LogHelper;
17 import net.sf.openrocket.logging.TraceException;
18 import net.sf.openrocket.rocketcomponent.ComponentChangeEvent;
19 import net.sf.openrocket.rocketcomponent.ComponentChangeListener;
20 import net.sf.openrocket.rocketcomponent.Configuration;
21 import net.sf.openrocket.rocketcomponent.Rocket;
22 import net.sf.openrocket.startup.Application;
23 import net.sf.openrocket.util.BugException;
24 import net.sf.openrocket.util.Icons;
25
26 /**
27  * Class describing an entire OpenRocket document, including a rocket and
28  * simulations.  This class also handles undo/redo operations for the rocket structure.
29  * 
30  * @author Sampo Niskanen <sampo.niskanen@iki.fi>
31  */
32 public class OpenRocketDocument implements ComponentChangeListener {
33         private static final LogHelper log = Application.getLogger();
34         
35         /**
36          * The minimum number of undo levels that are stored.
37          */
38         public static final int UNDO_LEVELS = 50;
39         /**
40          * The margin of the undo levels.  After the number of undo levels exceeds 
41          * UNDO_LEVELS by this amount the undo is purged to that length.
42          */
43         public static final int UNDO_MARGIN = 10;
44         
45         public static final String SIMULATION_NAME_PREFIX = "Simulation ";
46         
47         /** Whether an undo error has already been reported to the user */
48         private static boolean undoErrorReported = false;
49         
50         private final Rocket rocket;
51         private final Configuration configuration;
52         
53         private final ArrayList<Simulation> simulations = new ArrayList<Simulation>();
54         
55
56         /*
57          * The undo/redo variables and mechanism are documented in doc/undo-redo-flow.*
58          */
59
60         /** 
61          * The undo history of the rocket.   Whenever a new undo position is created while the
62          * rocket is in "dirty" state, the rocket is copied here.
63          */
64         private LinkedList<Rocket> undoHistory = new LinkedList<Rocket>();
65         private LinkedList<String> undoDescription = new LinkedList<String>();
66         
67         /**
68          * The position in the undoHistory we are currently at.  If modifications have been
69          * made to the rocket, the rocket is in "dirty" state and this points to the previous
70          * "clean" state.
71          */
72         private int undoPosition = -1; // Illegal position, init in constructor
73         
74         /**
75          * The description of the next action that modifies this rocket.
76          */
77         private String nextDescription = null;
78         private String storedDescription = null;
79         
80
81         private File file = null;
82         private int savedID = -1;
83         
84         private final StorageOptions storageOptions = new StorageOptions();
85         
86
87         private final List<DocumentChangeListener> listeners =
88                         new ArrayList<DocumentChangeListener>();
89         
90         /* These must be initialized after undo history is set up. */
91         private final UndoRedoAction undoAction;
92         private final UndoRedoAction redoAction;
93         
94         
95         public OpenRocketDocument(Rocket rocket) {
96                 this(rocket.getDefaultConfiguration());
97         }
98         
99         
100         private OpenRocketDocument(Configuration configuration) {
101                 this.configuration = configuration;
102                 this.rocket = configuration.getRocket();
103                 
104                 clearUndo();
105                 
106                 undoAction = new UndoRedoAction(UndoRedoAction.UNDO);
107                 redoAction = new UndoRedoAction(UndoRedoAction.REDO);
108                 
109                 rocket.addComponentChangeListener(this);
110         }
111         
112         
113
114
115         public Rocket getRocket() {
116                 return rocket;
117         }
118         
119         
120         public Configuration getDefaultConfiguration() {
121                 return configuration;
122         }
123         
124         
125         public File getFile() {
126                 return file;
127         }
128         
129         public void setFile(File file) {
130                 this.file = file;
131         }
132         
133         
134         public boolean isSaved() {
135                 return rocket.getModID() == savedID;
136         }
137         
138         public void setSaved(boolean saved) {
139                 if (saved == false)
140                         this.savedID = -1;
141                 else
142                         this.savedID = rocket.getModID();
143         }
144         
145         /**
146          * Retrieve the default storage options for this document.
147          * 
148          * @return      the storage options.
149          */
150         public StorageOptions getDefaultStorageOptions() {
151                 return storageOptions;
152         }
153         
154         
155
156
157
158         @SuppressWarnings("unchecked")
159         public List<Simulation> getSimulations() {
160                 return (ArrayList<Simulation>) simulations.clone();
161         }
162         
163         public int getSimulationCount() {
164                 return simulations.size();
165         }
166         
167         public Simulation getSimulation(int n) {
168                 return simulations.get(n);
169         }
170         
171         public int getSimulationIndex(Simulation simulation) {
172                 return simulations.indexOf(simulation);
173         }
174         
175         public void addSimulation(Simulation simulation) {
176                 simulations.add(simulation);
177                 fireDocumentChangeEvent(new SimulationChangeEvent(simulation));
178         }
179         
180         public void addSimulation(Simulation simulation, int n) {
181                 simulations.add(n, simulation);
182                 fireDocumentChangeEvent(new SimulationChangeEvent(simulation));
183         }
184         
185         public void removeSimulation(Simulation simulation) {
186                 simulations.remove(simulation);
187                 fireDocumentChangeEvent(new SimulationChangeEvent(simulation));
188         }
189         
190         public Simulation removeSimulation(int n) {
191                 Simulation simulation = simulations.remove(n);
192                 fireDocumentChangeEvent(new SimulationChangeEvent(simulation));
193                 return simulation;
194         }
195         
196         /**
197          * Return a unique name suitable for the next simulation.  The name begins
198          * with {@link #SIMULATION_NAME_PREFIX} and has a unique number larger than any
199          * previous simulation.
200          * 
201          * @return      the new name.
202          */
203         public String getNextSimulationName() {
204                 // Generate unique name for the simulation
205                 int maxValue = 0;
206                 for (Simulation s : simulations) {
207                         String name = s.getName();
208                         if (name.startsWith(SIMULATION_NAME_PREFIX)) {
209                                 try {
210                                         maxValue = Math.max(maxValue,
211                                                         Integer.parseInt(name.substring(SIMULATION_NAME_PREFIX.length())));
212                                 } catch (NumberFormatException ignore) {
213                                 }
214                         }
215                 }
216                 return SIMULATION_NAME_PREFIX + (maxValue + 1);
217         }
218         
219         
220         /**
221          * Adds an undo point at this position.  This method should be called *before* any
222          * action that is to be undoable.  All actions after the call will be undone by a 
223          * single "Undo" action.
224          * <p>
225          * The description should be a short, descriptive string of the actions that will 
226          * follow.  This is shown to the user e.g. in the Edit-menu, for example 
227          * "Undo (Modify body tube)".  If the actions are not known (in general should not
228          * be the case!) description may be null.
229          * <p>
230          * If this method is called successively without any change events occurring between the
231          * calls, only the last call will have any effect.
232          * 
233          * @param description A short description of the following actions.
234          */
235         public void addUndoPosition(String description) {
236                 
237                 if (storedDescription != null) {
238                         logUndoError("addUndoPosition called while storedDescription=" + storedDescription +
239                                         " description=" + description);
240                 }
241                 
242                 // Check whether modifications have been done since last call
243                 if (isCleanState()) {
244                         // No modifications
245                         log.info("Adding undo position '" + description + "' to " + this + ", document was in clean state");
246                         nextDescription = description;
247                         return;
248                 }
249                 
250                 log.info("Adding undo position '" + description + "' to " + this + ", document is in unclean state");
251                 
252                 /*
253                  * Modifications have been made to the rocket.  We should be at the end of the
254                  * undo history, but check for consistency and try to recover.
255                  */
256                 if (undoPosition != undoHistory.size() - 1) {
257                         logUndoError("undo position inconsistency");
258                 }
259                 while (undoPosition < undoHistory.size() - 1) {
260                         undoHistory.removeLast();
261                         undoDescription.removeLast();
262                 }
263                 
264
265                 // Add the current state to the undo history
266                 undoHistory.add(rocket.copyWithOriginalID());
267                 undoDescription.add(null);
268                 nextDescription = description;
269                 undoPosition++;
270                 
271
272                 // Maintain maximum undo size
273                 if (undoHistory.size() > UNDO_LEVELS + UNDO_MARGIN && undoPosition > UNDO_MARGIN) {
274                         for (int i = 0; i < UNDO_MARGIN; i++) {
275                                 undoHistory.removeFirst();
276                                 undoDescription.removeFirst();
277                                 undoPosition--;
278                         }
279                 }
280         }
281         
282         
283         /**
284          * Start a time-limited undoable operation.  After the operation {@link #stopUndo()}
285          * must be called, which will restore the previous undo description into effect.
286          * Only one level of start-stop undo descriptions is supported, i.e. start-stop
287          * undo cannot be nested, and no other undo operations may be called between
288          * the start and stop calls.
289          * 
290          * @param description   Description of the following undoable operations.
291          */
292         public void startUndo(String description) {
293                 if (storedDescription != null) {
294                         logUndoError("startUndo called while storedDescription=" + storedDescription +
295                                         " description=" + description);
296                 }
297                 log.info("Starting time-limited undoable operation '" + description + "' for " + this);
298                 String store = nextDescription;
299                 addUndoPosition(description);
300                 storedDescription = store;
301         }
302         
303         /**
304          * End the previous time-limited undoable operation.  This must be called after
305          * {@link #startUndo(String)} has been called before any other undo operations are
306          * performed.
307          */
308         public void stopUndo() {
309                 log.info("Ending time-limited undoable operation for " + this + " nextDescription=" +
310                                 nextDescription + "     storedDescription=" + storedDescription);
311                 String stored = storedDescription;
312                 storedDescription = null;
313                 addUndoPosition(stored);
314         }
315         
316         
317         public Action getUndoAction() {
318                 return undoAction;
319         }
320         
321         
322         public Action getRedoAction() {
323                 return redoAction;
324         }
325         
326         
327         /**
328          * Clear the undo history.
329          */
330         public void clearUndo() {
331                 log.info("Clearing undo history of " + this);
332                 undoHistory.clear();
333                 undoDescription.clear();
334                 
335                 undoHistory.add(rocket.copyWithOriginalID());
336                 undoDescription.add(null);
337                 undoPosition = 0;
338                 
339                 if (undoAction != null)
340                         undoAction.setAllValues();
341                 if (redoAction != null)
342                         redoAction.setAllValues();
343         }
344         
345         
346         @Override
347         public void componentChanged(ComponentChangeEvent e) {
348                 
349                 if (!e.isUndoChange()) {
350                         if (undoPosition < undoHistory.size() - 1) {
351                                 log.info("Rocket changed while in undo history, removing redo information for " + this +
352                                                 " undoPosition=" + undoPosition + " undoHistory.size=" + undoHistory.size() +
353                                                 " isClean=" + isCleanState());
354                         }
355                         // Remove any redo information if available
356                         while (undoPosition < undoHistory.size() - 1) {
357                                 undoHistory.removeLast();
358                                 undoDescription.removeLast();
359                         }
360                         
361                         // Set the latest description
362                         undoDescription.set(undoPosition, nextDescription);
363                 }
364                 
365                 undoAction.setAllValues();
366                 redoAction.setAllValues();
367         }
368         
369         
370         /**
371          * Return whether undo action is available.
372          * @return      <code>true</code> if undo can be performed
373          */
374         public boolean isUndoAvailable() {
375                 if (undoPosition > 0)
376                         return true;
377                 
378                 return !isCleanState();
379         }
380         
381         /**
382          * Return the description of what action would be undone if undo is called.
383          * @return      the description what would be undone, or <code>null</code> if description unavailable.
384          */
385         public String getUndoDescription() {
386                 if (!isUndoAvailable())
387                         return null;
388                 
389                 if (isCleanState()) {
390                         return undoDescription.get(undoPosition - 1);
391                 } else {
392                         return undoDescription.get(undoPosition);
393                 }
394         }
395         
396         
397         /**
398          * Return whether redo action is available.
399          * @return      <code>true</code> if redo can be performed
400          */
401         public boolean isRedoAvailable() {
402                 return undoPosition < undoHistory.size() - 1;
403         }
404         
405         /**
406          * Return the description of what action would be redone if redo is called.
407          * @return      the description of what would be redone, or <code>null</code> if description unavailable.
408          */
409         public String getRedoDescription() {
410                 if (!isRedoAvailable())
411                         return null;
412                 
413                 return undoDescription.get(undoPosition);
414         }
415         
416         
417         /**
418          * Perform undo operation on the rocket.
419          */
420         public void undo() {
421                 log.info("Performing undo for " + this + " undoPosition=" + undoPosition +
422                                 " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState());
423                 if (!isUndoAvailable()) {
424                         logUndoError("Undo not available");
425                         undoAction.setAllValues();
426                         redoAction.setAllValues();
427                         return;
428                 }
429                 if (storedDescription != null) {
430                         logUndoError("undo() called with storedDescription=" + storedDescription);
431                 }
432                 
433                 // Update history position
434                 
435                 if (isCleanState()) {
436                         // We are in a clean state, simply move backwards in history
437                         undoPosition--;
438                 } else {
439                         if (undoPosition != undoHistory.size() - 1) {
440                                 logUndoError("undo position inconsistency");
441                         }
442                         // Modifications have been made, save the state and restore previous state
443                         undoHistory.add(rocket.copyWithOriginalID());
444                         undoDescription.add(null);
445                 }
446                 
447                 rocket.loadFrom(undoHistory.get(undoPosition).copyWithOriginalID());
448         }
449         
450         
451         /**
452          * Perform redo operation on the rocket.
453          */
454         public void redo() {
455                 log.info("Performing redo for " + this + " undoPosition=" + undoPosition +
456                                 " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState());
457                 if (!isRedoAvailable()) {
458                         logUndoError("Redo not available");
459                         undoAction.setAllValues();
460                         redoAction.setAllValues();
461                         return;
462                 }
463                 if (storedDescription != null) {
464                         logUndoError("redo() called with storedDescription=" + storedDescription);
465                 }
466                 
467                 undoPosition++;
468                 
469                 rocket.loadFrom(undoHistory.get(undoPosition).copyWithOriginalID());
470         }
471         
472         
473         private boolean isCleanState() {
474                 return rocket.getModID() == undoHistory.get(undoPosition).getModID();
475         }
476         
477         
478         /**
479          * Log a non-fatal undo/redo error or inconsistency.  Reports it to the user the first 
480          * time it occurs, but not on subsequent times.  Logs automatically the undo system state.
481          */
482         private void logUndoError(String error) {
483                 log.error(1, error + ": this=" + this + " undoPosition=" + undoPosition +
484                                 " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState() +
485                                 " nextDescription=" + nextDescription + " storedDescription=" + storedDescription,
486                                 new TraceException());
487                 
488                 if (!undoErrorReported) {
489                         undoErrorReported = true;
490                         ExceptionHandler.handleErrorCondition("Undo/Redo error: " + error);
491                 }
492         }
493         
494         ///////  Listeners
495         
496         public void addDocumentChangeListener(DocumentChangeListener listener) {
497                 listeners.add(listener);
498         }
499         
500         public void removeDocumentChangeListener(DocumentChangeListener listener) {
501                 listeners.remove(listener);
502         }
503         
504         protected void fireDocumentChangeEvent(DocumentChangeEvent event) {
505                 DocumentChangeListener[] array = listeners.toArray(new DocumentChangeListener[0]);
506                 for (DocumentChangeListener l : array) {
507                         l.documentChanged(event);
508                 }
509         }
510         
511         
512
513
514         /**
515          * Inner class to implement undo/redo actions.
516          */
517         private class UndoRedoAction extends AbstractAction {
518                 public static final int UNDO = 1;
519                 public static final int REDO = 2;
520                 
521                 private final int type;
522                 
523                 // Sole constructor
524                 public UndoRedoAction(int type) {
525                         if (type != UNDO && type != REDO) {
526                                 throw new IllegalArgumentException("Unknown type = " + type);
527                         }
528                         this.type = type;
529                         setAllValues();
530                 }
531                 
532                 
533                 // Actual action to make
534                 public void actionPerformed(ActionEvent e) {
535                         switch (type) {
536                         case UNDO:
537                                 log.user("Performing undo, event=" + e);
538                                 undo();
539                                 break;
540                         
541                         case REDO:
542                                 log.user("Performing redo, event=" + e);
543                                 redo();
544                                 break;
545                         }
546                 }
547                 
548                 
549                 // Set all the values correctly (name and enabled/disabled status)
550                 public void setAllValues() {
551                         String name, desc;
552                         boolean actionEnabled;
553                         
554                         switch (type) {
555                         case UNDO:
556                                 name = "Undo";
557                                 desc = getUndoDescription();
558                                 actionEnabled = isUndoAvailable();
559                                 this.putValue(SMALL_ICON, Icons.EDIT_UNDO);
560                                 break;
561                         
562                         case REDO:
563                                 name = "Redo";
564                                 desc = getRedoDescription();
565                                 actionEnabled = isRedoAvailable();
566                                 this.putValue(SMALL_ICON, Icons.EDIT_REDO);
567                                 break;
568                         
569                         default:
570                                 throw new BugException("illegal type=" + type);
571                         }
572                         
573                         if (desc != null)
574                                 name = name + " (" + desc + ")";
575                         
576                         putValue(NAME, name);
577                         setEnabled(actionEnabled);
578                 }
579         }
580 }