View Javadoc
1   package org.opentrafficsim.editor;
2   
3   import java.rmi.RemoteException;
4   import java.util.ArrayDeque;
5   import java.util.Deque;
6   import java.util.Iterator;
7   import java.util.LinkedList;
8   
9   import javax.swing.AbstractButton;
10  
11  import org.djutils.event.Event;
12  import org.djutils.event.EventListener;
13  import org.djutils.exceptions.Throw;
14  import org.djutils.exceptions.Try;
15  
16  /**
17   * Undo unit for the OTS editor. This class stores an internal queue of actions. Changes to XsdTreeNodes should be grouped per
18   * single user input in an action. All actions need to be initiated externally using {@code startAction()}. This class will
19   * itself listen to all relevant changes in the tree and add incoming sub-actions under the started action.
20   * <p>
21   * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
22   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
23   * </p>
24   * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
25   */
26  public class Undo implements EventListener
27  {
28  
29      /** */
30      private static final long serialVersionUID = 20230921L;
31  
32      /** Maximum number of undo actions stored. */
33      private static final int MAX_UNDO = 50;
34  
35      /** Queue of actions. */
36      private LinkedList<Action> queue = new LinkedList<>();
37  
38      /** Location of most recent undo action. */
39      private int cursor = -1;
40  
41      /** Current queue of sub-actions from a single user input. */
42      private Deque<SubAction> currentSet;
43  
44      /** OTS editor. */
45      private final OtsEditor editor;
46  
47      /** Undo GUI item. */
48      private final AbstractButton undoItem;
49  
50      /** Redo GUI item. */
51      private final AbstractButton redoItem;
52  
53      /** Boolean to ignore changes during undo/redo, so no new undo/redo is made. */
54      boolean ignoreChanges = false;
55  
56      /**
57       * Constructor.
58       * @param editor editor.
59       * @param undoItem undo GUI item.
60       * @param redoItem redo GUI item.
61       */
62      public Undo(final OtsEditor editor, final AbstractButton undoItem, final AbstractButton redoItem)
63      {
64          this.editor = editor;
65          this.undoItem = undoItem;
66          this.redoItem = redoItem;
67          this.undoItem.setEnabled(false);
68          this.redoItem.setEnabled(false);
69          Try.execute(() -> editor.addListener(this, OtsEditor.NEW_FILE), "Remote exception when listening for NEW_FILE events.");
70      }
71  
72      /**
73       * Clears the entire queue, suitable for when a new tree is loaded. Also set ignore changes to false.
74       */
75      public void clear()
76      {
77          this.ignoreChanges = false;
78          this.currentSet = null;
79          this.cursor = -1;
80          this.queue = new LinkedList<>();
81      }
82  
83      /**
84       * Tells the undo unit to ignore all changes. Reset this by calling {@code clear()}. Useful during file loading.
85       * @param ignore ignore changes.
86       */
87      public void setIgnoreChanges(final boolean ignore)
88      {
89          this.ignoreChanges = ignore;
90      }
91  
92      /**
93       * Starts a new action, which groups all sub-actions until a new action is started.
94       * @param type action type.
95       * @param node node on which the action is applied, i.e. node that should be selected on undo/redo.
96       * @param attribute attribute name, may be null for actions that are not an attribute value change.
97       */
98      public void startAction(final ActionType type, final XsdTreeNode node, final String attribute)
99      {
100         if (this.ignoreChanges)
101         {
102             return;
103         }
104         if (this.currentSet != null && this.currentSet.isEmpty())
105         {
106             // last action set never resulted in any sub-action, overwrite it
107             this.queue.pollLast();
108         }
109 
110         // remove any possible redos fresher in the queue than our current pointer
111         while (this.cursor < this.queue.size() - 1)
112         {
113             this.queue.pollLast();
114         }
115 
116         // add new entry in queue
117         this.currentSet = new ArrayDeque<>();
118         this.queue.add(new Action(type, this.currentSet, node, node.parent, attribute));
119         while (this.queue.size() > MAX_UNDO)
120         {
121             this.queue.pollFirst();
122         }
123 
124         // set pointer to last non-empty element
125         this.cursor = this.queue.size() - 2;
126         updateButtons();
127     }
128 
129     /**
130      * Adds sub action to current action.
131      * @param subAction sub action.
132      */
133     private void add(final SubAction subAction)
134     {
135         if (this.ignoreChanges)
136         {
137             return;
138         }
139         Throw.when(this.currentSet == null, IllegalStateException.class,
140                 "Adding undo action without having called startUndoAction()");
141         if (this.currentSet.isEmpty())
142         {
143             this.cursor = this.queue.size() - 1; // now the latest undo actually has content
144             updateButtons();
145         }
146         this.currentSet.add(subAction);
147     }
148 
149     /**
150      * Returns whether an undo is available.
151      * @return whether an undo is available.
152      */
153     public boolean canUndo()
154     {
155         return this.cursor >= 0;
156     }
157 
158     /**
159      * Returns whether a redo is available.
160      * @return whether a redo is available.
161      */
162     public boolean canRedo()
163     {
164         return this.cursor < this.queue.size() - 1 && !this.queue.get(this.cursor + 1).subActions.isEmpty();
165     }
166 
167     /**
168      * Performs an undo.
169      */
170     public synchronized void undo()
171     {
172         if (this.ignoreChanges)
173         {
174             return;
175         }
176         this.ignoreChanges = true;
177 
178         Action action = this.queue.get(this.cursor);
179         if (action.type.equals(ActionType.ACTIVATE))
180         {
181             this.editor.collapse(action.node);
182         }
183         Iterator<SubAction> iterator = action.subActions.descendingIterator();
184         while (iterator.hasNext())
185         {
186             iterator.next().undo();
187         }
188         action.parent.children.forEach((n) -> n.invalidate());
189         action.parent.invalidate();
190         this.editor.show(action.node, action.attribute);
191         this.cursor--;
192         updateButtons();
193         this.ignoreChanges = false;
194     }
195 
196     /**
197      * Performs a redo.
198      */
199     public synchronized void redo()
200     {
201         if (this.ignoreChanges)
202         {
203             return;
204         }
205         this.ignoreChanges = true;
206         this.cursor++;
207         Action action = this.queue.get(this.cursor);
208         Iterator<SubAction> iterator = action.subActions.iterator();
209         while (iterator.hasNext())
210         {
211             iterator.next().redo();
212         }
213         action.parent.children.forEach((n) -> n.invalidate());
214         action.parent.invalidate();
215         this.editor.show(action.postActionShowNode, action.attribute);
216         updateButtons();
217         this.ignoreChanges = false;
218     }
219 
220     /**
221      * Update the enabled state and text of the undo and redo button.
222      */
223     public void updateButtons()
224     {
225         this.undoItem.setEnabled(canUndo());
226         this.undoItem.setText(canUndo() ? ("Undo " + this.queue.get(this.cursor).type) : "Undo");
227         this.redoItem.setEnabled(canRedo());
228         this.redoItem.setText(canRedo() ? ("Redo " + this.queue.get(this.cursor + 1).type) : "Redo");
229     }
230 
231     @Override
232     public void notify(final Event event) throws RemoteException
233     {
234         // listen and unlisten
235         if (event.getType().equals(OtsEditor.NEW_FILE))
236         {
237             XsdTreeNodeRoot root = (XsdTreeNodeRoot) event.getContent();
238             root.addListener(this, XsdTreeNodeRoot.NODE_CREATED);
239             root.addListener(this, XsdTreeNodeRoot.NODE_REMOVED);
240         }
241         else if (event.getType().equals(XsdTreeNodeRoot.NODE_CREATED))
242         {
243             XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[0];
244             node.addListener(this, XsdTreeNode.VALUE_CHANGED);
245             node.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
246             node.addListener(this, XsdTreeNode.OPTION_CHANGED);
247             node.addListener(this, XsdTreeNode.ACTIVATION_CHANGED);
248             node.addListener(this, XsdTreeNode.MOVED);
249         }
250         else if (event.getType().equals(XsdTreeNodeRoot.NODE_REMOVED))
251         {
252             XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[0];
253             node.removeListener(this, XsdTreeNode.VALUE_CHANGED);
254             node.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
255             node.removeListener(this, XsdTreeNode.OPTION_CHANGED);
256             node.removeListener(this, XsdTreeNode.ACTIVATION_CHANGED);
257             node.removeListener(this, XsdTreeNode.MOVED);
258         }
259 
260         // ignore any changes during an undo or redo; these should not result in another undo or redo
261         if (this.ignoreChanges)
262         {
263             return;
264         }
265 
266         // store action for each change
267         if (event.getType().equals(XsdTreeNodeRoot.NODE_CREATED))
268         {
269             Object[] content = (Object[]) event.getContent();
270             XsdTreeNode node = (XsdTreeNode) content[0];
271             XsdTreeNode parent = (XsdTreeNode) content[1];
272             int index = (int) content[2];
273             XsdTreeNode root = node.getRoot();
274             add(new SubActionRunnable(() ->
275             {
276                 parent.children.remove(node);
277                 node.parent = null;
278                 root.fireEvent(XsdTreeNodeRoot.NODE_REMOVED, new Object[] {node, parent, index});
279             }, () ->
280             {
281                 if (index >= 0)
282                 {
283                     parent.setChild(index, node);
284                 }
285                 node.parent = parent;
286                 root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, index});
287             }, "Create " + node.getPathString()));
288         }
289         else if (event.getType().equals(XsdTreeNodeRoot.NODE_REMOVED))
290         {
291             Object[] content = (Object[]) event.getContent();
292             XsdTreeNode node = (XsdTreeNode) content[0];
293             XsdTreeNode parent = (XsdTreeNode) content[1];
294             int index = (int) content[2];
295             XsdTreeNode root = parent.getRoot();
296             add(new SubActionRunnable(() ->
297             {
298                 if (index < 0)
299                 {
300                     // non selected choice node
301                     node.parent = parent;
302                     root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, parent.children.indexOf(node)});
303                 }
304                 else
305                 {
306                     parent.setChild(index, node);
307                     root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, index});
308                 }
309             }, () ->
310             {
311                 node.parent.children.remove(node);
312                 node.parent = null;
313                 root.fireEvent(XsdTreeNodeRoot.NODE_REMOVED, new Object[] {node, parent, index});
314             }, "Remove " + node.getPathString()));
315         }
316         else if (event.getType().equals(XsdTreeNode.VALUE_CHANGED))
317         {
318             Object[] content = (Object[]) event.getContent();
319             XsdTreeNode node = (XsdTreeNode) content[0];
320             String value = node.getValue();
321             add(new SubActionRunnable(() ->
322             {
323                 node.setValue((String) content[1]); // invokes event
324                 node.invalidate();
325             }, () ->
326             {
327                 node.setValue(value); // invokes event
328                 node.invalidate();
329             }, "Change " + node.getPathString() + " value: " + value));
330         }
331         else if (event.getType().equals(XsdTreeNode.ATTRIBUTE_CHANGED))
332         {
333             Object[] content = (Object[]) event.getContent();
334             XsdTreeNode node = (XsdTreeNode) content[0];
335             String attribute = (String) content[1];
336             String value = node.getAttributeValue(attribute);
337             // for include nodes, setAttributeValue will trigger addition and removal of nodes, we can ignore these events
338             if (node.xsdNode.equals(XiIncludeNode.XI_INCLUDE))
339             {
340                 this.currentSet.clear();
341             }
342             add(new SubActionRunnable(() ->
343             {
344                 node.setAttributeValue(attribute, (String) content[2]); // invokes event
345                 node.invalidate();
346             }, () ->
347             {
348                 node.setAttributeValue(attribute, value); // invokes event
349                 node.invalidate();
350             }, "Create " + node.getPathString() + ".@" + attribute + ": " + value));
351         }
352         else if (event.getType().equals(XsdTreeNode.ACTIVATION_CHANGED))
353         {
354             Object[] content = (Object[]) event.getContent();
355             XsdTreeNode node = (XsdTreeNode) content[0];
356             boolean activated = (boolean) content[1];
357             add(new SubActionRunnable(() ->
358             {
359                 node.active = !activated;
360                 node.fireEvent(XsdTreeNode.ACTIVATION_CHANGED, new Object[] {node, !activated});
361             }, () ->
362             {
363                 node.active = activated;
364                 node.fireEvent(XsdTreeNode.ACTIVATION_CHANGED, new Object[] {node, activated});
365             }, "Activation " + node.getPathString() + " " + activated));
366         }
367         else if (event.getType().equals(XsdTreeNode.OPTION_CHANGED))
368         {
369             Object[] content = (Object[]) event.getContent();
370             XsdTreeNode node = (XsdTreeNode) content[1];
371             XsdTreeNode previous = (XsdTreeNode) content[2];
372             if (previous != null)
373             {
374                 add(new SubActionRunnable(() ->
375                 {
376                     node.setOption(previous); // invokes event
377                 }, () ->
378                 {
379                     previous.setOption(node); // invokes event
380                 }, "Set option " + node.getPathString()));
381             }
382         }
383         else if (event.getType().equals(XsdTreeNode.MOVED))
384         {
385             Object[] content = (Object[]) event.getContent();
386             XsdTreeNode node = (XsdTreeNode) content[0];
387             int oldIndex = (int) content[1];
388             int newIndex = (int) content[2];
389             add(new SubActionRunnable(() ->
390             {
391                 node.parent.children.remove(node);
392                 node.parent.children.add(oldIndex, node);
393                 node.fireEvent(XsdTreeNode.MOVED, new Object[] {node, newIndex, oldIndex});
394             }, () ->
395             {
396                 node.parent.children.remove(node);
397                 node.parent.children.add(newIndex, node);
398                 node.fireEvent(XsdTreeNode.MOVED, new Object[] {node, oldIndex, newIndex});
399             }, "Move " + node.getPathString()));
400 
401         }
402     }
403 
404     /**
405      * Sets the node to show in the tree after the action.
406      * @param node node to show in the tree after the action.
407      */
408     public void setPostActionShowNode(final XsdTreeNode node)
409     {
410         this.queue.get(this.cursor).postActionShowNode = node;
411     }
412 
413     /**
414      * Interface for any sub-action reflecting any change.
415      * <p>
416      * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
417      * <br>
418      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
419      * </p>
420      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
421      */
422     private interface SubAction
423     {
424         /**
425          * Undo the change.
426          */
427         void undo();
428 
429         /**
430          * Redo the change.
431          */
432         void redo();
433     }
434 
435     /**
436      * Implements {@code SubAction} using two {@code Runnable}'s, definable as an lambda expression.
437      * <p>
438      * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
439      * <br>
440      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
441      * </p>
442      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
443      */
444     private static class SubActionRunnable implements SubAction
445     {
446         /** Undo runnable. */
447         private Runnable undo;
448 
449         /** Redo runnable. */
450         private Runnable redo;
451 
452         /** String representation of this sub action. */
453         private String string;
454 
455         /**
456          * Constructor.
457          * @param undo undo runnable.
458          * @param redo redo runnable.
459          * @param string string representation of this sub action.
460          */
461         public SubActionRunnable(final Runnable undo, final Runnable redo, final String string)
462         {
463             this.undo = undo;
464             this.redo = redo;
465             this.string = string;
466         }
467 
468         @Override
469         public void undo()
470         {
471             this.undo.run();
472         }
473 
474         @Override
475         public void redo()
476         {
477             this.redo.run();
478         }
479 
480         @Override
481         public String toString()
482         {
483             return this.string;
484         }
485     }
486 
487     /**
488      * Class that groups information around an action.
489      * <p>
490      * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
491      * <br>
492      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
493      * </p>
494      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
495      */
496     private class Action
497     {
498         /** Name of the action, as presented with the undo/redo buttons. */
499         final ActionType type;
500 
501         /** Queue of sub actions. */
502         final Deque<SubAction> subActions;
503 
504         /** Node involved in the action. */
505         final XsdTreeNode node;
506 
507         /** Parent node of the node involved in the action. */
508         final XsdTreeNode parent;
509 
510         /** Attribute for an attribute change, {@code null} otherwise. */
511         final String attribute;
512 
513         /** Node to gain focus after the action. */
514         XsdTreeNode postActionShowNode;
515 
516         /**
517          * Constructor.
518          * @param type type of the action, as presented with the undo/redo buttons.
519          * @param subActions queue of sub actions.
520          * @param node node involved in the action.
521          * @param parent parent node of the node involved in the action.
522          * @param attribute attribute for an attribute change, {@code null} otherwise.
523          */
524         public Action(final ActionType type, final Deque<SubAction> subActions, final XsdTreeNode node,
525                 final XsdTreeNode parent, final String attribute)
526         {
527             this.type = type;
528             this.subActions = subActions;
529             this.node = node;
530             this.parent = parent;
531             this.attribute = attribute;
532             this.postActionShowNode = node;
533         }
534     }
535 
536     /**
537      * Type of actions for undo.
538      * <p>
539      * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
540      * <br>
541      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
542      * </p>
543      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
544      */
545     public enum ActionType
546     {
547         /** Node activated. */
548         ACTIVATE,
549 
550         /** Node added. */
551         ADD,
552 
553         /** Attribute changed. */
554         ATTRIBUTE_CHANGE,
555 
556         /** Cut. */
557         CUT,
558 
559         /** Node duplicated. */
560         DUPLICATE,
561 
562         /** Id changed. */
563         ID_CHANGE,
564 
565         /** INSERT. */
566         INSERT,
567 
568         /** Node moved. */
569         MOVE,
570 
571         /** Option set. */
572         OPTION,
573 
574         /** Paste. */
575         PASTE,
576 
577         /** Node removed. */
578         REMOVE,
579 
580         /** Node value changed. */
581         VALUE_CHANGE,
582 
583         /** Action on node, by custom decoration. */
584         ACTION;
585 
586         @Override
587         public String toString()
588         {
589             return name().toLowerCase().replace("_", " ");
590         }
591     }
592 
593 }