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 OtsEditor; editor.
59       * @param undoItem AbstractButton; undo GUI item.
60       * @param redoItem AbstractButton; 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 boolean; 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 ActionType; action type.
95       * @param node XsdTreeNode; node on which the action is applied, i.e. node that should be selected on undo/redo.
96       * @param attribute String; 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 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 boolean; 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 boolean; 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         Iterator<SubAction> iterator = action.subActions.descendingIterator();
180         while (iterator.hasNext())
181         {
182             iterator.next().undo();
183         }
184         action.parent.children.forEach((n) -> n.invalidate());
185         action.parent.invalidate();
186         this.editor.show(action.node, action.attribute);
187         this.cursor--;
188         updateButtons();
189         this.ignoreChanges = false;
190     }
191 
192     /**
193      * Performs a redo.
194      */
195     public synchronized void redo()
196     {
197         if (this.ignoreChanges)
198         {
199             return;
200         }
201         this.ignoreChanges = true;
202         this.cursor++;
203         Action action = this.queue.get(this.cursor);
204         Iterator<SubAction> iterator = action.subActions.iterator();
205         while (iterator.hasNext())
206         {
207             iterator.next().redo();
208         }
209         action.parent.children.forEach((n) -> n.invalidate());
210         action.parent.invalidate();
211         this.editor.show(action.postActionShowNode, action.attribute);
212         updateButtons();
213         this.ignoreChanges = false;
214     }
215 
216     /**
217      * Update the enabled state and text of the undo and redo button.
218      */
219     public void updateButtons()
220     {
221         this.undoItem.setEnabled(canUndo());
222         this.undoItem.setText(canUndo() ? ("Undo " + this.queue.get(this.cursor).type) : "Undo");
223         this.redoItem.setEnabled(canRedo());
224         this.redoItem.setText(canRedo() ? ("Redo " + this.queue.get(this.cursor + 1).type) : "Redo");
225     }
226 
227     /** {@inheritDoc} */
228     @Override
229     public void notify(final Event event) throws RemoteException
230     {
231         // listen and unlisten
232         if (event.getType().equals(OtsEditor.NEW_FILE))
233         {
234             XsdTreeNodeRoot root = (XsdTreeNodeRoot) event.getContent();
235             root.addListener(this, XsdTreeNodeRoot.NODE_CREATED);
236             root.addListener(this, XsdTreeNodeRoot.NODE_REMOVED);
237         }
238         else if (event.getType().equals(XsdTreeNodeRoot.NODE_CREATED))
239         {
240             XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[0];
241             node.addListener(this, XsdTreeNode.VALUE_CHANGED);
242             node.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
243             node.addListener(this, XsdTreeNode.OPTION_CHANGED);
244             node.addListener(this, XsdTreeNode.ACTIVATION_CHANGED);
245             node.addListener(this, XsdTreeNode.MOVED);
246         }
247         else if (event.getType().equals(XsdTreeNodeRoot.NODE_REMOVED))
248         {
249             XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[0];
250             node.removeListener(this, XsdTreeNode.VALUE_CHANGED);
251             node.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
252             node.removeListener(this, XsdTreeNode.OPTION_CHANGED);
253             node.removeListener(this, XsdTreeNode.ACTIVATION_CHANGED);
254             node.removeListener(this, XsdTreeNode.MOVED);
255         }
256 
257         // ignore any changes during an undo or redo; these should not result in another undo or redo
258         if (this.ignoreChanges)
259         {
260             return;
261         }
262 
263         // store action for each change
264         if (event.getType().equals(XsdTreeNodeRoot.NODE_CREATED))
265         {
266             Object[] content = (Object[]) event.getContent();
267             XsdTreeNode node = (XsdTreeNode) content[0];
268             XsdTreeNode parent = (XsdTreeNode) content[1];
269             int index = (int) content[2];
270             XsdTreeNode root = node.getRoot();
271             add(new SubActionRunnable(() ->
272             {
273                 parent.children.remove(node);
274                 node.parent = null;
275                 root.fireEvent(XsdTreeNodeRoot.NODE_REMOVED, new Object[] {node, parent, index});
276             }, () ->
277             {
278                 if (index >= 0)
279                 {
280                     parent.setChild(index, node);
281                 }
282                 node.parent = parent;
283                 root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, index});
284             }, "Create " + node.getPathString()));
285         }
286         else if (event.getType().equals(XsdTreeNodeRoot.NODE_REMOVED))
287         {
288             Object[] content = (Object[]) event.getContent();
289             XsdTreeNode node = (XsdTreeNode) content[0];
290             XsdTreeNode parent = (XsdTreeNode) content[1];
291             int index = (int) content[2];
292             XsdTreeNode root = parent.getRoot();
293             add(new SubActionRunnable(() ->
294             {
295                 if (index < 0)
296                 {
297                     // non selected choice node
298                     node.parent = parent;
299                     root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, parent.children.indexOf(node)});
300                 }
301                 else
302                 {
303                     parent.setChild(index, node);
304                     root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, index});
305                 }
306             }, () ->
307             {
308                 node.parent.children.remove(node);
309                 node.parent = null;
310                 root.fireEvent(XsdTreeNodeRoot.NODE_REMOVED, new Object[] {node, parent, index});
311             }, "Remove " + node.getPathString()));
312         }
313         else if (event.getType().equals(XsdTreeNode.VALUE_CHANGED))
314         {
315             Object[] content = (Object[]) event.getContent();
316             XsdTreeNode node = (XsdTreeNode) content[0];
317             String value = node.getValue();
318             add(new SubActionRunnable(() ->
319             {
320                 node.setValue((String) content[1]); // invokes event
321                 node.invalidate();
322             }, () ->
323             {
324                 node.setValue(value); // invokes event
325                 node.invalidate();
326             }, "Change " + node.getPathString() + " value: " + value));
327         }
328         else if (event.getType().equals(XsdTreeNode.ATTRIBUTE_CHANGED))
329         {
330             Object[] content = (Object[]) event.getContent();
331             XsdTreeNode node = (XsdTreeNode) content[0];
332             String attribute = (String) content[1];
333             String value = node.getAttributeValue(attribute);
334             // for include nodes, setAttributeValue will trigger addition and removal of nodes, we can ignore these events
335             if (node.xsdNode.equals(XiIncludeNode.XI_INCLUDE))
336             {
337                 this.currentSet.clear();
338             }
339             add(new SubActionRunnable(() ->
340             {
341                 node.setAttributeValue(attribute, (String) content[2]); // invokes event
342                 node.invalidate();
343             }, () ->
344             {
345                 node.setAttributeValue(attribute, value); // invokes event
346                 node.invalidate();
347             }, "Create " + node.getPathString() + ".@" + attribute + ": " + value));
348         }
349         else if (event.getType().equals(XsdTreeNode.ACTIVATION_CHANGED))
350         {
351             Object[] content = (Object[]) event.getContent();
352             XsdTreeNode node = (XsdTreeNode) content[0];
353             boolean activated = (boolean) content[1];
354             add(new SubActionRunnable(() ->
355             {
356                 node.active = !activated;
357                 node.fireEvent(XsdTreeNode.ACTIVATION_CHANGED, new Object[] {node, !activated});
358             }, () ->
359             {
360                 node.active = activated;
361                 node.fireEvent(XsdTreeNode.ACTIVATION_CHANGED, new Object[] {node, activated});
362             }, "Activation " + node.getPathString() + " " + activated));
363         }
364         else if (event.getType().equals(XsdTreeNode.OPTION_CHANGED))
365         {
366             Object[] content = (Object[]) event.getContent();
367             XsdTreeNode node = (XsdTreeNode) content[1];
368             XsdTreeNode previous = (XsdTreeNode) content[2];
369             if (previous != null)
370             {
371                 add(new SubActionRunnable(() ->
372                 {
373                     node.setOption(previous); // invokes event
374                 }, () ->
375                 {
376                     previous.setOption(node); // invokes event
377                 }, "Set option " + node.getPathString()));
378             }
379         }
380         else if (event.getType().equals(XsdTreeNode.MOVED))
381         {
382             Object[] content = (Object[]) event.getContent();
383             XsdTreeNode node = (XsdTreeNode) content[0];
384             int oldIndex = (int) content[1];
385             int newIndex = (int) content[2];
386             add(new SubActionRunnable(() ->
387             {
388                 node.parent.children.remove(node);
389                 node.parent.children.add(oldIndex, node);
390                 node.fireEvent(XsdTreeNode.MOVED, new Object[] {node, newIndex, oldIndex});
391             }, () ->
392             {
393                 node.parent.children.remove(node);
394                 node.parent.children.add(newIndex, node);
395                 node.fireEvent(XsdTreeNode.MOVED, new Object[] {node, oldIndex, newIndex});
396             }, "Move " + node.getPathString()));
397 
398         }
399     }
400 
401     /**
402      * Sets the node to show in the tree after the action.
403      * @param node XsdTreeNode; node to show in the tree after the action.
404      */
405     public void setPostActionShowNode(final XsdTreeNode node)
406     {
407         this.queue.get(this.cursor).postActionShowNode = node;
408     }
409 
410     /**
411      * Interface for any sub-action reflecting any change.
412      * <p>
413      * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
414      * <br>
415      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
416      * </p>
417      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
418      */
419     private interface SubAction
420     {
421         /**
422          * Undo the change.
423          */
424         void undo();
425 
426         /**
427          * Redo the change.
428          */
429         void redo();
430     }
431 
432     /**
433      * Implements {@code SubAction} using two {@code Runnable}'s, definable as an lambda expression.
434      * <p>
435      * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
436      * <br>
437      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
438      * </p>
439      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
440      */
441     private static class SubActionRunnable implements SubAction
442     {
443         /** Undo runnable. */
444         private Runnable undo;
445 
446         /** Redo runnable. */
447         private Runnable redo;
448 
449         /** String representation of this sub action. */
450         private String string;
451 
452         /**
453          * Constructor.
454          * @param undo Runnable; undo runnable.
455          * @param redo Runnable; redo runnable.
456          * @param string String; string representation of this sub action.
457          */
458         public SubActionRunnable(final Runnable undo, final Runnable redo, final String string)
459         {
460             this.undo = undo;
461             this.redo = redo;
462             this.string = string;
463         }
464 
465         /** {@inheritDoc} */
466         @Override
467         public void undo()
468         {
469             this.undo.run();
470         }
471 
472         /** {@inheritDoc} */
473         @Override
474         public void redo()
475         {
476             this.redo.run();
477         }
478 
479         /** {@inheritDoc} */
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 ActionType; type of the action, as presented with the undo/redo buttons.
519          * @param subActions Deque&lt;SubAction&gt;; queue of sub actions.
520          * @param node XsdTreeNode; node involved in the action.
521          * @param parent XsdTreeNode; parent node of the node involved in the action.
522          * @param attribute String; 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         /** {@inheritDoc} */
587         @Override
588         public String toString()
589         {
590             return name().toLowerCase().replace("_", " ");
591         }
592     }
593 
594 }