View Javadoc
1   package org.opentrafficsim.editor;
2   
3   import java.awt.BorderLayout;
4   import java.awt.Color;
5   import java.awt.Component;
6   import java.awt.Dimension;
7   import java.awt.FileDialog;
8   import java.awt.Point;
9   import java.awt.Rectangle;
10  import java.awt.event.ActionEvent;
11  import java.awt.event.KeyAdapter;
12  import java.awt.event.KeyEvent;
13  import java.awt.event.MouseAdapter;
14  import java.awt.event.MouseEvent;
15  import java.awt.event.MouseWheelEvent;
16  import java.awt.event.MouseWheelListener;
17  import java.awt.event.WindowAdapter;
18  import java.awt.event.WindowEvent;
19  import java.io.File;
20  import java.io.FileOutputStream;
21  import java.io.IOException;
22  import java.nio.charset.StandardCharsets;
23  import java.nio.file.Files;
24  import java.nio.file.Path;
25  import java.nio.file.Paths;
26  import java.util.ArrayList;
27  import java.util.Date;
28  import java.util.Iterator;
29  import java.util.LinkedHashMap;
30  import java.util.LinkedHashSet;
31  import java.util.LinkedList;
32  import java.util.List;
33  import java.util.Map;
34  import java.util.Map.Entry;
35  import java.util.Optional;
36  import java.util.Set;
37  import java.util.Timer;
38  import java.util.TimerTask;
39  import java.util.function.Consumer;
40  import java.util.stream.Collectors;
41  
42  import javax.imageio.ImageIO;
43  import javax.swing.Box;
44  import javax.swing.BoxLayout;
45  import javax.swing.CellEditor;
46  import javax.swing.DefaultCellEditor;
47  import javax.swing.Icon;
48  import javax.swing.ImageIcon;
49  import javax.swing.JButton;
50  import javax.swing.JComboBox;
51  import javax.swing.JComponent;
52  import javax.swing.JLabel;
53  import javax.swing.JMenu;
54  import javax.swing.JMenuBar;
55  import javax.swing.JMenuItem;
56  import javax.swing.JOptionPane;
57  import javax.swing.JPanel;
58  import javax.swing.JPopupMenu;
59  import javax.swing.JScrollPane;
60  import javax.swing.JSeparator;
61  import javax.swing.JSplitPane;
62  import javax.swing.JTabbedPane;
63  import javax.swing.JTable;
64  import javax.swing.JTextField;
65  import javax.swing.KeyStroke;
66  import javax.swing.ListSelectionModel;
67  import javax.swing.SwingConstants;
68  import javax.swing.SwingUtilities;
69  import javax.swing.UIManager;
70  import javax.swing.border.BevelBorder;
71  import javax.swing.border.LineBorder;
72  import javax.swing.event.CellEditorListener;
73  import javax.swing.event.PopupMenuEvent;
74  import javax.swing.event.PopupMenuListener;
75  import javax.swing.plaf.basic.BasicSplitPaneUI;
76  import javax.swing.table.DefaultTableColumnModel;
77  import javax.swing.table.TableColumn;
78  import javax.swing.tree.TreePath;
79  import javax.xml.parsers.DocumentBuilder;
80  import javax.xml.parsers.DocumentBuilderFactory;
81  import javax.xml.parsers.ParserConfigurationException;
82  import javax.xml.transform.OutputKeys;
83  import javax.xml.transform.Transformer;
84  import javax.xml.transform.TransformerException;
85  import javax.xml.transform.TransformerFactory;
86  import javax.xml.transform.dom.DOMSource;
87  import javax.xml.transform.stream.StreamResult;
88  
89  import org.djutils.eval.Eval;
90  import org.djutils.event.EventListener;
91  import org.djutils.event.EventListenerMap;
92  import org.djutils.event.EventProducer;
93  import org.djutils.event.EventType;
94  import org.djutils.exceptions.Try;
95  import org.djutils.io.ResourceResolver;
96  import org.djutils.metadata.MetaData;
97  import org.djutils.metadata.ObjectDescriptor;
98  import org.opentrafficsim.editor.EvalWrapper.EvalListener;
99  import org.opentrafficsim.editor.Undo.ActionType;
100 import org.opentrafficsim.editor.decoration.DefaultDecorator;
101 import org.opentrafficsim.editor.listeners.AttributesListSelectionListener;
102 import org.opentrafficsim.editor.listeners.AttributesMouseListener;
103 import org.opentrafficsim.editor.listeners.ChangesListener;
104 import org.opentrafficsim.editor.listeners.PopupValueSelectedListener;
105 import org.opentrafficsim.editor.listeners.XsdTreeEditorListener;
106 import org.opentrafficsim.editor.listeners.XsdTreeKeyListener;
107 import org.opentrafficsim.editor.listeners.XsdTreeListener;
108 import org.opentrafficsim.editor.render.AttributeCellRenderer;
109 import org.opentrafficsim.editor.render.AttributesCellEditor;
110 import org.opentrafficsim.editor.render.StringCellRenderer;
111 import org.opentrafficsim.editor.render.XsdTreeCellRenderer;
112 import org.opentrafficsim.road.network.factory.xml.CircularDependencyException;
113 import org.opentrafficsim.swing.gui.Appearance;
114 import org.opentrafficsim.swing.gui.AppearanceApplication;
115 import org.opentrafficsim.swing.gui.AppearanceControlComboBox;
116 import org.w3c.dom.Document;
117 import org.w3c.dom.Element;
118 import org.w3c.dom.NamedNodeMap;
119 import org.xml.sax.SAXException;
120 
121 import de.javagl.treetable.JTreeTable;
122 
123 /**
124  * Editor window to load, edit and save OTS XML files. The class uses an underlying data structure that is based on the XML
125  * Schema for the XML (XSD).<br>
126  * <br>
127  * This functionality is currently in development.
128  * <p>
129  * Copyright (c) 2023-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
130  * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
131  * </p>
132  * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
133  */
134 public class OtsEditor extends AppearanceApplication implements EventProducer
135 {
136 
137     /** */
138     private static final long serialVersionUID = 20230217L;
139 
140     /** Event when a a new file is started. */
141     public static final EventType NEW_FILE = new EventType("NEWFILE",
142             new MetaData("New file", "New file", new ObjectDescriptor("Root", "New root element", XsdTreeNodeRoot.class)));
143 
144     /** Event when the selection in the tree is changed. */
145     public static final EventType SELECTION_CHANGED = new EventType("SELECTIONCHANGED", new MetaData("Selection",
146             "Selection changed", new ObjectDescriptor("Selected node", "Selected node", XsdTreeNode.class)));
147 
148     /** Width of the divider between parts of the screen. */
149     private static final int DIVIDER_SIZE = 4;
150 
151     /** Time between autosaves. */
152     private static final long AUTOSAVE_PERIOD_MS = 60000;
153 
154     /** Whether to update the windows as the split is being dragged. */
155     private static final boolean UPDATE_SPLIT_WHILE_DRAGGING = true;
156 
157     /** Color for inactive nodes (text). */
158     public static final Color INACTIVE_COLOR = new Color(160, 160, 160);
159 
160     /** Indent for first item shown in dropdown. */
161     private int dropdownIndent = 0;
162 
163     /** All items eligible to be shown in a dropdown, i.e. they match the currently typed value. */
164     private List<String> dropdownOptions = new ArrayList<>();
165 
166     /** Map of listeners for {@code EventProducer}. */
167     private final EventListenerMap listenerMap = new EventListenerMap();
168 
169     /** Main split pane. */
170     private final JSplitPane leftRightSplitPane;
171 
172     /** Main tabbed pane at the left-hand side. */
173     private final JTabbedPane visualizationPane;
174 
175     /** Split pane on the right-hand side. */
176     private final JSplitPane rightSplitPane;
177 
178     /** Scenario selection. */
179     private final JComboBox<ScenarioWrapper> scenario;
180 
181     /** Eval wrapper, which maintains input parameters and notifies all dependent objects on changes. */
182     private EvalWrapper evalWrapper = new EvalWrapper(this);
183 
184     /** Tree table at the top in the right-hand side. */
185     private JTreeTable treeTable;
186 
187     /** Table for attributes at the bottom of the right-hand side. */
188     private final JTable attributesTable;
189 
190     /** Status label. */
191     private final JLabel statusLabel;
192 
193     /** Prevents a popup when an expand node is being clicked. */
194     private boolean mayPresentChoice = true;
195 
196     /** Node for which currently a choice popup is being shown, {@code null} if there is none. */
197     private XsdTreeNode choiceNode;
198 
199     /** Map of custom icons, to be loaded as the icon for a node is being composed based in its properties. */
200     private Map<String, Icon> customIcons = new LinkedHashMap<>();
201 
202     /** Icon for in question dialog. */
203     private final ImageIcon questionIcon;
204 
205     /** Root node of the XSD file. */
206     private Document xsdDocument;
207 
208     /** Last directory from which a file was loaded or in to which a file was saved. */
209     private String lastDirectory;
210 
211     /** Last file that was loaded or saved. */
212     private String lastFile;
213 
214     /** Whether there is unsaved content. */
215     private boolean unsavedChanges = false;
216 
217     /** Undo unit, storing all actions. */
218     private Undo undo;
219 
220     /** Auto save task. */
221     private TimerTask autosave;
222 
223     // navigate
224 
225     /** Menu item for jumping back from coupled node. */
226     private JMenuItem backItem;
227 
228     /** Candidate keyref node that was coupled from to a key node, may be {@code null}. */
229     private XsdTreeNode candidateBackNode;
230 
231     /** Keyref node that was coupled from to a key node, may be {@code null}. */
232     private final LinkedList<XsdTreeNode> backNode = new LinkedList<>();
233 
234     /** Candidate attribute of back node referring to coupled node, may be {@code null}. */
235     private String candidateBackAttribute;
236 
237     /** Attribute of back node referring to coupled node, may be {@code null}. */
238     private final LinkedList<String> backAttribute = new LinkedList<>();
239 
240     /** Menu item for jumping to coupled node. */
241     private JMenuItem coupledItem;
242 
243     /** Key node that is coupled to from a keyref node, may be {@code null}. */
244     private XsdTreeNode coupledNode;
245 
246     // copy/paste
247 
248     /** Node in clipboard (sort of...). */
249     private XsdTreeNode clipboard;
250 
251     /** Whether the node in the clipboard was cut. */
252     private boolean cut;
253 
254     /** Node actions. */
255     private NodeActions nodeActions;
256 
257     /** Application store for preferences and recent files. */
258     private static final ApplicationStore APPLICATION_STORE = new ApplicationStore("OTS", "editor");
259 
260     /** Menu with recent files. */
261     private JMenu recentFilesMenu;
262 
263     /**
264      * List of root properties, such as xmlns:ots="http://www.opentrafficsim.org/ots". Keys at uneven indices and values at even
265      * indices.
266      */
267     private final List<String> properties = new ArrayList<>();
268 
269     /**
270      * Constructor.
271      * @throws IOException when a resource could not be loaded.
272      */
273     public OtsEditor() throws IOException
274     {
275         super();
276         setSize(1280, 720);
277         setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); // only exit after possible confirmation in case of unsaved changes
278         addWindowListener(new WindowAdapter()
279         {
280             @Override
281             public void windowClosing(final WindowEvent e)
282             {
283                 exit();
284             }
285         });
286 
287         // split panes
288         this.leftRightSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, UPDATE_SPLIT_WHILE_DRAGGING);
289         this.leftRightSplitPane.setDividerSize(DIVIDER_SIZE);
290         this.leftRightSplitPane.setResizeWeight(0.5);
291         makeClickFlippable(this.leftRightSplitPane);
292         add(this.leftRightSplitPane);
293         this.rightSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, UPDATE_SPLIT_WHILE_DRAGGING);
294         this.rightSplitPane.setDividerSize(DIVIDER_SIZE);
295         this.rightSplitPane.setResizeWeight(0.5);
296         this.rightSplitPane.setAlignmentX(0.5f);
297         makeClickFlippable(this.rightSplitPane);
298         JPanel rightContainer = new JPanel();
299         rightContainer.setLayout(new BoxLayout(rightContainer, BoxLayout.Y_AXIS));
300         rightContainer.setBorder(new LineBorder(null, -1));
301 
302         // scenario and controls
303         JPanel controlsContainer = new JPanel();
304         controlsContainer.add(Box.createHorizontalGlue()); // right-aligns everything
305         controlsContainer.setLayout(new BoxLayout(controlsContainer, BoxLayout.X_AXIS));
306         controlsContainer.setBorder(new LineBorder(null, -1));
307         controlsContainer.setMinimumSize(new Dimension(200, 28));
308         controlsContainer.setPreferredSize(new Dimension(200, 28));
309         controlsContainer.add(new JLabel("Scenario: "));
310         this.scenario = new AppearanceControlComboBox<>();
311         this.scenario.addItem(new ScenarioWrapper(null));
312         this.scenario.setMinimumSize(new Dimension(50, 22));
313         this.scenario.setMaximumSize(new Dimension(250, 22));
314         this.scenario.setPreferredSize(new Dimension(200, 22));
315         this.scenario.addActionListener((a) ->
316         {
317             try
318             {
319                 OtsEditor.this.evalWrapper.setDirty();
320                 OtsEditor.this.evalWrapper
321                         .getEval(OtsEditor.this.scenario.getItemAt(OtsEditor.this.scenario.getSelectedIndex()));
322             }
323             catch (CircularDependencyException ex)
324             {
325                 showCircularInputParameters(ex.getMessage());
326             }
327             catch (RuntimeException ex)
328             {
329                 showInvalidExpression(ex.getMessage());
330             }
331         });
332         controlsContainer.add(this.scenario);
333         controlsContainer.add(Box.createHorizontalStrut(2));
334         JButton playRun = new JButton();
335         playRun.setToolTipText("Run single run");
336         playRun.setIcon(DefaultDecorator.loadIcon("./Play.png", 18, 18, -1, -1));
337         Dimension iconDimension = new Dimension(24, 24);
338         playRun.setMinimumSize(iconDimension);
339         playRun.setMaximumSize(iconDimension);
340         playRun.setPreferredSize(iconDimension);
341         playRun.addActionListener((a) -> runSingle());
342         controlsContainer.add(playRun);
343         JButton playScenario = new JButton();
344         playScenario.setToolTipText("Run scenario (batch)");
345         playScenario.setIcon(DefaultDecorator.loadIcon("./NextTrack.png", 18, 18, -1, -1));
346         playScenario.setMinimumSize(iconDimension);
347         playScenario.setMaximumSize(iconDimension);
348         playScenario.setPreferredSize(iconDimension);
349         playScenario.addActionListener((a) -> runBatch(false));
350         controlsContainer.add(playScenario);
351         JButton playAll = new JButton();
352         playAll.setToolTipText("Run all (batch)");
353         playAll.setIcon(DefaultDecorator.loadIcon("./Last_recor.png", 18, 18, -1, -1));
354         playAll.setMinimumSize(iconDimension);
355         playAll.setMaximumSize(iconDimension);
356         playAll.setPreferredSize(iconDimension);
357         playAll.addActionListener((a) -> runBatch(true));
358         controlsContainer.add(playAll);
359         controlsContainer.add(Box.createHorizontalStrut(4));
360 
361         rightContainer.add(controlsContainer);
362         rightContainer.add(this.rightSplitPane);
363         this.leftRightSplitPane.setRightComponent(rightContainer);
364 
365         this.questionIcon = DefaultDecorator.loadIcon("./Question.png", -1, -1, -1, -1);
366 
367         // visualization pane
368         UIManager.getInsets("TabbedPane.contentBorderInsets").set(-1, -1, 1, -1);
369         this.visualizationPane = new JTabbedPane(JTabbedPane.BOTTOM, JTabbedPane.SCROLL_TAB_LAYOUT);
370         this.visualizationPane.setPreferredSize(new Dimension(900, 900));
371         this.visualizationPane.setBorder(new LineBorder(Color.BLACK, 0));
372         this.leftRightSplitPane.setLeftComponent(this.visualizationPane);
373 
374         // There is likely a better way to do this, but setting the icons specific on the tree is impossible for collapsed and
375         // expanded. Also in that case after removal of a node, the tree appearance gets reset and java default icons appear.
376         // This happens to the leaf/open/closed icons that can be set on the tree. This needs to be done before the JTreeTable
377         // is created, otherwise it loads normal default icons.
378         UIManager.put("Tree.collapsedIcon",
379                 new ImageIcon(ImageIO.read(ResourceResolver.resolve("/Eclipse_collapsed.png").openStream())));
380         UIManager.put("Tree.expandedIcon",
381                 new ImageIcon(ImageIO.read(ResourceResolver.resolve("/Eclipse_expanded.png").openStream())));
382 
383         // empty tree table
384         this.treeTable = new AppearanceControlTreeTable(new XsdTreeTableModel(null));
385         XsdTreeTableModel.applyColumnWidth(this.treeTable);
386         this.rightSplitPane.setTopComponent(new JScrollPane(this.treeTable));
387 
388         // attributes table
389         AttributesTableModel tableModel = new AttributesTableModel(null, this.treeTable);
390         DefaultTableColumnModel columns = new DefaultTableColumnModel();
391         for (int i = 0; i < tableModel.getColumnCount(); i++)
392         {
393             TableColumn column = new TableColumn(i);
394             column.setHeaderValue(tableModel.getColumnName(i));
395             columns.addColumn(column);
396         }
397         this.attributesTable = new JTable(tableModel, columns);
398         this.attributesTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
399         this.attributesTable.putClientProperty("terminateEditOnFocusLost", true);
400         this.attributesTable.setDefaultRenderer(String.class,
401                 new AttributeCellRenderer(DefaultDecorator.loadIcon("./Info.png", 12, 12, 16, 16)));
402         AttributesCellEditor editor = new AttributesCellEditor(this.attributesTable, this);
403         this.attributesTable.setDefaultEditor(String.class, editor);
404         this.attributesTable.addMouseListener(new AttributesMouseListener(this, this.attributesTable));
405         this.attributesTable.getSelectionModel()
406                 .addListSelectionListener(new AttributesListSelectionListener(this, this.attributesTable));
407         AttributesTableModel.applyColumnWidth(this.attributesTable);
408         this.rightSplitPane.setBottomComponent(new JScrollPane(this.attributesTable));
409 
410         addMenuBar();
411 
412         this.statusLabel = new StatusLabel();
413         this.statusLabel.setHorizontalAlignment(SwingConstants.LEFT);
414         this.statusLabel.setBorder(new BevelBorder(BevelBorder.LOWERED));
415         add(this.statusLabel, BorderLayout.SOUTH);
416         removeStatusLabel();
417     }
418 
419     /**
420      * Makes the divider clickable cause the panel to exchange screen size the other way around.
421      * @param pane splitpane to make click-flippable.
422      */
423     private void makeClickFlippable(final JSplitPane pane)
424     {
425         ((BasicSplitPaneUI) pane.getUI()).getDivider().addMouseListener(new MouseAdapter()
426         {
427             @Override
428             public void mouseClicked(final MouseEvent e)
429             {
430                 int size = pane.getOrientation() == JSplitPane.HORIZONTAL_SPLIT ? pane.getWidth() : pane.getHeight();
431                 int target = size - pane.getDividerLocation();
432                 int minimum = pane.getMinimumDividerLocation();
433                 int maximum = pane.getMaximumDividerLocation();
434                 pane.setDividerLocation(Math.min(Math.max(target, minimum), maximum));
435             }
436         });
437     }
438 
439     /**
440      * Returns the invalid cell color.
441      * @return the invalid cell color
442      */
443     public static Color getInvalidColor()
444     {
445         return APPLICATION_STORE.getColor("invalid_color");
446     }
447 
448     /**
449      * Returns the expression cell color.
450      * @return the expression cell color
451      */
452     public static Color getExpressionColor()
453     {
454         return APPLICATION_STORE.getColor("expression_color");
455     }
456 
457     /**
458      * Run a single simulation run.
459      */
460     private void runSingle()
461     {
462         if (!((XsdTreeNode) this.treeTable.getTree().getModel().getRoot()).isValid())
463         {
464             showInvalidToRunMessage();
465             return;
466         }
467         int index = this.scenario.getSelectedIndex();
468         try
469         {
470             OtsEditor.this.evalWrapper.setDirty();
471             if (OtsEditor.this.evalWrapper.getEval(OtsEditor.this.scenario.getItemAt(index)) == null)
472             {
473                 return;
474             }
475         }
476         catch (CircularDependencyException ex)
477         {
478             showCircularInputParameters(ex.getMessage());
479             return;
480         }
481         File file;
482         try
483         {
484             file = File.createTempFile("ots_", ".xml");
485         }
486         catch (IOException exception)
487         {
488             showUnableToRunFromTempFile();
489             return;
490         }
491         save(file, (XsdTreeNodeRoot) this.treeTable.getTree().getModel().getRoot(), false);
492 
493         if (index == 0)
494         {
495             OtsRunner.runSingle(file, null);
496         }
497         else
498         {
499             String selectedScenario = this.scenario.getItemAt(index).scenarioNode().getId();
500             OtsRunner.runSingle(file, selectedScenario);
501         }
502         file.delete();
503     }
504 
505     /**
506      * Batch run.
507      * @param all all scenarios, or only the selected scenario.
508      */
509     protected void runBatch(final boolean all)
510     {
511         if (!((XsdTreeNode) this.treeTable.getTree().getModel().getRoot()).isValid())
512         {
513             showInvalidToRunMessage();
514             return;
515         }
516         // TODO should probably create a utility to run from XML as demo fromXML, but with batch function too
517         if (all)
518         {
519             System.out.println("Running all.");
520         }
521         else
522         {
523             int index = this.scenario.getSelectedIndex();
524             if (index == 0)
525             {
526                 System.out.println("Running all runs of the default scenario.");
527             }
528             else
529             {
530                 System.out.println("Running all runs of scenario " + this.scenario.getItemAt(index) + ".");
531             }
532         }
533     }
534 
535     /**
536      * Returns the undo unit.
537      * @return undo unit.
538      */
539     public Undo getUndo()
540     {
541         return this.undo;
542     }
543 
544     /**
545      * Collapses the given node, if expanded.
546      * @param node node
547      */
548     public void collapse(final XsdTreeNode node)
549     {
550         TreePath path = this.treeTable.getTree().getSelectionPath();
551         if (this.treeTable.getTree().isExpanded(path))
552         {
553             getNodeActions().expand(node, path, true);
554         }
555     }
556 
557     /**
558      * Shows and selects the given node in the tree.
559      * @param node node.
560      * @param attribute attribute name, may be {@code null} to just show the node.
561      */
562     public void show(final XsdTreeNode node, final String attribute)
563     {
564         if (node.getParent() == null)
565         {
566             return; // trying to show node that is in collapsed part of the tree
567         }
568         if (this.treeTable.isEditing())
569         {
570             CellEditor editor = this.treeTable.getCellEditor();
571             if (editor != null)
572             {
573                 editor.cancelCellEditing();
574             }
575         }
576         if (this.attributesTable.isEditing())
577         {
578             CellEditor editor = this.attributesTable.getCellEditor();
579             if (editor != null)
580             {
581                 editor.cancelCellEditing();
582             }
583         }
584         List<XsdTreeNode> nodePath = node.getPath();
585         TreePath path = new TreePath(nodePath.toArray());
586         TreePath partialPath = new TreePath(nodePath.subList(0, nodePath.size() - 1).toArray());
587         this.treeTable.getTree().expandPath(partialPath);
588         this.treeTable.getTree().setSelectionPath(path);
589         this.treeTable.getTree().scrollPathToVisible(path);
590         this.treeTable.updateUI();
591         Rectangle bounds = this.treeTable.getTree().getPathBounds(path);
592         if (bounds == null)
593         {
594             return; // trying to show node that is in collapsed part of the tree
595         }
596         bounds.x += this.treeTable.getX();
597         bounds.y += this.treeTable.getY();
598         ((JComponent) this.treeTable.getParent()).scrollRectToVisible(bounds);
599 
600         this.attributesTable.setModel(new AttributesTableModel(node.isActive() ? node : null, this.treeTable));
601         if (attribute != null)
602         {
603             int index = node.getAttributeIndexByName(attribute);
604             this.attributesTable.setRowSelectionInterval(index, index);
605         }
606         else
607         {
608             this.attributesTable.getSelectionModel().clearSelection();
609         }
610     }
611 
612     /**
613      * Sets a status label.
614      * @param label status label.
615      */
616     public void setStatusLabel(final String label)
617     {
618         this.statusLabel.setText(label);
619     }
620 
621     /**
622      * Removes the status label.
623      */
624     public void removeStatusLabel()
625     {
626         this.statusLabel.setText(" ");
627     }
628 
629     /**
630      * Adds the menu bar.
631      */
632     private void addMenuBar()
633     {
634         JMenuBar menuBar = new JMenuBar();
635         setJMenuBar(menuBar);
636         JMenu fileMenu = new JMenu("File");
637         menuBar.add(fileMenu);
638         JMenuItem newFile = new JMenuItem("New");
639         newFile.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, ActionEvent.CTRL_MASK));
640         fileMenu.add(newFile);
641         newFile.addActionListener((a) -> newFile());
642         JMenuItem open = new JMenuItem("Open...");
643         open.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, ActionEvent.CTRL_MASK));
644         fileMenu.add(open);
645         open.addActionListener((a) -> openFile());
646         this.recentFilesMenu = new JMenu("Recent files");
647         updateRecentFileMenu();
648         fileMenu.add(this.recentFilesMenu);
649         JMenuItem save = new JMenuItem("Save");
650         save.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, ActionEvent.CTRL_MASK));
651         fileMenu.add(save);
652         save.addActionListener((a) -> saveFile());
653         JMenuItem saveAs = new JMenuItem("Save as...");
654         fileMenu.add(saveAs);
655         saveAs.addActionListener((a) -> saveFileAs((XsdTreeNodeRoot) OtsEditor.this.treeTable.getTree().getModel().getRoot()));
656         fileMenu.add(new JSeparator());
657         JMenuItem propertiesItem = new JMenuItem("Properties...");
658         fileMenu.add(propertiesItem);
659         propertiesItem.addActionListener((a) -> new PropertiesDialog(this, this.properties, this.questionIcon));
660         fileMenu.add(new JSeparator());
661         JMenuItem exit = new JMenuItem("Exit");
662         fileMenu.add(exit);
663         exit.addActionListener((a) -> exit());
664 
665         JMenu editMenu = new JMenu("Edit");
666         menuBar.add(editMenu);
667         JMenuItem undoItem = new JMenuItem("Undo");
668         undoItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Z, ActionEvent.CTRL_MASK));
669         editMenu.add(undoItem);
670         undoItem.addActionListener((a) -> OtsEditor.this.undo.undo());
671         JMenuItem redoItem = new JMenuItem("Redo");
672         redoItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Y, ActionEvent.CTRL_MASK));
673         editMenu.add(redoItem);
674         redoItem.addActionListener((a) -> OtsEditor.this.undo.redo());
675         this.undo = new Undo(this, undoItem, redoItem);
676 
677         JMenu navigateMenu = new JMenu("Navigate");
678         menuBar.add(navigateMenu);
679         this.backItem = new JMenuItem("Go back");
680         this.backItem.setEnabled(false);
681         this.backItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0));
682         navigateMenu.add(this.backItem);
683         this.backItem.addActionListener((a) ->
684         {
685             show(this.backNode.pollLast(), this.backAttribute.pollLast());
686             if (this.backNode.isEmpty())
687             {
688                 this.backItem.setText("Go back");
689                 this.backItem.setEnabled(false);
690             }
691             else
692             {
693                 XsdTreeNode back = this.backNode.peekLast();
694                 this.backItem.setText("Go back to " + back.getNodeName() + (back.isIdentifiable() ? " " + back.getId() : ""));
695                 this.backItem.setEnabled(true);
696             }
697         });
698         this.coupledItem = new JMenuItem("Go to coupled item");
699         this.coupledItem.setEnabled(false);
700         this.coupledItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F4, 0));
701         navigateMenu.add(this.coupledItem);
702         this.coupledItem.addActionListener((a) ->
703         {
704             if (this.coupledNode != null)
705             {
706                 this.backNode.add(this.candidateBackNode);
707                 this.backAttribute.add(this.candidateBackAttribute);
708                 while (this.backNode.size() > APPLICATION_STORE.getInt("max_navigate"))
709                 {
710                     this.backNode.remove();
711                     this.backAttribute.remove();
712                 }
713                 XsdTreeNode back = OtsEditor.this.backNode.peekLast();
714                 this.backItem.setText("Go back to " + back.getNodeName() + (back.isIdentifiable() ? " " + back.getId() : ""));
715                 this.backItem.setEnabled(this.backNode.peekLast() != null);
716                 show(OtsEditor.this.coupledNode, null);
717             }
718         });
719     }
720 
721     /**
722      * Updates the recent file menu.
723      */
724     private void updateRecentFileMenu()
725     {
726         this.recentFilesMenu.removeAll();
727         List<String> files = APPLICATION_STORE.getRecentFiles("recent_files");
728         if (!files.isEmpty())
729         {
730             for (String file : files)
731             {
732                 JMenuItem item = new JMenuItem(file);
733                 item.addActionListener((i) ->
734                 {
735                     if (confirmDiscardChanges())
736                     {
737                         File f = new File(file);
738                         this.lastDirectory = f.getParent() + File.separator;
739                         this.lastFile = f.getName();
740                         if (!loadFile(f, "File loaded", true))
741                         {
742                             boolean remove = JOptionPane.showConfirmDialog(OtsEditor.this,
743                                     "File could not be loaded. Do you want to remove it from recent files?",
744                                     "Remove from recent files?", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
745                                     this.questionIcon) == JOptionPane.OK_OPTION;
746                             if (remove)
747                             {
748                                 APPLICATION_STORE.removeRecentFile("recent_files", file);
749                                 updateRecentFileMenu();
750                             }
751                         }
752                     }
753                 });
754                 this.recentFilesMenu.add(item);
755             }
756             this.recentFilesMenu.add(new JSeparator());
757         }
758         JMenuItem item = new JMenuItem("Clear history");
759         item.setEnabled(!files.isEmpty());
760         item.addActionListener((i) ->
761         {
762             boolean clear = JOptionPane.showConfirmDialog(OtsEditor.this, "Are you sure you want to clear the recent files?",
763                     "Clear recent files?", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
764                     this.questionIcon) == JOptionPane.OK_OPTION;
765             if (clear)
766             {
767                 APPLICATION_STORE.clearProperty("recent_files");
768                 updateRecentFileMenu();
769             }
770         });
771         this.recentFilesMenu.add(item);
772     }
773 
774     /**
775      * Sets coupled node from user action, i.e. the node that contains the key value to which a user selected node with keyref
776      * refers to.
777      * @param toNode key node that is coupled to from a keyref node, may be {@code null}.
778      * @param fromNode keyref node that is coupled from to a key node, may be {@code null}.
779      * @param fromAttribute attribute in keyref node that refers to coupled node, may be {@code null}.
780      */
781     public void setCoupledNode(final XsdTreeNode toNode, final XsdTreeNode fromNode, final String fromAttribute)
782     {
783         if (toNode == null)
784         {
785             this.coupledItem.setEnabled(false);
786             this.coupledItem.setText("Go to coupled item");
787         }
788         else
789         {
790             this.coupledItem.setEnabled(true);
791             this.coupledItem.setText("Go to " + (fromAttribute != null ? fromNode.getAttributeValue(fromAttribute)
792                     : (fromNode.isIdentifiable() ? fromNode.getId() : fromNode.getValue())));
793         }
794         this.coupledNode = toNode;
795         this.candidateBackNode = fromNode;
796         this.candidateBackAttribute = fromAttribute;
797     }
798 
799     /**
800      * Sets whether there are unsaved changes, resulting in a * in the window name, and confirmation pop-ups upon file changes.
801      * @param unsavedChanges whether there are unsaved changes.
802      */
803     public void setUnsavedChanges(final boolean unsavedChanges)
804     {
805         this.unsavedChanges = unsavedChanges;
806         StringBuilder title = new StringBuilder("OTS | The Open Traffic Simulator | Editor");
807         if (this.lastFile != null)
808         {
809             title.append(" (").append(this.lastDirectory).append(this.lastFile).append(")");
810         }
811         if (this.unsavedChanges)
812         {
813             title.append(" *");
814         }
815         setTitle(title.toString());
816     }
817 
818     /**
819      * Sets a new schema in the GUI.
820      * @param xsdDocument main node from an XSD schema file.
821      * @throws IOException when a resource could not be loaded.
822      */
823     @SuppressWarnings("checkstyle:hiddenfield")
824     public void setSchema(final Document xsdDocument) throws IOException
825     {
826         this.xsdDocument = xsdDocument;
827         this.undo.setIgnoreChanges(true);
828         initializeTree();
829         this.undo.clear();
830         setStatusLabel("Schema " + xsdDocument.getBaseURI() + " loaded");
831         setVisible(true);
832         this.leftRightSplitPane.setDividerLocation(0.65);
833         this.rightSplitPane.setDividerLocation(0.75);
834         setAppearance(getAppearance());
835         SwingUtilities.invokeLater(() -> checkAutosave());
836     }
837 
838     /**
839      * Checks for "autosave*.xml" files in the temporary directory.
840      */
841     private void checkAutosave()
842     {
843         Path tmpPath = Paths.get(System.getProperty("java.io.tmpdir") + "ots" + File.separator);
844         File tmpDir = tmpPath.toFile();
845         if (!tmpDir.exists())
846         {
847             tmpDir.mkdir();
848         }
849         Iterator<Path> it;
850         try
851         {
852             it = Files.newDirectoryStream(tmpPath, "autosave*.xml").iterator();
853         }
854         catch (IOException ioe)
855         {
856             // skip presenting user with autosave, but also do not delete file
857             return;
858         }
859         if (it.hasNext())
860         {
861             File file = it.next().toFile();
862             int userInput = JOptionPane.showConfirmDialog(this,
863                     "Autosave file " + file.getName() + " (" + new Date(file.lastModified())
864                             + ") detected. Do you want to load this file? ('No' removes the file)",
865                     "Autosave file detected", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
866                     this.questionIcon);
867             if (userInput == JOptionPane.OK_OPTION)
868             {
869                 boolean loaded = loadFile(file, "Autosave file loaded", false);
870                 if (!loaded)
871                 {
872                     boolean remove = JOptionPane.showConfirmDialog(OtsEditor.this,
873                             "Autosave file could not be loaded. Do you want ro remove it?", "Remove autosave?",
874                             JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE,
875                             this.questionIcon) == JOptionPane.YES_OPTION;
876                     if (remove)
877                     {
878                         file.delete();
879                     }
880                 }
881                 setUnsavedChanges(true);
882                 this.treeTable.updateUI();
883                 file.delete();
884             }
885             else if (userInput == JOptionPane.NO_OPTION)
886             {
887                 file.delete();
888             }
889         }
890     }
891 
892     /**
893      * Asks for confirmation to discard unsaved changes, if any, and initializes the tree.
894      */
895     private void newFile()
896     {
897         if (confirmDiscardChanges())
898         {
899             try
900             {
901                 this.undo.setIgnoreChanges(true);
902                 initializeTree();
903                 this.attributesTable.setModel(new AttributesTableModel(null, this.treeTable));
904                 this.undo.clear();
905             }
906             catch (IOException exception)
907             {
908                 JOptionPane.showMessageDialog(this, "Unable to reload schema.", "Unable to reload schema.",
909                         JOptionPane.WARNING_MESSAGE);
910             }
911         }
912     }
913 
914     /**
915      * Initializes the tree based on the XSD schema.
916      * @throws IOException when a resource can not be loaded.
917      */
918     private void initializeTree() throws IOException
919     {
920         this.scenario.removeAllItems();
921         this.scenario.addItem(new ScenarioWrapper(null));
922         setDefaultProperties();
923 
924         // tree table
925         XsdTreeTableModel treeModel = new XsdTreeTableModel(this.xsdDocument);
926         this.treeTable = new AppearanceControlTreeTable(treeModel);
927         this.treeTable.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
928         this.nodeActions = new NodeActions(this, this.treeTable);
929         this.treeTable.putClientProperty("terminateEditOnFocusLost", true);
930         treeModel.setTreeTable(this.treeTable);
931         this.treeTable.setDefaultRenderer(String.class, new StringCellRenderer(this.treeTable));
932         ((DefaultCellEditor) this.treeTable.getDefaultEditor(String.class)).setClickCountToStart(1);
933         XsdTreeTableModel.applyColumnWidth(this.treeTable);
934         // sets custom icon and appends the choice icon for choice nodes
935         this.treeTable.getTree().setCellRenderer(new XsdTreeCellRenderer(this));
936 
937         // this listener changes Id or node Value for each key being pressed
938         // this listener starts a new undo event when the editor gets focus on the JTreeTable
939         // this listener may cause new undo actions when cells are navigated using the keyboard
940         new XsdTreeEditorListener(this, this.treeTable);
941 
942         // throws selection events and updates the attributes table
943         // this listener will make sure no choice popup is presented by a left-click on expand/collapse, even for a choice node
944         // this listener makes sure that a choice popup can be presented again after a left-click on an expansion/collapse node
945         // it also shows the tooltip in tree nodes
946         // this listener opens the attributes of a node, and presents the popup for a choice or for addition/deletion of nodes
947         new XsdTreeListener(this, this.treeTable, this.attributesTable);
948 
949         // listener to keyboard shortcuts and key events that should start (i.e. end previous) undo actions
950         new XsdTreeKeyListener(this, this.treeTable);
951 
952         int dividerLocation = this.rightSplitPane.getDividerLocation();
953         this.rightSplitPane.setTopComponent(new JScrollPane(this.treeTable));
954         this.rightSplitPane.setDividerLocation(dividerLocation);
955 
956         XsdTreeNodeRoot root = (XsdTreeNodeRoot) treeModel.getRoot();
957         EventListener listener = new ChangesListener(this, this.scenario);
958         root.addListener(listener, XsdTreeNodeRoot.NODE_CREATED);
959         root.addListener(listener, XsdTreeNodeRoot.NODE_REMOVED);
960         fireEvent(NEW_FILE, root);
961 
962         setUnsavedChanges(false);
963         if (this.autosave != null)
964         {
965             this.autosave.cancel();
966         }
967         this.autosave = new TimerTask()
968         {
969             @Override
970             public void run()
971             {
972                 if (OtsEditor.this.unsavedChanges)
973                 {
974                     setStatusLabel("Autosaving...");
975                     File file = new File(System.getProperty("java.io.tmpdir") + "ots" + File.separator
976                             + (OtsEditor.this.lastFile == null ? "autosave.xml" : "autosave_" + OtsEditor.this.lastFile));
977                     save(file, root, false);
978                     file.deleteOnExit();
979                     setStatusLabel("Autosaved");
980                 }
981             }
982         };
983         new Timer().scheduleAtFixedRate(this.autosave, AUTOSAVE_PERIOD_MS, AUTOSAVE_PERIOD_MS);
984         setAppearance(getAppearance()); // because of new AppearanceControlTreeTable
985     }
986 
987     /**
988      * Sets the default properties.
989      */
990     void setDefaultProperties()
991     {
992         this.properties.clear();
993         this.properties.add("xmlns:ots");
994         this.properties.add("http://www.opentrafficsim.org/ots");
995         this.properties.add("xmlns:xi");
996         this.properties.add("http://www.w3.org/2001/XInclude");
997         this.properties.add("xmlns:xsi");
998         this.properties.add("http://www.w3.org/2001/XMLSchema-instance");
999         this.properties.add("xsi:schemaLocation");
1000         this.properties.add(null);
1001     }
1002 
1003     /**
1004      * Creates a new undo action as the selection is changed in the tree table, editing is stopped, or focus is gained/lost.
1005      */
1006     public void startUndoActionOnTreeTable()
1007     {
1008         // allow selection to update after any events triggering this
1009         SwingUtilities.invokeLater(() ->
1010         {
1011             XsdTreeNode node = (XsdTreeNode) this.treeTable.getValueAt(this.treeTable.getSelectedRow(),
1012                     this.treeTable.convertColumnIndexToView(XsdTreeTableModel.TREE_COLUMN)); // columns may have been moved
1013             int col = this.treeTable.convertColumnIndexToModel(this.treeTable.getSelectedColumn());
1014             if (col == XsdTreeTableModel.ID_COLUMN)
1015             {
1016                 this.undo.startAction(ActionType.ID_CHANGE, node, null);
1017             }
1018             else if (col == XsdTreeTableModel.VALUE_COLUMN)
1019             {
1020                 this.undo.startAction(ActionType.VALUE_CHANGE, node, null);
1021             }
1022         });
1023     }
1024 
1025     /**
1026      * Returns the XsdTreeNode of the row under the given point (from a mouse or key event).
1027      * @param point point (from an event)
1028      * @return the XsdTreeNode of the row under the given point
1029      */
1030     public XsdTreeNode getTreeNodeAtPoint(final Point point)
1031     {
1032         int row = this.treeTable.rowAtPoint(point);
1033         int col = this.treeTable.convertColumnIndexToView(XsdTreeTableModel.TREE_COLUMN); // columns may have been moved
1034         return (XsdTreeNode) this.treeTable.getValueAt(row, col);
1035     }
1036 
1037     /**
1038      * Adds a listener to a popup to remove the popop from the component when the popup becomes invisible. This makes sure that
1039      * a right-click on another location that should show a different popup, is not overruled by the popup of a previous click.
1040      * @param popup popup menu.
1041      * @param component component from which the menu will be removed.
1042      */
1043     public void preparePopupRemoval(final JPopupMenu popup, final JComponent component)
1044     {
1045         popup.addPopupMenuListener(new PopupMenuListener()
1046         {
1047             @Override
1048             public void popupMenuWillBecomeVisible(final PopupMenuEvent e)
1049             {
1050             }
1051 
1052             @Override
1053             public void popupMenuWillBecomeInvisible(final PopupMenuEvent e)
1054             {
1055                 component.setComponentPopupMenu(null);
1056                 OtsEditor.this.choiceNode = null;
1057             }
1058 
1059             @Override
1060             public void popupMenuCanceled(final PopupMenuEvent e)
1061             {
1062             }
1063         });
1064     }
1065 
1066     /**
1067      * Sets a custom icon for nodes that comply to the path. The path may be an absolute path (e.g. "Ots.Network.Connector") or
1068      * a relative path (e.g. ".Node").
1069      * @param path path.
1070      * @param icon image icon.
1071      */
1072     public void setCustomIcon(final String path, final ImageIcon icon)
1073     {
1074         this.customIcons.put(path, icon);
1075     }
1076 
1077     /**
1078      * Obtains a custom icon for the path.
1079      * @param path node path.
1080      * @return custom icon, empty if there is no custom icon specified for the path.
1081      */
1082     public Optional<Icon> getCustomIcon(final String path)
1083     {
1084         Icon icon = this.customIcons.get(path);
1085         if (icon != null)
1086         {
1087             return Optional.of(icon);
1088         }
1089         for (Entry<String, Icon> entry : this.customIcons.entrySet())
1090         {
1091             if (path.endsWith(entry.getKey()))
1092             {
1093                 return Optional.of(entry.getValue());
1094             }
1095         }
1096         return Optional.empty();
1097     }
1098 
1099     /**
1100      * Returns the node that is the currently selected choice.
1101      * @return node that is the currently selected choice.
1102      */
1103     public XsdTreeNode getChoiceNode()
1104     {
1105         return this.choiceNode;
1106     }
1107 
1108     /**
1109      * Sets the node that is the currently selected choice.
1110      * @param choiceNode node that is the currently selected choice.
1111      */
1112     public void setChoiceNode(final XsdTreeNode choiceNode)
1113     {
1114         this.choiceNode = choiceNode;
1115     }
1116 
1117     /**
1118      * Sets whether the choice menu may appear.
1119      * @param mayPresentChoice whether the choice menu may appear
1120      */
1121     public void setMayPresentChoice(final boolean mayPresentChoice)
1122     {
1123         this.mayPresentChoice = mayPresentChoice;
1124     }
1125 
1126     /**
1127      * Returns whether a choice may be presented.
1128      * @return whether a choice may be presented.
1129      */
1130     public boolean mayPresentChoice()
1131     {
1132         return this.mayPresentChoice;
1133     }
1134 
1135     @Override
1136     public EventListenerMap getEventListenerMap()
1137     {
1138         return this.listenerMap;
1139     }
1140 
1141     /**
1142      * Adds a tab to the main window.
1143      * @param name name of the tab.
1144      * @param icon icon for the tab, may be {@code null}.
1145      * @param component component that will fill the tab.
1146      * @param tip tool-tip for the tab, may be {@code null}.
1147      */
1148     public void addTab(final String name, final Icon icon, final Component component, final String tip)
1149     {
1150         this.visualizationPane.addTab(name, icon, component, tip);
1151     }
1152 
1153     /**
1154      * Returns the component of the tab with given name.
1155      * @param name name of the tab.
1156      * @return component of the tab with given name or empty if no such tab
1157      */
1158     public Optional<Component> getTab(final String name)
1159     {
1160         for (int index = 0; index < this.visualizationPane.getTabCount(); index++)
1161         {
1162             if (this.visualizationPane.getTitleAt(index).equals(name))
1163             {
1164                 return Optional.of(this.visualizationPane.getComponentAt(index));
1165             }
1166         }
1167         return Optional.empty();
1168     }
1169 
1170     /**
1171      * Place focus on the tab with given name.
1172      * @param name name of the tab.
1173      */
1174     public void focusTab(final String name)
1175     {
1176         for (int index = 0; index < this.visualizationPane.getTabCount(); index++)
1177         {
1178             if (this.visualizationPane.getTitleAt(index).equals(name))
1179             {
1180                 this.visualizationPane.setSelectedIndex(index);
1181             }
1182         }
1183     }
1184 
1185     /**
1186      * Requests the user to confirm the deletion of a node. The default button is "Ok". The window popping up is considered
1187      * sufficient warning, and in this way a speedy succession of "del" and "enter" may delete a consecutive range of nodes to
1188      * be deleted.
1189      * @param node node.
1190      * @return {@code true} if the user confirms node removal.
1191      */
1192     public boolean confirmNodeRemoval(final XsdTreeNode node)
1193     {
1194         return JOptionPane.showConfirmDialog(this, "Remove `" + node + "`?", "Remove?", JOptionPane.OK_CANCEL_OPTION,
1195                 JOptionPane.QUESTION_MESSAGE, this.questionIcon) == JOptionPane.OK_OPTION;
1196     }
1197 
1198     /**
1199      * Shows a dialog in a modal pane to confirm discarding unsaved changes.
1200      * @return whether unsaved changes can be discarded.
1201      */
1202     private boolean confirmDiscardChanges()
1203     {
1204         if (!this.unsavedChanges)
1205         {
1206             return true;
1207         }
1208         return JOptionPane.showConfirmDialog(this, "Discard unsaved changes?", "Discard unsaved changes?",
1209                 JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, this.questionIcon) == JOptionPane.OK_OPTION;
1210     }
1211 
1212     /**
1213      * Shows a description in a modal pane.
1214      * @param description description.
1215      */
1216     public void showDescription(final String description)
1217     {
1218         JOptionPane.showMessageDialog(OtsEditor.this,
1219                 "<html><body><p style='width: 400px;'>" + description + "</p></body></html>");
1220     }
1221 
1222     /**
1223      * Show tree invalid.
1224      */
1225     public void showInvalidToRunMessage()
1226     {
1227         JOptionPane.showMessageDialog(OtsEditor.this, "The setup is not valid. Make sure no red nodes remain.",
1228                 "Setup is not valid", JOptionPane.INFORMATION_MESSAGE);
1229     }
1230 
1231     /**
1232      * Show input parameters have a circular dependency.
1233      * @param message exception message
1234      */
1235     public void showCircularInputParameters(final String message)
1236     {
1237         JOptionPane.showMessageDialog(OtsEditor.this, "Input parameters have a circular dependency: " + message,
1238                 "Circular input parameter", JOptionPane.INFORMATION_MESSAGE);
1239     }
1240 
1241     /**
1242      * Show message about invalid expression.
1243      * @param message exception message
1244      */
1245     public void showInvalidExpression(final String message)
1246     {
1247         JOptionPane.showMessageDialog(OtsEditor.this, "An expression is not valid: " + message, "Expression not valid",
1248                 JOptionPane.INFORMATION_MESSAGE);
1249     }
1250 
1251     /**
1252      * Show unable to run.
1253      */
1254     public void showUnableToRunFromTempFile()
1255     {
1256         JOptionPane.showMessageDialog(OtsEditor.this, "Unable to run, temporary file could not be saved.", "Unable to run",
1257                 JOptionPane.INFORMATION_MESSAGE);
1258     }
1259 
1260     /**
1261      * Places a popup with value options under the cell that is being clicked in a table (tree or attributes). The popup will
1262      * show items relevant to what is being typed in the cell. The maximum number of items shown is limited to
1263      * {@code MAX_DROPDOWN_ITEMS}.
1264      * @param allOptions list of all options, will be filtered when typing.
1265      * @param table table, will be either the tree table or the attributes table.
1266      * @param action action to perform based on the option in the popup that was selected.
1267      */
1268     public void valueOptionsPopup(final List<String> allOptions, final JTable table, final Consumer<String> action)
1269     {
1270         // initially no filtering on current value; this allows a quick reset to possible values
1271         List<String> options = filterOptions(allOptions, "");
1272         OtsEditor.this.dropdownOptions = options;
1273         if (options.isEmpty())
1274         {
1275             return;
1276         }
1277         JPopupMenu popup = new JPopupMenu();
1278         int index = 0;
1279         int maxDropdown = APPLICATION_STORE.getInt("max_dropdown_items");
1280         for (String option : options)
1281         {
1282             JMenuItem item = new JMenuItem(option);
1283             item.setVisible(index++ < maxDropdown);
1284             item.addActionListener(new PopupValueSelectedListener(option, table, action, this.treeTable));
1285             item.setFont(table.getFont());
1286             popup.add(item);
1287         }
1288         this.dropdownIndent = 0;
1289         popup.addMouseWheelListener(new MouseWheelListener()
1290         {
1291             @Override
1292             public void mouseWheelMoved(final MouseWheelEvent e)
1293             {
1294                 OtsEditor.this.dropdownIndent += (e.getWheelRotation() * e.getScrollAmount());
1295                 OtsEditor.this.dropdownIndent = OtsEditor.this.dropdownIndent < 0 ? 0 : OtsEditor.this.dropdownIndent;
1296                 int maxIndent = OtsEditor.this.dropdownOptions.size() - maxDropdown;
1297                 if (maxIndent > 0)
1298                 {
1299                     OtsEditor.this.dropdownIndent =
1300                             OtsEditor.this.dropdownIndent > maxIndent ? maxIndent : OtsEditor.this.dropdownIndent;
1301                     showOptionsInScope(popup);
1302                 }
1303             }
1304         });
1305         preparePopupRemoval(popup, table);
1306         // invoke later because JTreeTable removes the popup with editable cells and it may take previous editable field
1307         SwingUtilities.invokeLater(() ->
1308         {
1309             JTextField field = (JTextField) ((DefaultCellEditor) table.getDefaultEditor(String.class)).getComponent();
1310             table.setComponentPopupMenu(popup);
1311             popup.pack();
1312             popup.setInvoker(table);
1313             popup.setVisible(true);
1314             field.requestFocus();
1315             Rectangle rectangle = field.getBounds();
1316             placePopup(popup, rectangle, table);
1317             field.addKeyListener(new KeyAdapter()
1318             {
1319                 @Override
1320                 public void keyTyped(final KeyEvent e)
1321                 {
1322                     // invoke later to include this current typed key in the result
1323                     SwingUtilities.invokeLater(() ->
1324                     {
1325                         OtsEditor.this.dropdownIndent = 0;
1326                         String currentValue = field.getText();
1327                         OtsEditor.this.dropdownOptions = filterOptions(allOptions, currentValue);
1328                         boolean anyVisible = showOptionsInScope(popup);
1329                         // if no items left, show what was typed as a single item
1330                         // it will be hidden later if we are in the scope of the options, or another current value
1331                         if (!anyVisible)
1332                         {
1333                             JMenuItem item = new JMenuItem(currentValue);
1334                             item.addActionListener(
1335                                     new PopupValueSelectedListener(currentValue, table, action, OtsEditor.this.treeTable));
1336                             item.setFont(table.getFont());
1337                             popup.add(item);
1338                         }
1339                         popup.pack();
1340                         placePopup(popup, rectangle, table);
1341                     });
1342                 }
1343             });
1344             field.addActionListener((e) ->
1345             {
1346                 popup.setVisible(false);
1347                 table.setComponentPopupMenu(null);
1348             });
1349         });
1350     }
1351 
1352     /**
1353      * Filter options for popup, leaving only those that start with the current value.
1354      * @param options options to filter.
1355      * @param currentValue current value.
1356      * @return filtered options.
1357      */
1358     private static List<String> filterOptions(final List<String> options, final String currentValue)
1359     {
1360         return options.stream().filter((val) -> currentValue == null || currentValue.isEmpty() || val.startsWith(currentValue))
1361                 .distinct().sorted().collect(Collectors.toList());
1362     }
1363 
1364     /**
1365      * Updates the options that are shown within a popup menu based on an indent from scrolling.
1366      * @param popup popup menu.
1367      * @return whether at least one item is visible.
1368      */
1369     private boolean showOptionsInScope(final JPopupMenu popup)
1370     {
1371         int optionIndex = 0;
1372         int maxDropdown = APPLICATION_STORE.getInt("max_dropdown_items");
1373         for (Component component : popup.getComponents())
1374         {
1375             JMenuItem item = (JMenuItem) component;
1376             boolean visible = optionIndex < maxDropdown && this.dropdownOptions.indexOf(item.getText()) >= this.dropdownIndent;
1377             item.setVisible(visible);
1378             if (visible)
1379             {
1380                 optionIndex++;
1381             }
1382         }
1383         popup.pack();
1384         return optionIndex > 0;
1385     }
1386 
1387     /**
1388      * Places a popup either below or above a given rectangle, based on surrounding space in the window.
1389      * @param popup popup.
1390      * @param rectangle rectangle of cell being edited, relative to the parent component.
1391      * @param parent component containing the cell.
1392      */
1393     private void placePopup(final JPopupMenu popup, final Rectangle rectangle, final JComponent parent)
1394     {
1395         Point pAttributes = parent.getLocationOnScreen();
1396         // cannot use screen size in case of multiple monitors, so we keep the popup on the JFrame rather than the window
1397         Dimension windowSize = OtsEditor.this.getSize();
1398         Point pWindow = OtsEditor.this.getLocationOnScreen();
1399         if (pAttributes.y + (int) rectangle.getMaxY() + popup.getBounds().getHeight() > windowSize.height + pWindow.y - 1)
1400         {
1401             // above
1402             popup.setLocation(pAttributes.x + (int) rectangle.getMinX(),
1403                     pAttributes.y + (int) rectangle.getMinY() - 1 - (int) popup.getBounds().getHeight());
1404         }
1405         else
1406         {
1407             // below
1408             popup.setLocation(pAttributes.x + (int) rectangle.getMinX(), pAttributes.y + (int) rectangle.getMaxY() - 1);
1409         }
1410     }
1411 
1412     /**
1413      * Asks for confirmation to discard unsaved changes, if any, and show a dialog to open a file.
1414      */
1415     void openFile()
1416     {
1417         if (!confirmDiscardChanges())
1418         {
1419             return;
1420         }
1421         FileDialog fileDialog = new FileDialog(this, "Open XML", FileDialog.LOAD);
1422         fileDialog.setFilenameFilter((dir, name) -> name.toLowerCase().endsWith(".xml"));
1423         fileDialog.setVisible(true);
1424         String fileName = fileDialog.getFile();
1425         if (fileName == null)
1426         {
1427             return;
1428         }
1429         if (!fileName.toLowerCase().endsWith(".xml"))
1430         {
1431             return;
1432         }
1433         this.lastDirectory = fileDialog.getDirectory();
1434         this.lastFile = fileName;
1435         File file = new File(this.lastDirectory + this.lastFile);
1436         boolean loaded = loadFile(file, "File loaded", true);
1437         if (!loaded)
1438         {
1439             JOptionPane.showMessageDialog(this, "Unable to read file.", "Unable to read file.", JOptionPane.WARNING_MESSAGE);
1440         }
1441     }
1442 
1443     /**
1444      * Load file.
1445      * @param file file to load.
1446      * @param postLoadStatus status message in status bar to show after loading.
1447      * @param updateRecentFiles whether to include the opened file in recent files.
1448      * @return whether the file was successfully loaded.
1449      */
1450     private boolean loadFile(final File file, final String postLoadStatus, final boolean updateRecentFiles)
1451     {
1452         try
1453         {
1454             Document document = DocumentReader.open(file.toURI());
1455             this.undo.setIgnoreChanges(true);
1456             initializeTree();
1457             // load main tag xml properties that are outside of XML OTS specification, so they can remain in a saved file
1458             NamedNodeMap attributes = document.getFirstChild().getAttributes();
1459             for (int i = 0; i < attributes.getLength(); i++)
1460             {
1461                 if (this.properties.contains(attributes.item(i).getNodeName()))
1462                 {
1463                     int index = this.properties.indexOf(attributes.item(i).getNodeName());
1464                     this.properties.set(index, attributes.item(i).getNodeName());
1465                     this.properties.set(index + 1, attributes.item(i).getNodeValue());
1466                 }
1467                 else
1468                 {
1469                     this.properties.add(attributes.item(i).getNodeName());
1470                     this.properties.add(attributes.item(i).getNodeValue());
1471                 }
1472             }
1473             XsdTreeNodeRoot root = (XsdTreeNodeRoot) OtsEditor.this.treeTable.getTree().getModel().getRoot();
1474             root.setDirectory(this.lastDirectory);
1475             root.loadXmlNodes(document.getFirstChild());
1476             this.undo.clear();
1477             setUnsavedChanges(false);
1478             setStatusLabel(postLoadStatus);
1479             this.undo.updateButtons();
1480             this.backItem.setEnabled(false);
1481             this.coupledItem.setEnabled(false);
1482             this.coupledItem.setText("Go to coupled item");
1483             this.treeTable.updateUI(); // knowing/changing the directory may change validation status through imports
1484             if (updateRecentFiles)
1485             {
1486                 APPLICATION_STORE.addRecentFile("recent_files", file.getAbsolutePath());
1487                 updateRecentFileMenu();
1488             }
1489             return true;
1490         }
1491         catch (SAXException | IOException | ParserConfigurationException exception)
1492         {
1493             return false;
1494         }
1495     }
1496 
1497     /**
1498      * Saves the file if a file name is known, otherwise forwards to {@code saveFileAs()}.
1499      */
1500     private void saveFile()
1501     {
1502         XsdTreeNodeRoot root = (XsdTreeNodeRoot) OtsEditor.this.treeTable.getTree().getModel().getRoot();
1503         if (this.lastFile == null)
1504         {
1505             saveFileAs(root);
1506             return;
1507         }
1508         save(new File(this.lastDirectory + this.lastFile), root, true);
1509         setUnsavedChanges(false);
1510         setStatusLabel("Saved");
1511     }
1512 
1513     /**
1514      * Shows a dialog to define a file and saves in to it.
1515      * @param root root node of tree to save, can be a sub-tree of the full tree.
1516      */
1517     public void saveFileAs(final XsdTreeNode root)
1518     {
1519         FileDialog fileDialog = new FileDialog(this, "Save XML", FileDialog.SAVE);
1520         fileDialog.setFile("*.xml");
1521         fileDialog.setVisible(true);
1522         String fileName = fileDialog.getFile();
1523         if (fileName == null)
1524         {
1525             return;
1526         }
1527         if (!fileName.toLowerCase().endsWith(".xml"))
1528         {
1529             fileName = fileName + ".xml";
1530         }
1531         if (root instanceof XsdTreeNodeRoot)
1532         {
1533             this.lastDirectory = fileDialog.getDirectory();
1534             this.lastFile = fileName;
1535         }
1536         save(new File(fileDialog.getDirectory() + fileName), root, true);
1537         if (root instanceof XsdTreeNodeRoot)
1538         {
1539             this.undo.setIgnoreChanges(true);
1540             ((XsdTreeNodeRoot) root).setDirectory(this.lastDirectory);
1541             this.treeTable.updateUI();
1542             this.attributesTable.updateUI();
1543             setUnsavedChanges(false);
1544             this.undo.setIgnoreChanges(false);
1545         }
1546         setStatusLabel("Saved");
1547     }
1548 
1549     /**
1550      * Performs the actual saving, either from {@code saveFile()}, {@code saveFileAs()} or for a temporary file for simulation.
1551      * @param file file to save.
1552      * @param root root node of tree to save, can be a sub-tree of the full tree.
1553      * @param storeAsRecent whether to store the file under recent files.
1554      */
1555     private void save(final File file, final XsdTreeNode root, final boolean storeAsRecent)
1556     {
1557         try (FileOutputStream fileOutputStream = new FileOutputStream(file))
1558         {
1559             DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
1560             Document document = docBuilder.newDocument();
1561             /*
1562              * The following line omits the 'standalone="no"' in the header xml tag. But there will be no new-line after this
1563              * header tag. It seems a java bug: https://bugs.openjdk.org/browse/JDK-8249867. Result: <?xml version="1.0"
1564              * encoding="UTF-8"?><ots:Ots xmlns:ots="http://www.opentrafficsim.org/ots" ... etc. Other lines will be on a new
1565              * line and indented.
1566              */
1567             document.setXmlStandalone(true);
1568             root.saveXmlNodes(document, document);
1569             Element xmlRoot = (Element) document.getChildNodes().item(0);
1570             Set<String> nameSpaces = new LinkedHashSet<>();
1571             nameSpaces.add("xmlns");
1572             for (int i = 0; i < this.properties.size(); i = i + 2)
1573             {
1574                 String prop = this.properties.get(i);
1575                 String value = this.properties.get(i + 1);
1576                 if (prop.startsWith("xmlns") && value != null && !value.isBlank())
1577                 {
1578                     nameSpaces.add(prop.substring(6));
1579                 }
1580             }
1581             for (int i = 0; i < this.properties.size(); i = i + 2)
1582             {
1583                 String prop = this.properties.get(i);
1584                 String value = this.properties.get(i + 1);
1585                 int semi = prop.indexOf(":");
1586                 String nameSpace = semi < 0 ? null : prop.substring(0, semi);
1587                 if (!nameSpaces.contains(nameSpace) && value != null && !value.isBlank())
1588                 {
1589                     JOptionPane.showMessageDialog(this,
1590                             "Unable to save property " + prop + " as its namespace xmlns:" + nameSpace + " is not provided.",
1591                             "Unable to save property.", JOptionPane.WARNING_MESSAGE);
1592                 }
1593                 else if (value != null && !value.isBlank())
1594                 {
1595                     xmlRoot.setAttribute(prop, value);
1596                 }
1597             }
1598             StreamResult result = new StreamResult(fileOutputStream);
1599 
1600             Transformer transformer = TransformerFactory.newInstance().newTransformer();
1601             transformer.setOutputProperty(OutputKeys.INDENT, "yes");
1602             transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
1603             transformer.transform(new DOMSource(document), result);
1604 
1605             fileOutputStream.close();
1606             // this fixes a bug with missing new line
1607             String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
1608             content = content.replace("<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
1609                     "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + System.lineSeparator());
1610             Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8));
1611             // end of fix
1612             if (storeAsRecent)
1613             {
1614                 APPLICATION_STORE.addRecentFile("recent_files", file.getAbsolutePath());
1615                 updateRecentFileMenu();
1616             }
1617         }
1618         catch (ParserConfigurationException | TransformerException | IOException exception)
1619         {
1620             JOptionPane.showMessageDialog(this, "Unable to save file.", "Unable to save file.", JOptionPane.WARNING_MESSAGE);
1621         }
1622     }
1623 
1624     /**
1625      * Exits the system, but not before a confirmation on unsaved changes if there are unsaved changes.
1626      */
1627     private void exit()
1628     {
1629         if (confirmDiscardChanges())
1630         {
1631             System.exit(0);
1632         }
1633     }
1634 
1635     /**
1636      * Limits the length of a tooltip message. This is to prevent absurd tooltip texts based on really long patterns that should
1637      * be matched. Will return {@code null} if the input is {@code null}.
1638      * @param message tooltip message, may be {@code null}.
1639      * @return possibly shortened tooltip message.
1640      */
1641     public static String limitTooltip(final String message)
1642     {
1643         int maxTooltipLength = APPLICATION_STORE.getInt("max_tooltip_length");
1644         if (message == null || message.length() < maxTooltipLength)
1645         {
1646             return message;
1647         }
1648         return message.substring(0, maxTooltipLength - 3) + "...";
1649     }
1650 
1651     /**
1652      * Adds an external listener to the cell editor of the attributes table.
1653      * @param listener listener to the cell editor of the attributes table.
1654      */
1655     public void addAttributeCellEditorListener(final CellEditorListener listener)
1656     {
1657         this.attributesTable.getDefaultEditor(String.class).addCellEditorListener(listener);
1658     }
1659 
1660     /**
1661      * Sets a node in the clipboard.
1662      * @param clipboard node to set in the clipboard.
1663      * @param cut whether the node was cut.
1664      */
1665     @SuppressWarnings("hiddenfield")
1666     public void setClipboard(final XsdTreeNode clipboard, final boolean cut)
1667     {
1668         this.clipboard = clipboard;
1669         this.cut = cut;
1670     }
1671 
1672     /**
1673      * Returns the clipboard node.
1674      * @return clipboard node.
1675      */
1676     public XsdTreeNode getClipboard()
1677     {
1678         return this.clipboard;
1679     }
1680 
1681     /**
1682      * Remove node that was cut. This can be called safely while not knowing the clipboard was cut.
1683      */
1684     public void removeClipboardWhenCut()
1685     {
1686         if (this.clipboard != null && this.cut)
1687         {
1688             if (this.clipboard.isRemovable())
1689             {
1690                 this.clipboard.remove();
1691             }
1692             this.clipboard = null;
1693         }
1694     }
1695 
1696     /**
1697      * Returns the node actions.
1698      * @return node actions.
1699      */
1700     public NodeActions getNodeActions()
1701     {
1702         return this.nodeActions;
1703     }
1704 
1705     @Override
1706     public boolean addListener(final EventListener listener, final EventType eventType)
1707     {
1708         return Try.assign(() -> EventProducer.super.addListener(listener, eventType),
1709                 "Local event producer should not give a RemoteException.");
1710     }
1711 
1712     /**
1713      * Return an evaluator to evaluate expression values. This evaluator uses the input parameters of the currently selected
1714      * scenario.
1715      * @return evaluator to evaluate expression values.
1716      */
1717     public Eval getEval()
1718     {
1719         try
1720         {
1721             Eval eval = this.evalWrapper.getEval(OtsEditor.this.scenario.getItemAt(OtsEditor.this.scenario.getSelectedIndex()));
1722             return eval == null ? this.evalWrapper.getLastValidEval() : eval;
1723         }
1724         catch (CircularDependencyException ex)
1725         {
1726             showCircularInputParameters(ex.getMessage());
1727             return this.evalWrapper.getLastValidEval();
1728         }
1729         catch (RuntimeException ex)
1730         {
1731             // some parameters are not valid
1732             return this.evalWrapper.getLastValidEval();
1733         }
1734     }
1735 
1736     /**
1737      * Adds listener to changes in the evaluator, i.e. added, removed or changed input parameters.
1738      * @param listener listener.
1739      */
1740     public void addEvalListener(final EvalListener listener)
1741     {
1742         this.evalWrapper.addListener(listener);
1743     }
1744 
1745     /**
1746      * Removes listener to changes in the evaluator, i.e. added, removed or changed input parameters.
1747      * @param listener listener.
1748      */
1749     public void removeEvalListener(final EvalListener listener)
1750     {
1751         this.evalWrapper.removeListener(listener);
1752     }
1753 
1754     @Override
1755     public void setAppearance(final Appearance appearance)
1756     {
1757         super.setAppearance(appearance);
1758         // these components are hidden from the Swing structure
1759         if (this.treeTable != null)
1760         {
1761             changeFont((Component) this.treeTable.getTree().getCellRenderer(), appearance.getFont());
1762             changeFontSize((Component) this.treeTable.getTree().getCellRenderer());
1763         }
1764     }
1765 
1766 }