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             /** {@inheritDoc} */
315             @Override
316             public void windowClosing(final WindowEvent e)
317             {
318                 exit();
319             }
320         });
321 
322         // split panes
323         this.leftRightSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, UPDATE_SPLIT_WHILE_DRAGGING);
324         this.leftRightSplitPane.setDividerSize(DIVIDER_SIZE);
325         this.leftRightSplitPane.setResizeWeight(0.5);
326         add(this.leftRightSplitPane);
327         this.rightSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, UPDATE_SPLIT_WHILE_DRAGGING);
328         this.rightSplitPane.setDividerSize(DIVIDER_SIZE);
329         this.rightSplitPane.setResizeWeight(0.5);
330         this.rightSplitPane.setAlignmentX(0.5f);
331 
332         JPanel rightContainer = new JPanel();
333         rightContainer.setLayout(new BoxLayout(rightContainer, BoxLayout.Y_AXIS));
334         rightContainer.setBorder(new LineBorder(null, -1));
335 
336         // scenario and controls
337         JPanel controlsContainer = new JPanel();
338         controlsContainer.add(Box.createHorizontalGlue()); // right-aligns everything
339         controlsContainer.setLayout(new BoxLayout(controlsContainer, BoxLayout.X_AXIS));
340         controlsContainer.setBorder(new LineBorder(null, -1));
341         controlsContainer.setMinimumSize(new Dimension(200, 28));
342         controlsContainer.setPreferredSize(new Dimension(200, 28));
343         JLabel scenarioLabel = new JLabel("Scenario: ");
344         // scenarioLabel.setFont(FONT);
345         controlsContainer.add(scenarioLabel);
346         this.scenario = new AppearanceControlComboBox<>();
347         // this.scenario.setFont(FONT);
348         this.scenario.addItem(new ScenarioWrapper(null));
349         this.scenario.setMinimumSize(new Dimension(50, 22));
350         this.scenario.setMaximumSize(new Dimension(250, 22));
351         this.scenario.setPreferredSize(new Dimension(200, 22));
352         this.scenario.addActionListener((a) ->
353         {
354             try
355             {
356                 OtsEditor.this.evalWrapper.setDirty();
357                 OtsEditor.this.evalWrapper
358                         .getEval(OtsEditor.this.scenario.getItemAt(OtsEditor.this.scenario.getSelectedIndex()));
359             }
360             catch (CircularDependencyException exception)
361             {
362                 showCircularInputParameters();
363             }
364             catch (RuntimeException exception)
365             {
366                 // invalid parameter, should be shown in the tree as red cell
367             }
368         });
369         controlsContainer.add(this.scenario);
370         controlsContainer.add(Box.createHorizontalStrut(2));
371         JButton playRun = new JButton();
372         playRun.setToolTipText("Run single run");
373         playRun.setIcon(loadIcon("./Play.png", 18, 18, -1, -1));
374         playRun.setMinimumSize(new Dimension(24, 24));
375         playRun.setMaximumSize(new Dimension(24, 24));
376         playRun.setPreferredSize(new Dimension(24, 24));
377         playRun.addActionListener((a) -> runSingle());
378         controlsContainer.add(playRun);
379         JButton playScenario = new JButton();
380         playScenario.setToolTipText("Run scenario (batch)");
381         playScenario.setIcon(loadIcon("./NextTrack.png", 18, 18, -1, -1));
382         playScenario.setMinimumSize(new Dimension(24, 24));
383         playScenario.setMaximumSize(new Dimension(24, 24));
384         playScenario.setPreferredSize(new Dimension(24, 24));
385         playScenario.addActionListener((a) -> runBatch(false));
386         controlsContainer.add(playScenario);
387         JButton playAll = new JButton();
388         playAll.setToolTipText("Run all (batch)");
389         playAll.setIcon(loadIcon("./Last_recor.png", 18, 18, -1, -1));
390         playAll.setMinimumSize(new Dimension(24, 24));
391         playAll.setMaximumSize(new Dimension(24, 24));
392         playAll.setPreferredSize(new Dimension(24, 24));
393         playAll.addActionListener((a) -> runBatch(true));
394         controlsContainer.add(playAll);
395         controlsContainer.add(Box.createHorizontalStrut(4));
396 
397         rightContainer.add(controlsContainer);
398         rightContainer.add(this.rightSplitPane);
399         this.leftRightSplitPane.setRightComponent(rightContainer);
400 
401         this.questionIcon = loadIcon("./Question.png", -1, -1, -1, -1);
402 
403         // visualization pane
404         UIManager.getInsets("TabbedPane.contentBorderInsets").set(-1, -1, 1, -1);
405         this.visualizationPane = new JTabbedPane(JTabbedPane.BOTTOM, JTabbedPane.SCROLL_TAB_LAYOUT);
406         this.visualizationPane.setPreferredSize(new Dimension(900, 900));
407         this.visualizationPane.setBorder(new LineBorder(Color.BLACK, 0));
408         this.leftRightSplitPane.setLeftComponent(this.visualizationPane);
409 
410         // There is likely a better way to do this, but setting the icons specific on the tree is impossible for collapsed and
411         // expanded. Also in that case after removal of a node, the tree appearance gets reset and java default icons appear.
412         // This happens to the leaf/open/closed icons that can be set on the tree. This needs to be done before the JTreeTable
413         // is created, otherwise it loads normal default icons.
414         UIManager.put("Tree.collapsedIcon",
415                 new ImageIcon(ImageIO.read(Resource.getResourceAsStream("/Eclipse_collapsed.png"))));
416         UIManager.put("Tree.expandedIcon", new ImageIcon(ImageIO.read(Resource.getResourceAsStream("/Eclipse_expanded.png"))));
417 
418         // empty tree table
419         this.treeTable = new AppearanceControlTreeTable(new XsdTreeTableModel(null));
420         XsdTreeTableModel.applyColumnWidth(this.treeTable);
421         this.rightSplitPane.setTopComponent(new JScrollPane(this.treeTable));
422 
423         // attributes table
424         AttributesTableModel tableModel = new AttributesTableModel(null, this.treeTable);
425         DefaultTableColumnModel columns = new DefaultTableColumnModel();
426         TableColumn column1 = new TableColumn(0);
427         column1.setHeaderValue(tableModel.getColumnName(0));
428         columns.addColumn(column1);
429         TableColumn column2 = new TableColumn(1);
430         column2.setHeaderValue(tableModel.getColumnName(1));
431         columns.addColumn(column2);
432         TableColumn column3 = new TableColumn(2);
433         column3.setHeaderValue(tableModel.getColumnName(2));
434         columns.addColumn(column3);
435         TableColumn column4 = new TableColumn(3);
436         column4.setHeaderValue(tableModel.getColumnName(3));
437         columns.addColumn(column4);
438         this.attributesTable = new JTable(tableModel, columns);
439         this.attributesTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
440         this.attributesTable.putClientProperty("terminateEditOnFocusLost", true);
441         this.attributesTable.setDefaultRenderer(String.class,
442                 new AttributeCellRenderer(loadIcon("./Info.png", 12, 12, 16, 16)));
443         AttributesCellEditor editor = new AttributesCellEditor(this.attributesTable, this);
444         this.attributesTable.setDefaultEditor(String.class, editor);
445         this.attributesTable.addMouseListener(new AttributesMouseListener(this, this.attributesTable));
446         this.attributesTable.getSelectionModel()
447                 .addListSelectionListener(new AttributesListSelectionListener(this, this.attributesTable));
448         AttributesTableModel.applyColumnWidth(this.attributesTable);
449         this.rightSplitPane.setBottomComponent(new JScrollPane(this.attributesTable));
450 
451         addMenuBar();
452 
453         this.statusLabel = new StatusLabel();
454         this.statusLabel.setForeground(STATUS_COLOR);
455         this.statusLabel.setHorizontalAlignment(SwingConstants.LEFT);
456         this.statusLabel.setBorder(new BevelBorder(BevelBorder.LOWERED));
457         add(this.statusLabel, BorderLayout.SOUTH);
458         removeStatusLabel();
459     }
460 
461     /**
462      * Run a single simulation run.
463      */
464     private void runSingle()
465     {
466         if (!((XsdTreeNode) this.treeTable.getTree().getModel().getRoot()).isValid())
467         {
468             showInvalidMessage();
469             return;
470         }
471         int index = this.scenario.getSelectedIndex();
472         try
473         {
474             OtsEditor.this.evalWrapper.setDirty();
475             OtsEditor.this.evalWrapper.getEval(OtsEditor.this.scenario.getItemAt(index));
476         }
477         catch (CircularDependencyException ex)
478         {
479             showCircularInputParameters();
480             return;
481         }
482         File file;
483         try
484         {
485             file = File.createTempFile("ots_", ".xml");
486         }
487         catch (IOException exception)
488         {
489             showUnableToRun();
490             return;
491         }
492         save(file, (XsdTreeNodeRoot) this.treeTable.getTree().getModel().getRoot(), false);
493 
494         if (index == 0)
495         {
496             OtsRunner.runSingle(file, null);
497         }
498         else
499         {
500             String scenario = this.scenario.getItemAt(index).getScenarioNode().getId();
501             OtsRunner.runSingle(file, scenario);
502         }
503         file.delete();
504     }
505 
506     /**
507      * Batch run.
508      * @param all boolean; all scenarios, or only the selected scenario.
509      */
510     protected void runBatch(final boolean all)
511     {
512         if (!((XsdTreeNode) this.treeTable.getTree().getModel().getRoot()).isValid())
513         {
514             showInvalidMessage();
515             return;
516         }
517         // TODO: should probably create a utility to run from XML as demo fromXML, but with batch function too
518         if (all)
519         {
520             System.out.println("Running all.");
521         }
522         else
523         {
524             int index = this.scenario.getSelectedIndex();
525             if (index == 0)
526             {
527                 System.out.println("Running all runs of the default scenario.");
528             }
529             else
530             {
531                 System.out.println("Running all runs of scenario " + this.scenario.getItemAt(index) + ".");
532             }
533         }
534     }
535 
536     /**
537      * Returns the undo unit.
538      * @return Undo; undo unit.
539      */
540     public Undo getUndo()
541     {
542         return this.undo;
543     }
544 
545     /**
546      * Shows and selects the given node in the tree.
547      * @param node XsdTreeNode; node.
548      * @param attribute String; attribute name, may be {@code null} to just show the node.
549      */
550     public void show(final XsdTreeNode node, final String attribute)
551     {
552         if (node.getParent() == null)
553         {
554             return; // trying to show node that is in collapsed part of the tree
555         }
556         if (this.treeTable.isEditing())
557         {
558             CellEditor editor = this.treeTable.getCellEditor();
559             if (editor != null)
560             {
561                 editor.cancelCellEditing();
562             }
563         }
564         if (this.attributesTable.isEditing())
565         {
566             CellEditor editor = this.attributesTable.getCellEditor();
567             if (editor != null)
568             {
569                 editor.cancelCellEditing();
570             }
571         }
572         List<XsdTreeNode> nodePath = node.getPath();
573         TreePath path = new TreePath(nodePath.toArray());
574         TreePath partialPath = new TreePath(nodePath.subList(0, nodePath.size() - 1).toArray());
575         this.treeTable.getTree().expandPath(partialPath);
576         this.treeTable.getTree().setSelectionPath(path);
577         this.treeTable.getTree().scrollPathToVisible(path);
578         this.treeTable.updateUI();
579         Rectangle bounds = this.treeTable.getTree().getPathBounds(path);
580         if (bounds == null)
581         {
582             return; // trying to show node that is in collapsed part of the tree
583         }
584         bounds.x += this.treeTable.getX();
585         bounds.y += this.treeTable.getY();
586         ((JComponent) this.treeTable.getParent()).scrollRectToVisible(bounds);
587 
588         if (node.isActive())
589         {
590             this.attributesTable.setModel(new AttributesTableModel(node, this.treeTable));
591         }
592         else
593         {
594             this.attributesTable.setModel(new AttributesTableModel(null, this.treeTable));
595         }
596         if (attribute != null)
597         {
598             int index = node.getAttributeIndexByName(attribute);
599             this.attributesTable.setRowSelectionInterval(index, index);
600         }
601         else
602         {
603             this.attributesTable.getSelectionModel().clearSelection();
604         }
605     }
606 
607     /**
608      * Sets a status label.
609      * @param label String; status label.
610      */
611     public void setStatusLabel(final String label)
612     {
613         this.statusLabel.setText(label);
614     }
615 
616     /**
617      * Removes the status label.
618      */
619     public void removeStatusLabel()
620     {
621         this.statusLabel.setText(" ");
622     }
623 
624     /**
625      * Adds the menu bar.
626      */
627     private void addMenuBar()
628     {
629         JMenuBar menuBar = new JMenuBar();
630         setJMenuBar(menuBar);
631 
632         JMenu fileMenu = new JMenu("File");
633         menuBar.add(fileMenu);
634         JMenuItem newFile = new JMenuItem("New");
635         newFile.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, ActionEvent.CTRL_MASK));
636         fileMenu.add(newFile);
637         newFile.addActionListener((a) -> newFile());
638         JMenuItem open = new JMenuItem("Open...");
639         open.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, ActionEvent.CTRL_MASK));
640         fileMenu.add(open);
641         open.addActionListener((a) -> openFile());
642         this.recentFilesMenu = new JMenu("Recent files");
643         updateRecentFileMenu();
644         fileMenu.add(this.recentFilesMenu);
645         JMenuItem save = new JMenuItem("Save");
646         save.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, ActionEvent.CTRL_MASK));
647         fileMenu.add(save);
648         save.addActionListener((a) -> saveFile());
649         JMenuItem saveAs = new JMenuItem("Save as...");
650         fileMenu.add(saveAs);
651         saveAs.addActionListener((a) -> saveFileAs((XsdTreeNodeRoot) OtsEditor.this.treeTable.getTree().getModel().getRoot()));
652         fileMenu.add(new JSeparator());
653         JMenuItem properties = new JMenuItem("Properties...");
654         fileMenu.add(properties);
655         properties.addActionListener((a) -> showProperties());
656         fileMenu.add(new JSeparator());
657         JMenuItem exit = new JMenuItem("Exit");
658         fileMenu.add(exit);
659         exit.addActionListener((a) -> exit());
660 
661         JMenu editMenu = new JMenu("Edit");
662         menuBar.add(editMenu);
663 
664         JMenuItem undoItem = new JMenuItem("Undo");
665         undoItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Z, ActionEvent.CTRL_MASK));
666         editMenu.add(undoItem);
667         undoItem.addActionListener((a) ->
668         {
669             if (undoItem.isEnabled())
670             {
671                 OtsEditor.this.undo.undo();
672             }
673         });
674 
675         JMenuItem redoItem = new JMenuItem("Redo");
676         redoItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Y, ActionEvent.CTRL_MASK));
677         editMenu.add(redoItem);
678         redoItem.addActionListener((a) ->
679         {
680             if (redoItem.isEnabled())
681             {
682                 OtsEditor.this.undo.redo();
683             }
684         });
685         this.undo = new Undo(this, undoItem, redoItem);
686 
687         JMenu navigateMenu = new JMenu("Navigate");
688         menuBar.add(navigateMenu);
689         this.backItem = new JMenuItem("Go back");
690         this.backItem.setEnabled(false);
691         this.backItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0));
692         navigateMenu.add(this.backItem);
693         this.backItem.addActionListener((a) ->
694         {
695             show(this.backNode.pollLast(), this.backAttribute.pollLast());
696             if (this.backNode.isEmpty())
697             {
698                 this.backItem.setText("Go back");
699                 this.backItem.setEnabled(false);
700             }
701             else
702             {
703                 XsdTreeNode back = this.backNode.peekLast();
704                 this.backItem.setText("Go back to " + back.getNodeName() + (back.isIdentifiable() ? " " + back.getId() : ""));
705                 this.backItem.setEnabled(true);
706             }
707         });
708         this.coupledItem = new JMenuItem("Go to coupled item");
709         this.coupledItem.setEnabled(false);
710         this.coupledItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F4, 0));
711         navigateMenu.add(this.coupledItem);
712         this.coupledItem.addActionListener((a) ->
713         {
714             if (this.coupledNode != null)
715             {
716                 this.backNode.add(this.candidateBackNode);
717                 this.backAttribute.add(this.candidateBackAttribute);
718                 while (this.backNode.size() > MAX_NAVIGATE)
719                 {
720                     this.backNode.remove();
721                     this.backAttribute.remove();
722                 }
723                 XsdTreeNode back = OtsEditor.this.backNode.peekLast();
724                 this.backItem.setText("Go back to " + back.getNodeName() + (back.isIdentifiable() ? " " + back.getId() : ""));
725                 this.backItem.setEnabled(this.backNode.peekLast() != null);
726                 show(OtsEditor.this.coupledNode, null);
727             }
728         });
729     }
730 
731     /**
732      * Updates the recent file menu.
733      */
734     private void updateRecentFileMenu()
735     {
736         this.recentFilesMenu.removeAll();
737         List<String> files = this.applicationStore.getRecentFiles("recent_files");
738         if (!files.isEmpty())
739         {
740             for (String file : files)
741             {
742                 JMenuItem item = new JMenuItem(file);
743                 item.addActionListener((i) ->
744                 {
745                     if (confirmDiscardChanges())
746                     {
747                         File f = new File(file);
748                         this.lastDirectory = f.getParent() + File.separator;
749                         this.lastFile = f.getName();
750                         if (!loadFile(f, "File loaded", true))
751                         {
752                             boolean remove = JOptionPane.showConfirmDialog(OtsEditor.this,
753                                     "File could not be loaded. Do you want ro remove it from recent files?",
754                                     "Remove from recent files?", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
755                                     this.questionIcon) == JOptionPane.OK_OPTION;
756                             if (remove)
757                             {
758                                 this.applicationStore.removeRecentFile("recent_files", file);
759                                 updateRecentFileMenu();
760                             }
761                         }
762                     }
763                 });
764                 this.recentFilesMenu.add(item);
765             }
766             this.recentFilesMenu.add(new JSeparator());
767         }
768         JMenuItem item = new JMenuItem("Clear history");
769         item.setEnabled(!files.isEmpty());
770         item.addActionListener((i) ->
771         {
772             boolean clear = JOptionPane.showConfirmDialog(OtsEditor.this, "Are you sure you want to clear the recent files?",
773                     "Clear recent files?", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
774                     this.questionIcon) == JOptionPane.OK_OPTION;
775             if (clear)
776             {
777                 this.applicationStore.clearProperty("recent_files");
778                 updateRecentFileMenu();
779             }
780         });
781         this.recentFilesMenu.add(item);
782     }
783 
784     /**
785      * Sets coupled node from user action, i.e. the node that contains the key value to which a user selected node with keyref
786      * refers to.
787      * @param coupledNode XsdTreeNode; key node that is coupled to from a keyref node, may be {@code null}.
788      * @param backNode XsdTreeNode; keyref node that is coupled from to a key node, may be {@code null}.
789      * @param backAttribute String; attribute in keyref node that refers to coupled node, may be {@code null}.
790      */
791     public void setCoupledNode(final XsdTreeNode coupledNode, final XsdTreeNode backNode, final String backAttribute)
792     {
793         if (coupledNode == null)
794         {
795             this.coupledItem.setEnabled(false);
796             this.coupledItem.setText("Go to coupled item");
797         }
798         else
799         {
800             this.coupledItem.setEnabled(true);
801             this.coupledItem.setText("Go to " + (backAttribute != null ? backNode.getAttributeValue(backAttribute)
802                     : (backNode.isIdentifiable() ? backNode.getId() : backNode.getValue())));
803         }
804         this.coupledNode = coupledNode;
805         this.candidateBackNode = backNode;
806         this.candidateBackAttribute = backAttribute;
807     }
808 
809     /**
810      * Sets whether there are unsaved changes, resulting in a * in the window name, and confirmation pop-ups upon file changes.
811      * @param unsavedChanges boolean; whether there are unsaved changes.
812      */
813     public void setUnsavedChanges(final boolean unsavedChanges)
814     {
815         this.unsavedChanges = unsavedChanges;
816         StringBuilder title = new StringBuilder("OTS | The Open Traffic Simulator | Editor");
817         if (this.lastFile != null)
818         {
819             title.append(" (").append(this.lastDirectory).append(this.lastFile).append(")");
820         }
821         if (this.unsavedChanges)
822         {
823             title.append(" *");
824         }
825         setTitle(title.toString());
826     }
827 
828     /**
829      * Sets a new schema in the GUI.
830      * @param xsdDocument Document; main node from an XSD schema file.
831      * @throws IOException when a resource could not be loaded.
832      */
833     @SuppressWarnings("checkstyle:hiddenfield")
834     public void setSchema(final Document xsdDocument) throws IOException
835     {
836         this.xsdDocument = xsdDocument;
837         this.undo.setIgnoreChanges(true);
838         initializeTree();
839         this.undo.clear();
840         setStatusLabel("Schema " + xsdDocument.getBaseURI() + " loaded");
841 
842         setVisible(true);
843         this.leftRightSplitPane.setDividerLocation(0.65);
844         this.rightSplitPane.setDividerLocation(0.75);
845         setAppearance(getAppearance());
846 
847         SwingUtilities.invokeLater(() -> checkAutosave());
848     }
849 
850     /**
851      * Checks for "autosave*.xml" files in the temporary directory.
852      */
853     private void checkAutosave()
854     {
855         Path tmpPath = Paths.get(System.getProperty("java.io.tmpdir") + "ots" + File.separator);
856         File tmpDir = tmpPath.toFile();
857         if (!tmpDir.exists())
858         {
859             tmpDir.mkdir();
860         }
861         Iterator<Path> it;
862         try
863         {
864             it = Files.newDirectoryStream(tmpPath, "autosave*.xml").iterator();
865         }
866         catch (IOException ioe)
867         {
868             // skip presenting user with autosave, but also do not delete file
869             return;
870         }
871         if (it.hasNext())
872         {
873             File file = it.next().toFile();
874             int userInput = JOptionPane.showConfirmDialog(this,
875                     "Autosave file " + file.getName() + " (" + new Date(file.lastModified())
876                             + ") detected. Do you want to load this file? ('No' removes the file)",
877                     "Autosave file detected", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
878                     this.questionIcon);
879             if (userInput == JOptionPane.OK_OPTION)
880             {
881                 boolean loaded = loadFile(file, "Autosave file loaded", false);
882                 if (!loaded)
883                 {
884                     boolean remove = JOptionPane.showConfirmDialog(OtsEditor.this,
885                             "File could not be loaded. Do you want ro remove it from recent files?",
886                             "Remove from recent files?", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE,
887                             this.questionIcon) == JOptionPane.YES_OPTION;
888                     if (remove)
889                     {
890                         file.delete();
891                     }
892                 }
893                 setUnsavedChanges(true);
894                 this.treeTable.updateUI();
895                 file.delete();
896             }
897             else if (userInput == JOptionPane.NO_OPTION)
898             {
899                 file.delete();
900             }
901         }
902     }
903 
904     /**
905      * Asks for confirmation to discard unsaved changes, if any, and initializes the tree.
906      */
907     private void newFile()
908     {
909         if (confirmDiscardChanges())
910         {
911             try
912             {
913                 XsdTreeNode node = (XsdTreeNode) this.treeTable.getTree().getModel().getRoot();
914                 this.undo.startAction(ActionType.ADD, node, null);
915                 initializeTree();
916                 this.attributesTable.setModel(new AttributesTableModel(null, this.treeTable));
917                 this.undo.clear();
918             }
919             catch (IOException exception)
920             {
921                 JOptionPane.showMessageDialog(this, "Unable to reload schema.", "Unable to reload schema.",
922                         JOptionPane.WARNING_MESSAGE);
923             }
924         }
925     }
926 
927     /**
928      * Initializes the tree based on the XSD schema.
929      * @throws IOException when a resource can not be loaded.
930      */
931     private void initializeTree() throws IOException
932     {
933         this.scenario.removeAllItems();
934         this.scenario.addItem(new ScenarioWrapper(null));
935         setDefaultProperties();
936 
937         // tree table
938         XsdTreeTableModel treeModel = new XsdTreeTableModel(this.xsdDocument);
939         this.treeTable = new AppearanceControlTreeTable(treeModel);
940         this.treeTable.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
941         this.nodeActions = new NodeActions(this, this.treeTable);
942         this.treeTable.putClientProperty("terminateEditOnFocusLost", true);
943         treeModel.setTreeTable(this.treeTable);
944         this.treeTable.setDefaultRenderer(String.class, new StringCellRenderer(this.treeTable));
945         ((DefaultCellEditor) this.treeTable.getDefaultEditor(String.class)).setClickCountToStart(1);
946         XsdTreeTableModel.applyColumnWidth(this.treeTable);
947 
948         addTreeTableListeners();
949 
950         int dividerLocation = this.rightSplitPane.getDividerLocation();
951         this.rightSplitPane.setTopComponent(null);
952         this.rightSplitPane.setTopComponent(new JScrollPane(this.treeTable));
953         this.rightSplitPane.setDividerLocation(dividerLocation);
954 
955         XsdTreeNodeRoot root = (XsdTreeNodeRoot) treeModel.getRoot();
956         EventListener listener = new ChangesListener(this, this.scenario);
957         root.addListener(listener, XsdTreeNodeRoot.NODE_CREATED);
958         root.addListener(listener, XsdTreeNodeRoot.NODE_REMOVED);
959         fireEvent(NEW_FILE, root);
960 
961         setUnsavedChanges(false);
962         if (this.autosave != null)
963         {
964             this.autosave.cancel();
965         }
966         this.autosave = new TimerTask()
967         {
968             /** {@inheritDoc} */
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());
985     }
986 
987     /**
988      * Sets the default properties.
989      */
990     private 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      * Adds all listeners to the tree table.
1005      * @throws IOException on exception.
1006      */
1007     private void addTreeTableListeners() throws IOException
1008     {
1009         // this listener changes Id or node value values for each key being pressed
1010         DefaultCellEditor editor = (DefaultCellEditor) this.treeTable.getDefaultEditor(String.class);
1011         ((JTextField) editor.getComponent()).addKeyListener(new KeyAdapter()
1012         {
1013             /** {@inheritDoc} */
1014             @Override
1015             public void keyReleased(final KeyEvent e)
1016             {
1017                 int editorCol = OtsEditor.this.treeTable.convertColumnIndexToView(OtsEditor.this.treeTable.getSelectedColumn());
1018                 if (editorCol == 1 || editorCol == 2)
1019                 {
1020                     int row = OtsEditor.this.treeTable.getSelectedRow();
1021                     int col = OtsEditor.this.treeTable.convertColumnIndexToView(0); // columns may have been moved in view
1022                     XsdTreeNode treeNode = (XsdTreeNode) OtsEditor.this.treeTable.getValueAt(row, col);
1023                     if (editorCol == 1)
1024                     {
1025                         treeNode.setId(((JTextField) e.getComponent()).getText());
1026                     }
1027                     else if (editorCol == 2)
1028                     {
1029                         treeNode.setValue(((JTextField) e.getComponent()).getText());
1030                     }
1031                 }
1032             }
1033         });
1034 
1035         // this listener starts a new undo event when the editor gets focus on the JTreeTable
1036         ((JTextField) editor.getComponent()).addFocusListener(new FocusListener()
1037         {
1038             /** {@inheritDoc} */
1039             @Override
1040             public void focusGained(final FocusEvent e)
1041             {
1042                 startUndoActionOnTreeTable();
1043             }
1044 
1045             /** {@inheritDoc} */
1046             @Override
1047             public void focusLost(final FocusEvent e)
1048             {
1049                 startUndoActionOnTreeTable();
1050             }
1051         });
1052 
1053         // this listener may cause new undo actions when cells are navigated using the keyboard
1054         editor.addCellEditorListener(new CellEditorListener()
1055         {
1056             /** {@inheritDoc} */
1057             @Override
1058             public void editingStopped(final ChangeEvent e)
1059             {
1060                 startUndoActionOnTreeTable();
1061             }
1062 
1063             /** {@inheritDoc} */
1064             @Override
1065             public void editingCanceled(final ChangeEvent e)
1066             {
1067                 startUndoActionOnTreeTable();
1068             }
1069         });
1070 
1071         // throws selection events and updates the attributes table
1072         this.treeTable.getTree()
1073                 .addTreeSelectionListener(new XsdTreeSelectionListener(this, this.treeTable, this.attributesTable));
1074 
1075         // sets custom icon, prepends grouping icon, and appends the choice icon for choice nodes
1076         this.treeTable.getTree().setCellRenderer(new XsdTreeCellRenderer(this));
1077 
1078         // this listener will make sure no choice popup is presented by a left-click on expand/collapse, even for a choice node
1079         this.treeTable.getTree().addTreeWillExpandListener(new TreeWillExpandListener()
1080         {
1081             /** {@inheritDoc} */
1082             @Override
1083             public void treeWillExpand(final TreeExpansionEvent event) throws ExpandVetoException
1084             {
1085                 OtsEditor.this.mayPresentChoice = false;
1086             }
1087 
1088             /** {@inheritDoc} */
1089             @Override
1090             public void treeWillCollapse(final TreeExpansionEvent event) throws ExpandVetoException
1091             {
1092                 OtsEditor.this.mayPresentChoice = false;
1093             }
1094         });
1095 
1096         // this listener makes sure that a choice popup can be presented again after a left-click on an expansion/collapse node
1097         // it also shows the tooltip in tree nodes
1098         this.treeTable.addMouseMotionListener(new MouseMotionAdapter()
1099         {
1100             /** {@inheritDoc} */
1101             @Override
1102             public void mouseMoved(final MouseEvent e)
1103             {
1104                 OtsEditor.this.mayPresentChoice = true;
1105 
1106                 // ToolTip
1107                 int row = OtsEditor.this.treeTable.rowAtPoint(e.getPoint());
1108                 int col = OtsEditor.this.treeTable.convertColumnIndexToView(0); // columns may have been moved in view
1109                 XsdTreeNode treeNode = (XsdTreeNode) OtsEditor.this.treeTable.getValueAt(row, col);
1110                 try
1111                 {
1112                     if (!treeNode.isSelfValid())
1113                     {
1114                         OtsEditor.this.treeTable.getTree().setToolTipText(treeNode.reportInvalidNode());
1115                     }
1116                     else
1117                     {
1118                         OtsEditor.this.treeTable.getTree().setToolTipText(null);
1119                     }
1120                 }
1121                 catch (Exception ex)
1122                 {
1123                     if (treeNode.isIdentifiable())
1124                     {
1125                         System.out.println("Node " + treeNode.getId() + " no valid.");
1126                     }
1127                     else
1128                     {
1129                         System.out.println("Node " + treeNode.getNodeName() + " no valid.");
1130                     }
1131                 }
1132             }
1133         });
1134 
1135         // this listener opens the attributes of a node, and presents the popup for a choice or for addition/deletion of nodes
1136         this.treeTable.addMouseListener(new XsdTreeMouseListener(this, this.treeTable, this.attributesTable));
1137 
1138         // this listener removes the selected node, if it is removable
1139         this.treeTable.addKeyListener(new XsdTreeKeyListener(this, this.treeTable));
1140     }
1141 
1142     /**
1143      * Creates a new undo action as the selection is changed in the tree table.
1144      */
1145     public void startUndoActionOnTreeTable()
1146     {
1147         XsdTreeNode node = (XsdTreeNode) this.treeTable.getValueAt(this.treeTable.getSelectedRow(), 0);
1148         int col = this.treeTable.convertColumnIndexToView(this.treeTable.getSelectedColumn());
1149         if (col == 1)
1150         {
1151             this.undo.startAction(ActionType.ID_CHANGE, node, null);
1152         }
1153         else if (col == 2)
1154         {
1155             this.undo.startAction(ActionType.VALUE_CHANGE, node, null);
1156         }
1157     }
1158 
1159     /**
1160      * Adds a listener to a popup to remove the popop from the component when the popup becomes invisible. This makes sure that
1161      * a right-clicks on another location that should show a different popup, is not overruled by the popup of a previous click.
1162      * @param popup JPopupMenu; popup menu.
1163      * @param component JComponent; component from which the menu will be removed.
1164      */
1165     public void preparePopupRemoval(final JPopupMenu popup, final JComponent component)
1166     {
1167         popup.addPopupMenuListener(new PopupMenuListener()
1168         {
1169             /** {@inheritDoc} */
1170             @Override
1171             public void popupMenuWillBecomeVisible(final PopupMenuEvent e)
1172             {
1173             }
1174 
1175             /** {@inheritDoc} */
1176             @Override
1177             public void popupMenuWillBecomeInvisible(final PopupMenuEvent e)
1178             {
1179                 component.setComponentPopupMenu(null);
1180                 OtsEditor.this.choiceNode = null;
1181             }
1182 
1183             /** {@inheritDoc} */
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 String; path.
1195      * @param icon ImageIcon; 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 String; image filename, relative in resources.
1205      * @param width int; width to resize to, may be -1 to leave as is.
1206      * @param height int; width to resize to, may be -1 to leave as is.
1207      * @param bgWidth int; background image width icon will be centered in, may be -1 to leave as is.
1208      * @param bgHeight int; background image height icon will be centered in, may be -1 to leave as is.
1209      * @return ImageIcon; 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 String; node path.
1235      * @return Icon; 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 XsdTreeNode; 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     /** {@inheritDoc} */
1282     @Override
1283     public EventListenerMap getEventListenerMap() throws RemoteException
1284     {
1285         return this.listenerMap;
1286     }
1287 
1288     /**
1289      * Adds a tab to the main window.
1290      * @param name String; name of the tab.
1291      * @param icon Icon; icon for the tab, may be {@code null}.
1292      * @param component Component; component that will fill the tab.
1293      * @param tip String; tool-tip for the tab, may be {@code null}.
1294      */
1295     public void addTab(final String name, final Icon icon, final Component component, final String tip)
1296     {
1297         this.visualizationPane.addTab(name, icon, component, tip);
1298     }
1299 
1300     /**
1301      * Returns the component of the tab with given name.
1302      * @param name String; name of the tab.
1303      * @return Component; component of the tab with given name.
1304      */
1305     public Component getTab(final String name)
1306     {
1307         for (int index = 0; index < this.visualizationPane.getTabCount(); index++)
1308         {
1309             if (this.visualizationPane.getTitleAt(index).equals(name))
1310             {
1311                 return this.visualizationPane.getComponentAt(index);
1312             }
1313         }
1314         return null;
1315     }
1316 
1317     /**
1318      * Place focus on the tab with given name.
1319      * @param name String; name of the tab.
1320      */
1321     public void focusTab(final String name)
1322     {
1323         for (int index = 0; index < this.visualizationPane.getTabCount(); index++)
1324         {
1325             if (this.visualizationPane.getTitleAt(index).equals(name))
1326             {
1327                 this.visualizationPane.setSelectedIndex(index);
1328             }
1329         }
1330     }
1331 
1332     /**
1333      * Requests the user to confirm the deletion of a node. The default button is "Ok". The window popping up is considered
1334      * sufficient warning, and in this way a speedy succession of "del" and "enter" may delete a consecutive range of nodes to
1335      * be deleted.
1336      * @param node XsdTreeNode; node.
1337      * @return boolean; {@code true} if the user confirms node removal.
1338      */
1339     public boolean confirmNodeRemoval(final XsdTreeNode node)
1340     {
1341         return JOptionPane.showConfirmDialog(this, "Remove `" + node + "`?", "Remove?", JOptionPane.OK_CANCEL_OPTION,
1342                 JOptionPane.QUESTION_MESSAGE, this.questionIcon) == JOptionPane.OK_OPTION;
1343     }
1344 
1345     /**
1346      * Shows a dialog in a modal pane to confirm discarding unsaved changes.
1347      * @return boolean; whether unsaved changes can be discarded.
1348      */
1349     private boolean confirmDiscardChanges()
1350     {
1351         if (!this.unsavedChanges)
1352         {
1353             return true;
1354         }
1355         return JOptionPane.showConfirmDialog(this, "Discard unsaved changes?", "Discard unsaved changes?",
1356                 JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, this.questionIcon) == JOptionPane.OK_OPTION;
1357     }
1358 
1359     /**
1360      * Shows a description in a modal pane.
1361      * @param description String; description.
1362      */
1363     public void showDescription(final String description)
1364     {
1365         JOptionPane.showMessageDialog(OtsEditor.this,
1366                 "<html><body><p style='width: 400px;'>" + description + "</p></body></html>");
1367     }
1368 
1369     /**
1370      * Show tree invalid.
1371      */
1372     public void showInvalidMessage()
1373     {
1374         JOptionPane.showMessageDialog(OtsEditor.this, "The setup is not valid. Make sure no red nodes remain.",
1375                 "Setup is not valid", JOptionPane.INFORMATION_MESSAGE);
1376     }
1377 
1378     /**
1379      * Show input parameters have a circular dependency.
1380      */
1381     public void showCircularInputParameters()
1382     {
1383         JOptionPane.showMessageDialog(OtsEditor.this, "Input parameters have a circular dependency.",
1384                 "Circular input parameter", JOptionPane.INFORMATION_MESSAGE);
1385     }
1386 
1387     /**
1388      * Show unable to run.
1389      */
1390     public void showUnableToRun()
1391     {
1392         JOptionPane.showMessageDialog(OtsEditor.this, "Unable to run, temporary file could not be saved.", "Unable to run",
1393                 JOptionPane.INFORMATION_MESSAGE);
1394     }
1395 
1396     /**
1397      * Places a popup with options under the cell that is being clicked in a table. The popup will show items relevant to what
1398      * is being typed in the cell. The maximum number of items shown is limited to {@code MAX_DROPDOWN_ITEMS}.
1399      * @param allOptions List&lt;String&gt;; list of all options, will be filtered when typing.
1400      * @param table JTable; table, will be either the tree table or the attributes table.
1401      * @param action Consumer&lt;String&gt;; action to perform based on the option in the popup that was selected.
1402      */
1403     public void optionsPopup(final List<String> allOptions, final JTable table, final Consumer<String> action)
1404     {
1405         // initially no filtering on current value; this allows a quick reset to possible values
1406         List<String> options = filterOptions(allOptions, "");
1407         OtsEditor.this.dropdownOptions = options;
1408         if (options.isEmpty())
1409         {
1410             return;
1411         }
1412         JPopupMenu popup = new JPopupMenu();
1413         int index = 0;
1414         for (String option : options)
1415         {
1416             JMenuItem item = new JMenuItem(option);
1417             item.setVisible(index++ < MAX_DROPDOWN_ITEMS);
1418             item.addActionListener(new ActionListener()
1419             {
1420                 /** {@inheritDoc} */
1421                 @Override
1422                 public void actionPerformed(final ActionEvent e)
1423                 {
1424                     action.accept(option);
1425                     CellEditor cellEditor = table.getCellEditor();
1426                     if (cellEditor != null)
1427                     {
1428                         cellEditor.cancelCellEditing();
1429                     }
1430                     OtsEditor.this.treeTable.updateUI();
1431                 }
1432             });
1433             item.setFont(table.getFont());
1434             popup.add(item);
1435         }
1436         this.dropdownIndent = 0;
1437         popup.addMouseWheelListener(new MouseWheelListener()
1438         {
1439 
1440             /** {@inheritDoc} */
1441             @Override
1442             public void mouseWheelMoved(final MouseWheelEvent e)
1443             {
1444                 OtsEditor.this.dropdownIndent += (e.getWheelRotation() * e.getScrollAmount());
1445                 OtsEditor.this.dropdownIndent = OtsEditor.this.dropdownIndent < 0 ? 0 : OtsEditor.this.dropdownIndent;
1446                 int maxIndent = OtsEditor.this.dropdownOptions.size() - MAX_DROPDOWN_ITEMS;
1447                 if (maxIndent > 0)
1448                 {
1449                     OtsEditor.this.dropdownIndent =
1450                             OtsEditor.this.dropdownIndent > maxIndent ? maxIndent : OtsEditor.this.dropdownIndent;
1451                     showOptionsInScope(popup);
1452                 }
1453             }
1454         });
1455         preparePopupRemoval(popup, table);
1456         // invoke later because JTreeTable removes the popup with editable cells and it may take previous editable field
1457         SwingUtilities.invokeLater(new Runnable()
1458         {
1459             /** {@inheritDoc} */
1460             @Override
1461             public void run()
1462             {
1463                 JTextField field = (JTextField) ((DefaultCellEditor) table.getDefaultEditor(String.class)).getComponent();
1464                 table.setComponentPopupMenu(popup);
1465                 popup.pack();
1466                 popup.setInvoker(table);
1467                 popup.setVisible(true);
1468                 field.requestFocus();
1469                 Rectangle rectangle = field.getBounds();
1470                 placePopup(popup, rectangle, table);
1471                 field.addKeyListener(new KeyAdapter()
1472                 {
1473                     /** {@inheritDoc} */
1474                     @Override
1475                     public void keyTyped(final KeyEvent e)
1476                     {
1477                         // invoke later to include this current typed key in the result
1478                         SwingUtilities.invokeLater(new Runnable()
1479                         {
1480                             /** {@inheritDoc} */
1481                             @Override
1482                             public void run()
1483                             {
1484                                 OtsEditor.this.dropdownIndent = 0;
1485                                 String currentValue = field.getText();
1486                                 OtsEditor.this.dropdownOptions = filterOptions(allOptions, currentValue);
1487                                 boolean anyVisible = showOptionsInScope(popup);
1488                                 // if no items left, show what was typed as a single item
1489                                 // it will be hidden later if we are in the scope of the options, or another current value
1490                                 if (!anyVisible)
1491                                 {
1492                                     JMenuItem item = new JMenuItem(currentValue);
1493                                     item.addActionListener(new ActionListener()
1494                                     {
1495                                         /** {@inheritDoc} */
1496                                         @Override
1497                                         public void actionPerformed(final ActionEvent e)
1498                                         {
1499                                             action.accept(currentValue);
1500                                             CellEditor cellEditor = table.getCellEditor();
1501                                             if (cellEditor != null)
1502                                             {
1503                                                 cellEditor.cancelCellEditing();
1504                                             }
1505                                             OtsEditor.this.treeTable.updateUI();
1506                                         }
1507                                     });
1508                                     item.setFont(table.getFont());
1509                                     popup.add(item);
1510                                 }
1511                                 popup.pack();
1512                                 placePopup(popup, rectangle, table);
1513                             }
1514                         });
1515                     }
1516                 });
1517                 field.addActionListener(new ActionListener()
1518                 {
1519                     /** {@inheritDoc} */
1520                     @Override
1521                     public void actionPerformed(final ActionEvent e)
1522                     {
1523                         popup.setVisible(false);
1524                         table.setComponentPopupMenu(null);
1525                     }
1526                 });
1527             }
1528         });
1529     }
1530 
1531     /**
1532      * Updates the options that are shown within an dropdown menu based on an indent from scrolling.
1533      * @param popup JPopupMenu; dropdown menu.
1534      * @return boolean; whether at least one item is visible.
1535      */
1536     private boolean showOptionsInScope(final JPopupMenu popup)
1537     {
1538         int optionIndex = 0;
1539         for (Component component : popup.getComponents())
1540         {
1541             JMenuItem item = (JMenuItem) component;
1542             boolean visible =
1543                     optionIndex < MAX_DROPDOWN_ITEMS && this.dropdownOptions.indexOf(item.getText()) >= this.dropdownIndent;
1544             item.setVisible(visible);
1545             if (visible)
1546             {
1547                 optionIndex++;
1548             }
1549         }
1550         popup.pack();
1551         return optionIndex > 0;
1552     }
1553 
1554     /**
1555      * Places a popup either below or above a given rectangle, based on surrounding space in the window.
1556      * @param popup JPopupMenu; popup.
1557      * @param rectangle Rectangle; rectangle of cell being edited, relative to the parent component.
1558      * @param parent JComponent; component containing the cell.
1559      */
1560     private void placePopup(final JPopupMenu popup, final Rectangle rectangle, final JComponent parent)
1561     {
1562         Point pAttributes = parent.getLocationOnScreen();
1563         // cannot use screen size in case of multiple monitors, so we keep the popup on the JFrame rather than the window
1564         Dimension windowSize = OtsEditor.this.getSize();
1565         Point pWindow = OtsEditor.this.getLocationOnScreen();
1566         if (pAttributes.y + (int) rectangle.getMaxY() + popup.getBounds().getHeight() > windowSize.height + pWindow.y - 1)
1567         {
1568             popup.setLocation(pAttributes.x + (int) rectangle.getMinX(),
1569                     pAttributes.y + (int) rectangle.getMinY() - 1 - (int) popup.getBounds().getHeight());
1570         }
1571         else
1572         {
1573             popup.setLocation(pAttributes.x + (int) rectangle.getMinX(), pAttributes.y + (int) rectangle.getMaxY() - 1);
1574         }
1575     }
1576 
1577     /**
1578      * Asks for confirmation to discard unsaved changes, if any, and show a dialog to open a file.
1579      */
1580     void openFile()
1581     {
1582         if (!confirmDiscardChanges())
1583         {
1584             return;
1585         }
1586         FileDialog fileDialog = new FileDialog(this, "Open XML", FileDialog.LOAD);
1587         fileDialog.setFilenameFilter(new FilenameFilter()
1588         {
1589             /** {@inheritDoc} */
1590             @Override
1591             public boolean accept(final File dir, final String name)
1592             {
1593                 return name.toLowerCase().endsWith(".xml");
1594             }
1595         });
1596         fileDialog.setVisible(true);
1597         String fileName = fileDialog.getFile();
1598         if (fileName == null)
1599         {
1600             return;
1601         }
1602         if (!fileName.toLowerCase().endsWith(".xml"))
1603         {
1604             return;
1605         }
1606         this.lastDirectory = fileDialog.getDirectory();
1607         this.lastFile = fileName;
1608         File file = new File(this.lastDirectory + this.lastFile);
1609         boolean loaded = loadFile(file, "File loaded", true);
1610         if (!loaded)
1611         {
1612             JOptionPane.showMessageDialog(this, "Unable to read file.", "Unable to read file.", JOptionPane.WARNING_MESSAGE);
1613         }
1614     }
1615 
1616     /**
1617      * Load file.
1618      * @param file File; file to load.
1619      * @param status String; status message in status bar to show upon loading.
1620      * @param updateRecentFiles boolean; whether to include the opened file in recent files.
1621      * @return boolean; whether the file was successfully loaded.
1622      */
1623     private boolean loadFile(final File file, final String status, final boolean updateRecentFiles)
1624     {
1625         try
1626         {
1627             Document document = DocumentReader.open(file.toURI());
1628             this.undo.setIgnoreChanges(true);
1629             initializeTree();
1630             NamedNodeMap attributes = document.getFirstChild().getAttributes();
1631             for (int i = 0; i < attributes.getLength(); i++)
1632             {
1633                 if (this.properties.contains(attributes.item(i).getNodeName()))
1634                 {
1635                     int index = this.properties.indexOf(attributes.item(i).getNodeName());
1636                     this.properties.set(index, attributes.item(i).getNodeName());
1637                     this.properties.set(index + 1, attributes.item(i).getNodeValue());
1638                 }
1639                 else
1640                 {
1641                     this.properties.add(attributes.item(i).getNodeName());
1642                     this.properties.add(attributes.item(i).getNodeValue());
1643                 }
1644             }
1645             XsdTreeNodeRoot root = (XsdTreeNodeRoot) OtsEditor.this.treeTable.getTree().getModel().getRoot();
1646             root.setDirectory(this.lastDirectory);
1647             root.loadXmlNodes(document.getFirstChild());
1648             this.undo.clear();
1649             setUnsavedChanges(false);
1650             setStatusLabel(status);
1651             this.undo.updateButtons();
1652             this.backItem.setEnabled(false);
1653             this.coupledItem.setEnabled(false);
1654             this.coupledItem.setText("Go to coupled item");
1655             this.treeTable.updateUI(); // knowing/changing the directory may change validation status
1656             if (updateRecentFiles)
1657             {
1658                 this.applicationStore.addRecentFile("recent_files", file.getAbsolutePath());
1659                 updateRecentFileMenu();
1660             }
1661             return true;
1662         }
1663         catch (SAXException | IOException | ParserConfigurationException exception)
1664         {
1665             return false;
1666         }
1667     }
1668 
1669     /**
1670      * Saves the file is a file name is known, otherwise forwards to {@code saveFileAs()}.
1671      */
1672     private void saveFile()
1673     {
1674         XsdTreeNodeRoot root = (XsdTreeNodeRoot) OtsEditor.this.treeTable.getTree().getModel().getRoot();
1675         if (this.lastFile == null)
1676         {
1677             saveFileAs(root);
1678             return;
1679         }
1680         save(new File(this.lastDirectory + this.lastFile), root, true);
1681         setUnsavedChanges(false);
1682         setStatusLabel("Saved");
1683     }
1684 
1685     /**
1686      * Shows a dialog to define a file and saves in to it.
1687      * @param root XsdTreeNode; root node of tree to save, can be a sub-tree of the full tree.
1688      */
1689     public void saveFileAs(final XsdTreeNode root)
1690     {
1691         FileDialog fileDialog = new FileDialog(this, "Save XML", FileDialog.SAVE);
1692         fileDialog.setFile("*.xml");
1693         fileDialog.setVisible(true);
1694         String fileName = fileDialog.getFile();
1695         if (fileName == null)
1696         {
1697             return;
1698         }
1699         if (!fileName.toLowerCase().endsWith(".xml"))
1700         {
1701             fileName = fileName + ".xml";
1702         }
1703         if (root instanceof XsdTreeNodeRoot)
1704         {
1705             this.lastDirectory = fileDialog.getDirectory();
1706             this.lastFile = fileName;
1707         }
1708         save(new File(fileDialog.getDirectory() + fileName), root, true);
1709         if (root instanceof XsdTreeNodeRoot)
1710         {
1711             this.undo.setIgnoreChanges(true);
1712             ((XsdTreeNodeRoot) root).setDirectory(this.lastDirectory);
1713             this.treeTable.updateUI();
1714             this.attributesTable.updateUI();
1715             setUnsavedChanges(false);
1716             this.undo.setIgnoreChanges(false);
1717         }
1718         setStatusLabel("Saved");
1719     }
1720 
1721     /**
1722      * Performs the actual saving, either from {@code saveFile()} or {@code saveFileAs()}.
1723      * @param file File; file to save.
1724      * @param root XsdTreeNode; root node of tree to save, can be a sub-tree of the full tree.
1725      * @param storeAsRecent boolean; whether to store the file under recent files.
1726      */
1727     private void save(final File file, final XsdTreeNode root, final boolean storeAsRecent)
1728     {
1729         try (FileOutputStream fileOutputStream = new FileOutputStream(file))
1730         {
1731             DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
1732             Document document = docBuilder.newDocument();
1733             /*
1734              * The following line omits the 'standalone="no"' in the header xml tag. But there will be no new-line after this
1735              * header tag. It seems a java bug: https://bugs.openjdk.org/browse/JDK-8249867. Result: <?xml version="1.0"
1736              * encoding="UTF-8"?><ots:Ots xmlns:ots="http://www.opentrafficsim.org/ots" ... etc. Other lines will be on a new
1737              * line and indented.
1738              */
1739             document.setXmlStandalone(true);
1740             root.saveXmlNodes(document, document);
1741             Element xmlRoot = (Element) document.getChildNodes().item(0);
1742             Set<String> nameSpaces = new LinkedHashSet<>();
1743             nameSpaces.add("xmlns");
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                 if (prop.startsWith("xmlns") && value != null && !value.isBlank())
1749                 {
1750                     nameSpaces.add(prop.substring(6));
1751                 }
1752             }
1753             for (int i = 0; i < this.properties.size(); i = i + 2)
1754             {
1755                 String prop = this.properties.get(i);
1756                 String value = this.properties.get(i + 1);
1757                 int semi = prop.indexOf(":");
1758                 String nameSpace = semi < 0 ? null : prop.substring(0, semi);
1759                 if (!nameSpaces.contains(nameSpace) && value != null && !value.isBlank())
1760                 {
1761                     JOptionPane.showMessageDialog(this,
1762                             "Unable to save property " + prop + " as its namespace xmlns:" + nameSpace + " is not provided.",
1763                             "Unable to save property.", JOptionPane.WARNING_MESSAGE);
1764                 }
1765                 else if (value != null && !value.isBlank())
1766                 {
1767                     xmlRoot.setAttribute(prop, value);
1768                 }
1769             }
1770             StreamResult result = new StreamResult(fileOutputStream);
1771 
1772             Transformer transformer = TransformerFactory.newInstance().newTransformer();
1773             transformer.setOutputProperty(OutputKeys.INDENT, "yes");
1774             transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
1775             transformer.transform(new DOMSource(document), result);
1776 
1777             fileOutputStream.close();
1778             // this fixes a bug with missing new line
1779             String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
1780             content = content.replace("<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
1781                     "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + System.lineSeparator());
1782             Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8));
1783             // end of fix
1784             if (storeAsRecent)
1785             {
1786                 this.applicationStore.addRecentFile("recent_files", file.getAbsolutePath());
1787                 updateRecentFileMenu();
1788             }
1789         }
1790         catch (ParserConfigurationException | TransformerException | IOException exception)
1791         {
1792             JOptionPane.showMessageDialog(this, "Unable to save file.", "Unable to save file.", JOptionPane.WARNING_MESSAGE);
1793         }
1794     }
1795 
1796     /**
1797      * Shows the properties in a modal window.
1798      */
1799     private void showProperties()
1800     {
1801         // main panel
1802         JPanel panel = new JPanel();
1803         panel.setLayout(new BorderLayout());
1804         // columns
1805         TableColumnModel columns = new DefaultTableColumnModel();
1806         TableColumn column1 = new TableColumn(0, 200);
1807         column1.setHeaderValue("Property");
1808         columns.addColumn(column1);
1809         TableColumn column2 = new TableColumn(1, 600);
1810         column2.setHeaderValue("Value");
1811         columns.addColumn(column2);
1812         // model
1813         TableModel model = new AbstractTableModel()
1814         {
1815             /** */
1816             private static final long serialVersionUID = 20240314L;
1817 
1818             /** {@inheritDoc} */
1819             @Override
1820             public int getRowCount()
1821             {
1822                 return OtsEditor.this.properties.size() / 2;
1823             }
1824 
1825             /** {@inheritDoc} */
1826             @Override
1827             public int getColumnCount()
1828             {
1829                 return 2;
1830             }
1831 
1832             /** {@inheritDoc} */
1833             @Override
1834             public Object getValueAt(final int rowIndex, final int columnIndex)
1835             {
1836                 return OtsEditor.this.properties.get(rowIndex * 2 + columnIndex);
1837             }
1838 
1839             /** {@inheritDoc} */
1840             @Override
1841             public boolean isCellEditable(final int rowIndex, final int columnIndex)
1842             {
1843                 return columnIndex == 1;
1844             }
1845 
1846             /** {@inheritDoc} */
1847             @Override
1848             public void setValueAt(final Object aValue, final int rowIndex, final int columnIndex)
1849             {
1850                 OtsEditor.this.properties.set(rowIndex * 2 + columnIndex, aValue.toString());
1851                 OtsEditor.this.setUnsavedChanges(true);
1852             }
1853         };
1854         // table
1855         JTable table = new JTable(model, columns);
1856         DefaultTableCellRenderer renderer = new DefaultTableCellRenderer()
1857         {
1858             /** */
1859             private static final long serialVersionUID = 20240314L;
1860 
1861             /** {@inheritDoc} */
1862             @Override
1863             public Component getTableCellRendererComponent(final JTable table, final Object value, final boolean isSelected,
1864                     final boolean hasFocus, final int row, final int column)
1865             {
1866                 Component component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1867                 if (table.convertColumnIndexToModel(column) == 0)
1868                 {
1869                     this.setOpaque(true);
1870                 }
1871                 else
1872                 {
1873                     this.setOpaque(false);
1874                 }
1875                 return component;
1876             }
1877         };
1878         renderer.setBackground(panel.getBackground()); // for editable cells
1879         table.setDefaultRenderer(Object.class, renderer);
1880         JScrollPane scroll = new JScrollPane(table); // put table in scroll window, also makes the header visible
1881         scroll.setBorder(new EmptyBorder(0, 0, 0, 0));
1882         panel.add(scroll, BorderLayout.CENTER);
1883         // dialog
1884         final JDialog propertyWindow = new JDialog(this, "Properties", true);
1885         propertyWindow.setMinimumSize(new Dimension(250, 150));
1886         propertyWindow.setPreferredSize(new Dimension(800, 200));
1887         // buttons
1888         JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT));
1889         JButton defaults = new JButton("Set defaults...");
1890         defaults.addActionListener((a) ->
1891         {
1892             boolean set = JOptionPane.showConfirmDialog(propertyWindow, "Are you sure? This will reset all properties.",
1893                     "Are you sure?", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE,
1894                     this.questionIcon) == JOptionPane.OK_OPTION;
1895             if (set)
1896             {
1897                 setDefaultProperties();
1898                 table.updateUI();
1899                 OtsEditor.this.setUnsavedChanges(true);
1900             }
1901         });
1902         buttons.add(defaults);
1903         JButton ok = new JButton("Ok");
1904         ok.addActionListener((a) ->
1905         {
1906             table.getDefaultEditor(Object.class).stopCellEditing();
1907             propertyWindow.dispose();
1908         });
1909         buttons.add(ok);
1910         panel.add(buttons, BorderLayout.PAGE_END);
1911         // pack and visualize
1912         propertyWindow.getContentPane().add(panel);
1913         propertyWindow.pack();
1914         propertyWindow.setLocationRelativeTo(this);
1915         propertyWindow.setVisible(true);
1916     }
1917 
1918     /**
1919      * Exits the system, but not before a confirmation on unsaved changes if there are unsaved changes.
1920      */
1921     private void exit()
1922     {
1923         if (confirmDiscardChanges())
1924         {
1925             System.exit(0);
1926         }
1927     }
1928 
1929     /**
1930      * Limits the length of a tooltip message. This is to prevent absurd tooltip texts based on really long patterns that should
1931      * be matched. Will return {@code null} if the input is {@code null}.
1932      * @param message String; tooltip message, may be {@code null}.
1933      * @return String; possibly shortened tooltip message.
1934      */
1935     public static String limitTooltip(final String message)
1936     {
1937         if (message == null)
1938         {
1939             return null;
1940         }
1941         if (message.length() < MAX_TOOLTIP_LENGTH)
1942         {
1943             return message;
1944         }
1945         return message.substring(0, MAX_TOOLTIP_LENGTH - 3) + "...";
1946     }
1947 
1948     /**
1949      * Filter options, leaving only those that start with the current value.
1950      * @param options List&lt;String&gt;; options to filter.
1951      * @param currentValue String; current value.
1952      * @return List&lt;String&gt;; filtered options.
1953      */
1954     private static List<String> filterOptions(final List<String> options, final String currentValue)
1955     {
1956         return options.stream().filter((val) -> currentValue == null || currentValue.isEmpty() || val.startsWith(currentValue))
1957                 .distinct().sorted().collect(Collectors.toList());
1958     }
1959 
1960     /**
1961      * Adds a listener to the cell editor of the attributes table.
1962      * @param listener CellEditorListener; listener to the cell editor of the attributes table.
1963      */
1964     public void addAttributeCellEditorListener(final CellEditorListener listener)
1965     {
1966         this.attributesTable.getDefaultEditor(String.class).addCellEditorListener(listener);
1967     }
1968 
1969     /**
1970      * Sets a node in the clipboard.
1971      * @param clipboard XsdTreeNode; node to set in the clipboard.
1972      * @param cut boolean; whether the node was cut.
1973      */
1974     public void setClipboard(final XsdTreeNode clipboard, final boolean cut)
1975     {
1976         this.clipboard = clipboard;
1977         this.cut = cut;
1978     }
1979 
1980     /**
1981      * Returns the clipboard node.
1982      * @return XsdTreeNode; clipboard node.
1983      */
1984     public XsdTreeNode getClipboard()
1985     {
1986         return this.clipboard;
1987     }
1988 
1989     /**
1990      * Remove node that was cut.
1991      */
1992     public void removeClipboardWhenCut()
1993     {
1994         if (this.clipboard != null && this.cut)
1995         {
1996             if (this.clipboard.isRemovable())
1997             {
1998                 this.clipboard.remove();
1999             }
2000             this.clipboard = null;
2001         }
2002     }
2003 
2004     /**
2005      * Returns the node actions.
2006      * @return NodeActions; node actions.
2007      */
2008     public NodeActions getNodeActions()
2009     {
2010         return this.nodeActions;
2011     }
2012 
2013     /** {@inheritDoc} */
2014     @Override
2015     public boolean addListener(final EventListener listener, final EventType eventType)
2016     {
2017         return Try.assign(() -> EventProducer.super.addListener(listener, eventType),
2018                 "Local event producer should not give a RemoteException.");
2019     }
2020 
2021     /**
2022      * Return an evaluator to evaluate expression values. This evaluator uses the input parameters of the currently selected
2023      * scenario.
2024      * @return Eval; evaluator to evaluate expression values.
2025      */
2026     public Eval getEval()
2027     {
2028         try
2029         {
2030             return this.evalWrapper.getEval(OtsEditor.this.scenario.getItemAt(OtsEditor.this.scenario.getSelectedIndex()));
2031         }
2032         catch (CircularDependencyException ex)
2033         {
2034             showCircularInputParameters();
2035             return this.evalWrapper.getLastValidEval();
2036         }
2037         catch (RuntimeException ex)
2038         {
2039             // some parameters are not valid
2040             return this.evalWrapper.getLastValidEval();
2041         }
2042     }
2043 
2044     /**
2045      * Adds listener to changes in the evaluator, i.e. added, removed or changed input parameters.
2046      * @param listener EvalListener; listener.
2047      */
2048     public void addEvalListener(final EvalListener listener)
2049     {
2050         this.evalWrapper.addListener(listener);
2051     }
2052 
2053     /**
2054      * Removes listener to changes in the evaluator, i.e. added, removed or changed input parameters.
2055      * @param listener EvalListener; listener.
2056      */
2057     public void removeEvalListener(final EvalListener listener)
2058     {
2059         this.evalWrapper.removeListener(listener);
2060     }
2061 
2062     /** {@inheritDoc} */
2063     @Override
2064     public void setAppearance(final Appearance appearance)
2065     {
2066         super.setAppearance(appearance);
2067         // these components are hidden from the Swing structure
2068         if (this.treeTable != null)
2069         {
2070             changeFont((Component) this.treeTable.getTree().getCellRenderer(), appearance.getFont());
2071             changeFontSize((Component) this.treeTable.getTree().getCellRenderer());
2072         }
2073     }
2074 
2075 }