View Javadoc
1   package org.opentrafficsim.editor;
2   
3   import java.util.ArrayDeque;
4   import java.util.Deque;
5   import java.util.Iterator;
6   import java.util.LinkedList;
7   import java.util.function.Consumer;
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.opentrafficsim.editor.decoration.validation.CoupledValidator;
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      /** Maximum number of undo actions stored. */
30      private static final int MAX_UNDO = 50;
31  
32      /** Queue of actions. */
33      private LinkedList<Action> queue = new LinkedList<>();
34  
35      /** Location of most recent undo action. */
36      private int cursor = -1;
37  
38      /** Current queue of sub-actions from a single user input. */
39      private Deque<SubAction> currentSet;
40  
41      /** OTS editor. */
42      private final OtsEditor editor;
43  
44      /** Undo GUI item. */
45      private final AbstractButton undoItem;
46  
47      /** Redo GUI item. */
48      private final AbstractButton redoItem;
49  
50      /** Boolean to ignore changes during undo/redo, so no new undo/redo is made. */
51      private boolean ignoreChanges = false;
52  
53      /** Allocated next action to make concrete on first actual change. */
54      private Action nextAction;
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          editor.addListener(this, OtsEditor.NEW_FILE);
70      }
71  
72      /**
73       * Clears the entire queue, suitable for when a new tree is loaded. Also sets 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          this.undoItem.setEnabled(false);
82          this.redoItem.setEnabled(false);
83      }
84  
85      /**
86       * Tells the undo unit to ignore all changes. Reset this by calling {@code clear()}. Useful during file loading.
87       * @param ignore ignore changes.
88       */
89      public void setIgnoreChanges(final boolean ignore)
90      {
91          this.ignoreChanges = ignore;
92      }
93  
94      /**
95       * Starts a new action, which groups all sub-actions until a new action is started. This method can be called without being
96       * sure concrete changes will be made. Internal listeners listen to all changes and will combine them in to one undo action,
97       * up to the point the next action is started with this method. If no actual changes were made in between, the former start
98       * of an action does not result in anything the user can undo or redo. When the user has stepped back a few undo actions,
99       * and then makes a new change, rolled back undo steps can no longer be redone. Clearing rolled back undo steps is performed
100      * lazily on the first concrete change by a sub-action. Starting a new action does not clear rolled back undo steps by
101      * itself.
102      * @param type action type.
103      * @param node node on which the action is applied, i.e. node that should be selected on undo/redo.
104      * @param attribute attribute name, may be {@code null} for actions that are not an attribute value change.
105      */
106     public void startAction(final ActionType type, final XsdTreeNode node, final String attribute)
107     {
108         if (this.ignoreChanges)
109         {
110             return;
111         }
112         // allocate a next action with the right type, nodes and attribute, but with an empty set of sub-actions for now
113         // this does not yet represent an actual undoable action until any sub-action is added to it
114         this.nextAction = new Action(type, new ArrayDeque<>(), node, node.parent, attribute);
115     }
116 
117     /**
118      * Adds sub-action to current action.
119      * @param subAction sub-action.
120      */
121     private void add(final SubAction subAction)
122     {
123         if (this.ignoreChanges)
124         {
125             return;
126         }
127         // make allocated next action a concrete next action in the queue
128         if (this.nextAction != null)
129         {
130             // remove any possible redos fresher in the queue than our current pointer (i.e. rolled back undo steps)
131             while (this.cursor < this.queue.size() - 1)
132             {
133                 this.queue.pollLast();
134             }
135             this.currentSet = this.nextAction.subActions;
136             this.queue.add(this.nextAction);
137             while (this.queue.size() > MAX_UNDO)
138             {
139                 this.queue.pollFirst();
140             }
141             this.nextAction = null;
142             this.cursor = this.queue.size() - 1;
143             updateButtons();
144         }
145         Throw.when(this.currentSet == null, IllegalStateException.class,
146                 "Adding undo action without having called startUndoAction()");
147         this.currentSet.add(subAction);
148     }
149 
150     /**
151      * Returns whether an undo is available.
152      * @return whether an undo is available.
153      */
154     public boolean canUndo()
155     {
156         return this.cursor >= 0;
157     }
158 
159     /**
160      * Returns whether a redo is available.
161      * @return whether a redo is available.
162      */
163     public boolean canRedo()
164     {
165         return this.cursor < this.queue.size() - 1;
166     }
167 
168     /**
169      * Performs an undo.
170      */
171     public synchronized void undo()
172     {
173         if (this.ignoreChanges)
174         {
175             return;
176         }
177         this.ignoreChanges = true;
178 
179         Action action = this.queue.get(this.cursor);
180         if (action.type.equals(ActionType.ACTIVATE))
181         {
182             this.editor.collapse(action.node);
183         }
184         // In case of Java 21: action.subActions.reversed().forEach((a) -> a.undo());
185         Iterator<SubAction> iterator = action.subActions.descendingIterator();
186         while (iterator.hasNext())
187         {
188             iterator.next().undo();
189         }
190         action.parent.children.forEach((n) -> n.invalidate());
191         action.parent.invalidate();
192         this.editor.show(action.node, action.attribute);
193         this.cursor--;
194         updateButtons();
195         this.ignoreChanges = false;
196     }
197 
198     /**
199      * Performs a redo.
200      */
201     public synchronized void redo()
202     {
203         if (this.ignoreChanges)
204         {
205             return;
206         }
207         this.ignoreChanges = true;
208         this.cursor++;
209         Action action = this.queue.get(this.cursor);
210         action.subActions.forEach((a) -> a.redo());
211         action.parent.children.forEach((n) -> n.invalidate());
212         action.parent.invalidate();
213         this.editor.show(action.postActionShowNode, action.attribute);
214         updateButtons();
215         this.ignoreChanges = false;
216     }
217 
218     /**
219      * Update the enabled state and text of the undo and redo button.
220      */
221     public void updateButtons()
222     {
223         this.undoItem.setEnabled(canUndo());
224         this.undoItem.setText(canUndo() ? ("Undo " + this.queue.get(this.cursor).type) : "Undo");
225         this.redoItem.setEnabled(canRedo());
226         this.redoItem.setText(canRedo() ? ("Redo " + this.queue.get(this.cursor + 1).type) : "Redo");
227     }
228 
229     @Override
230     @SuppressWarnings("methodlength")
231     public void notify(final Event event)
232     {
233         listenAndUnlisten(event);
234 
235         // ignore any changes during an undo or redo; these should not result in another undo or redo
236         if (this.ignoreChanges)
237         {
238             return;
239         }
240 
241         // store action for each change
242         if (event.getType().equals(XsdTreeNodeRoot.NODE_CREATED))
243         {
244             Object[] content = (Object[]) event.getContent();
245             XsdTreeNode node = (XsdTreeNode) content[0];
246             XsdTreeNode parent = (XsdTreeNode) content[1];
247             int index = (int) content[2];
248             XsdTreeNode root = node.getRoot();
249             add(new SubAction(() ->
250             {
251                 parent.children.remove(node);
252                 node.parent = null;
253                 root.fireEvent(XsdTreeNodeRoot.NODE_REMOVED, new Object[] {node, parent, index});
254             }, () ->
255             {
256                 if (index >= 0)
257                 {
258                     parent.setChild(index, node);
259                 }
260                 node.parent = parent;
261                 root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, index});
262             }, "Create " + node.getPathString()));
263         }
264         else if (event.getType().equals(XsdTreeNodeRoot.NODE_REMOVED))
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 = parent.getRoot();
271             add(new SubAction(() ->
272             {
273                 if (index < 0)
274                 {
275                     // non selected choice node
276                     node.parent = parent;
277                     root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, parent.children.indexOf(node)});
278                 }
279                 else
280                 {
281                     parent.setChild(index, node);
282                     root.fireEvent(XsdTreeNodeRoot.NODE_CREATED, new Object[] {node, parent, index});
283                 }
284             }, () ->
285             {
286                 node.parent.children.remove(node);
287                 node.parent = null;
288                 root.fireEvent(XsdTreeNodeRoot.NODE_REMOVED, new Object[] {node, parent, index});
289             }, "Remove " + node.getPathString()));
290         }
291         else if (event.getType().equals(XsdTreeNode.VALUE_CHANGED))
292         {
293             Object[] content = (Object[]) event.getContent();
294             XsdTreeNode node = (XsdTreeNode) content[0];
295             String value = node.getValue();
296             add(new SubAction(() ->
297             {
298                 node.setValue((String) content[1]); // invokes event
299             }, () ->
300             {
301                 node.setValue(value); // invokes event
302             }, "Change " + node.getPathString() + " value: " + value));
303         }
304         else if (event.getType().equals(XsdTreeNode.ATTRIBUTE_CHANGED))
305         {
306             Object[] content = (Object[]) event.getContent();
307             XsdTreeNode node = (XsdTreeNode) content[0];
308             String attribute = (String) content[1];
309             String prevValue = (String) content[2];
310             String value = node.getAttributeValue(attribute);
311             // for include nodes, setAttributeValue will trigger addition and removal of nodes, we can ignore these events
312             if (node.xsdNode.equals(XiIncludeNode.XI_INCLUDE))
313             {
314                 this.currentSet.clear();
315             }
316             add(new SubAction(() ->
317             {
318                 node.setAttributeValue(attribute, prevValue); // invokes event
319             }, () ->
320             {
321                 node.setAttributeValue(attribute, value); // invokes event
322             }, "Create " + node.getPathString() + ".@" + attribute + ": " + value));
323         }
324         else if (event.getType().equals(XsdTreeNode.ACTIVATION_CHANGED))
325         {
326             Object[] content = (Object[]) event.getContent();
327             XsdTreeNode node = (XsdTreeNode) content[0];
328             boolean activated = (boolean) content[1];
329             add(new SubAction(() ->
330             {
331                 node.active = !activated;
332                 node.fireEvent(XsdTreeNode.ACTIVATION_CHANGED, new Object[] {node, !activated});
333             }, () ->
334             {
335                 node.active = activated;
336                 node.fireEvent(XsdTreeNode.ACTIVATION_CHANGED, new Object[] {node, activated});
337             }, "Activation " + node.getPathString() + " " + activated));
338         }
339         else if (event.getType().equals(XsdTreeNode.OPTION_CHANGED))
340         {
341             Object[] content = (Object[]) event.getContent();
342             XsdTreeNode node = (XsdTreeNode) content[1];
343             XsdTreeNode previous = (XsdTreeNode) content[2];
344             if (previous != null)
345             {
346                 add(new SubAction(() ->
347                 {
348                     node.setOption(previous); // invokes event
349                 }, () ->
350                 {
351                     previous.setOption(node); // invokes event
352                 }, "Set option " + node.getPathString()));
353             }
354         }
355         else if (event.getType().equals(XsdTreeNode.MOVED))
356         {
357             Object[] content = (Object[]) event.getContent();
358             XsdTreeNode node = (XsdTreeNode) content[0];
359             int oldIndex = (int) content[1];
360             int newIndex = (int) content[2];
361             add(new SubAction(() ->
362             {
363                 node.parent.children.remove(node);
364                 node.parent.children.add(oldIndex, node);
365                 node.fireEvent(XsdTreeNode.MOVED, new Object[] {node, newIndex, oldIndex});
366             }, () ->
367             {
368                 node.parent.children.remove(node);
369                 node.parent.children.add(newIndex, node);
370                 node.fireEvent(XsdTreeNode.MOVED, new Object[] {node, oldIndex, newIndex});
371             }, "Move " + node.getPathString()));
372         }
373         else if (event.getType().equals(CoupledValidator.COUPLING))
374         {
375             if (this.currentSet == null)
376             {
377                 return; // We can ignore couplings created by node expansion after loading a file
378             }
379             Object[] content = (Object[]) event.getContent();
380             CoupledValidator validator = (CoupledValidator) content[0];
381             XsdTreeNode fromNode = (XsdTreeNode) content[1];
382             XsdTreeNode toNode = (XsdTreeNode) content[2];
383             XsdTreeNode prevToNode = (XsdTreeNode) content[3];
384             Consumer<XsdTreeNode> consumer = (node) -> // this works either way, towards prevToNode (undo) or toNode (redo)
385             {
386                 if (node == null)
387                 {
388                     validator.removeCoupling(fromNode);
389                 }
390                 else
391                 {
392                     validator.addCoupling(fromNode, node);
393                 }
394                 fromNode.invalidate();
395             };
396             add(new SubAction(() -> consumer.accept(prevToNode), () -> consumer.accept(toNode),
397                     "Coupling " + fromNode.getNodeName()));
398         }
399     }
400 
401     /**
402      * Listen and un-listen to all possible changes.
403      * @param event event
404      */
405     private void listenAndUnlisten(final Event event)
406     {
407         if (event.getType().equals(OtsEditor.NEW_FILE))
408         {
409             XsdTreeNodeRoot root = (XsdTreeNodeRoot) event.getContent();
410             root.addListener(this, XsdTreeNodeRoot.NODE_CREATED);
411             root.addListener(this, XsdTreeNodeRoot.NODE_REMOVED);
412             root.addListener(this, CoupledValidator.COUPLING);
413         }
414         else if (event.getType().equals(XsdTreeNodeRoot.NODE_CREATED))
415         {
416             XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[0];
417             node.addListener(this, XsdTreeNode.VALUE_CHANGED);
418             node.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
419             node.addListener(this, XsdTreeNode.OPTION_CHANGED);
420             node.addListener(this, XsdTreeNode.ACTIVATION_CHANGED);
421             node.addListener(this, XsdTreeNode.MOVED);
422         }
423         else if (event.getType().equals(XsdTreeNodeRoot.NODE_REMOVED))
424         {
425             XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[0];
426             node.removeListener(this, XsdTreeNode.VALUE_CHANGED);
427             node.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
428             node.removeListener(this, XsdTreeNode.OPTION_CHANGED);
429             node.removeListener(this, XsdTreeNode.ACTIVATION_CHANGED);
430             node.removeListener(this, XsdTreeNode.MOVED);
431         }
432     }
433 
434     /**
435      * Sets the node to show in the tree after the action. This is for example useful to set the selection on the duplicate of a
436      * duplicated node when redoing the duplication. Note that the node of the action that is otherwise shown would be the
437      * duplicated node, rather than the duplicate.
438      * @param node node to show in the tree after the action.
439      */
440     public void setPostActionShowNode(final XsdTreeNode node)
441     {
442         this.queue.get(this.cursor).postActionShowNode = node;
443     }
444 
445     /**
446      * Class that groups information around an action.
447      */
448     private class Action
449     {
450         // can't be a record due to mutable postActionShowNode
451 
452         /** Name of the action, as presented with the undo/redo buttons. */
453         @SuppressWarnings("checkstyle:visibilitymodifier")
454         final ActionType type;
455 
456         /** Queue of sub-actions. */
457         @SuppressWarnings("checkstyle:visibilitymodifier")
458         final Deque<SubAction> subActions;
459 
460         /** Node involved in the action. */
461         @SuppressWarnings("checkstyle:visibilitymodifier")
462         final XsdTreeNode node;
463 
464         /** Parent node of the node involved in the action. */
465         @SuppressWarnings("checkstyle:visibilitymodifier")
466         final XsdTreeNode parent;
467 
468         /** Attribute for an attribute change, {@code null} otherwise. */
469         @SuppressWarnings("checkstyle:visibilitymodifier")
470         final String attribute;
471 
472         /** Node to gain focus after the action. */
473         @SuppressWarnings("checkstyle:visibilitymodifier")
474         XsdTreeNode postActionShowNode;
475 
476         /**
477          * Constructor.
478          * @param type type of the action, as presented with the undo/redo buttons.
479          * @param subActions queue of sub-actions.
480          * @param node node involved in the action.
481          * @param parent parent node of the node involved in the action.
482          * @param attribute attribute for an attribute change, {@code null} otherwise.
483          */
484         Action(final ActionType type, final Deque<SubAction> subActions, final XsdTreeNode node, final XsdTreeNode parent,
485                 final String attribute)
486         {
487             this.type = type;
488             this.subActions = subActions;
489             this.node = node;
490             this.parent = parent;
491             this.attribute = attribute;
492             this.postActionShowNode = node;
493         }
494     }
495 
496     /**
497      * Type of actions for undo.
498      */
499     public enum ActionType
500     {
501         /** Node activated. */
502         ACTIVATE,
503 
504         /** Node added. */
505         ADD,
506 
507         /** Attribute changed. */
508         ATTRIBUTE_CHANGE,
509 
510         /** Cut. */
511         CUT,
512 
513         /** Node duplicated. */
514         DUPLICATE,
515 
516         /** Id changed. */
517         ID_CHANGE,
518 
519         /** INSERT. */
520         INSERT,
521 
522         /** Node moved. */
523         MOVE,
524 
525         /** Option set. */
526         OPTION,
527 
528         /** Paste. */
529         PASTE,
530 
531         /** Node removed. */
532         REMOVE,
533 
534         /** Node value changed. */
535         VALUE_CHANGE,
536 
537         /** Action on node, by custom decoration. */
538         ACTION;
539 
540         @Override
541         public String toString()
542         {
543             return name().toLowerCase().replace("_", " ");
544         }
545     }
546 
547     /**
548      * Sub-action defined by using two {@code Runnable}'s, definable as an lambda expression.
549      */
550     private static class SubAction
551     {
552         /** Undo runnable. */
553         private Runnable undo;
554 
555         /** Redo runnable. */
556         private Runnable redo;
557 
558         /** String representation of this sub-action. */
559         private String string;
560 
561         /**
562          * Constructor.
563          * @param undo undo runnable.
564          * @param redo redo runnable.
565          * @param string string representation of this sub-action.
566          */
567         SubAction(final Runnable undo, final Runnable redo, final String string)
568         {
569             this.undo = undo;
570             this.redo = redo;
571             this.string = string;
572         }
573 
574         /**
575          * Undo the sub-action.
576          */
577         public void undo()
578         {
579             this.undo.run();
580         }
581 
582         /**
583          * Redo the sub-action.
584          */
585         public void redo()
586         {
587             this.redo.run();
588         }
589 
590         @Override
591         public String toString()
592         {
593             return this.string;
594         }
595     }
596 
597 }