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