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