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