java/src/jmri/jmrit/timetable/swing/TimeTableFrame.java

Summary

Maintainability
F
3 wks
Test Coverage
B
86%
package jmri.jmrit.timetable.swing;

import java.awt.*;
import java.awt.event.*;
import java.io.File;
import java.io.IOException;
import java.text.NumberFormat;
import java.text.ParseException;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

import javax.swing.*;
import javax.swing.colorchooser.AbstractColorChooserPanel;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.filechooser.FileNameExtensionFilter;
import javax.swing.tree.*;

import jmri.InstanceManager;
import jmri.Scale;
import jmri.ScaleManager;
import jmri.jmrit.operations.trains.tools.ExportTimetable;
import jmri.jmrit.timetable.*;
import jmri.jmrit.timetable.configurexml.TimeTableXml;
import jmri.util.JmriJFrame;
import jmri.util.swing.SplitButtonColorChooserPanel;
import jmri.util.swing.JmriJOptionPane;

/**
 * Create and maintain timetables.
 * <p>
 * A timetable describes the layout and trains along with the times that each train should be at specified locations.
 *
 *   Logical Schema
 * Layout
 *    Train Types
 *    Segments
 *        Stations
 *    Schedules
 *        Trains
 *           Stops
 *
 * @author Dave Sand Copyright (c) 2018
 */
public class TimeTableFrame extends jmri.util.JmriJFrame {

    public static final String EMPTY_GRID = "EmptyGrid";

    public TimeTableFrame() {
    }

    public TimeTableFrame(String tt) {
        super(true, true);
        setTitle(Bundle.getMessage("TitleTimeTable"));  // NOI18N
        InstanceManager.setDefault(TimeTableFrame.class, this);
        _dataMgr = TimeTableDataManager.getDataManager();
        buildComponents();
        createFrame();
        createMenu();
        setEditMode(false);
        setShowReminder(false);
    }

    TimeTableDataManager _dataMgr;
    boolean _isDirty = false;
    boolean _showTrainTimes = false;
    boolean _twoPage = false;

    // ------------ Tree variables ------------
    JTree _timetableTree;
    DefaultTreeModel _timetableModel;
    DefaultMutableTreeNode _timetableRoot;
    TreeSelectionListener _timetableListener;
    TreePath _curTreePath = null;

    // ------------ Tree components ------------
    TimeTableTreeNode _layoutNode = null;
    TimeTableTreeNode _typeHead = null;
    TimeTableTreeNode _typeNode = null;
    TimeTableTreeNode _segmentHead = null;
    TimeTableTreeNode _segmentNode = null;
    TimeTableTreeNode _stationNode = null;
    TimeTableTreeNode _scheduleHead = null;
    TimeTableTreeNode _scheduleNode = null;
    TimeTableTreeNode _trainNode = null;
    TimeTableTreeNode _stopNode = null;
    TimeTableTreeNode _leafNode = null;

    // ------------ Current tree node variables ------------
    TimeTableTreeNode _curNode = null;
    int _curNodeId = 0;
    String _curNodeType = null;
    String _curNodeText = null;
    int _curNodeRow = -1;

    // ------------ Edit detail components ------------
    JPanel _detailGrid = new JPanel();
    JPanel _detailFooter = new JPanel();
    JPanel _gridPanel;  // Child of _detailGrid, contains the current grid labels and fields
    boolean _editActive = false;
    JButton _cancelAction;
    JButton _updateAction;

    // Layout
    JTextField _editLayoutName;
    JComboBox<Scale> _editScale;
    JTextField _editFastClock;
    JTextField _editThrottles;
    JCheckBox _editMetric;
    JLabel _showScaleMK;

    // TrainType
    JTextField _editTrainTypeName;
    JColorChooser _editTrainTypeColor;

    // Segment
    JTextField _editSegmentName;

    // Station
    JTextField _editStationName;
    JTextField _editDistance;
    JCheckBox _editDoubleTrack;
    JSpinner _editSidings;
    JSpinner _editStaging;

    // Schedule
    JTextField _editScheduleName;
    JTextField _editEffDate;
    JSpinner _editStartHour;
    JSpinner _editDuration;

    // Train
    JTextField _editTrainName;
    JTextField _editTrainDesc;
    JComboBox<TrainType> _editTrainType;
    JTextField _editDefaultSpeed;
    JTextField _editTrainStartTime;
    JSpinner _editThrottle;
    JTextArea _editTrainNotes;
    JLabel _showRouteDuration;

    // Stop
    JLabel _showStopSeq;
    JComboBox<TimeTableDataManager.SegmentStation> _editStopStation;
    JTextField _editStopDuration;
    JTextField _editNextSpeed;
    JSpinner _editStagingTrack;
    JTextArea _editStopNotes;
    JLabel _showArriveTime;
    JLabel _showDepartTime;

    // ------------ Button bar components ------------
    JPanel _leftButtonBar;
    JPanel _addButtonPanel;
    JPanel _duplicateButtonPanel;
    JPanel _copyButtonPanel;
    JPanel _deleteButtonPanel;
    JPanel _moveButtonPanel;
    JPanel _graphButtonPanel;
    JButton _addButton = new JButton();
    JButton _duplicateButton = new JButton();
    JButton _copyButton = new JButton();
    JButton _deleteButton = new JButton();
    JButton _displayButton = new JButton();
    JButton _printButton = new JButton();
    JButton _saveButton = new JButton();

    // ------------ Create Panel and components ------------

    /**
     * Create the main Timetable Window
     * The left side contains the timetable tree.
     * The right side contains the current edit grid.
     */
    private void createFrame() {
        Container contentPane = getContentPane();
        contentPane.setLayout(new BorderLayout());

        // ------------ Body - tree (left side) ------------
        JTree treeContent = buildTree();
        JScrollPane treeScroll = new JScrollPane(treeContent);

        // ------------ Body - detail (right side) ------------
        JPanel detailPane = new JPanel();
        detailPane.setBorder(BorderFactory.createMatteBorder(0, 2, 0, 0, Color.DARK_GRAY));
        detailPane.setLayout(new BoxLayout(detailPane, BoxLayout.Y_AXIS));

        // ------------ Edit Detail Panel ------------
        makeDetailGrid(EMPTY_GRID);  // NOI18N

        JPanel panel = new JPanel();
        panel.setLayout(new BoxLayout(panel, BoxLayout.X_AXIS));

        _cancelAction = new JButton(Bundle.getMessage("ButtonCancel"));  // NOI18N
        _cancelAction.setToolTipText(Bundle.getMessage("HintCancelButton"));  // NOI18N
        panel.add(_cancelAction);
        _cancelAction.addActionListener((ActionEvent e) -> cancelPressed());
        panel.add(Box.createHorizontalStrut(10));

        _updateAction = new JButton(Bundle.getMessage("ButtonUpdate"));  // NOI18N
        _updateAction.setToolTipText(Bundle.getMessage("HintUpdateButton"));  // NOI18N
        panel.add(_updateAction);
        _updateAction.addActionListener((ActionEvent e) -> updatePressed());
        _detailFooter.add(panel);

        JPanel detailEdit = new JPanel(new BorderLayout());
        detailEdit.add(_detailGrid, BorderLayout.NORTH);
        detailEdit.add(_detailFooter, BorderLayout.SOUTH);
        detailPane.add(detailEdit);

        JSplitPane bodyPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, treeScroll, detailPane);
        bodyPane.setDividerSize(10);
        bodyPane.setResizeWeight(.35);
        bodyPane.setOneTouchExpandable(true);
        contentPane.add(bodyPane);

        // ------------ Footer ------------
        JPanel footer = new JPanel(new BorderLayout());
        _leftButtonBar = new JPanel();

        // ------------ Add Button ------------
        _addButton = new JButton(Bundle.getMessage("AddLayoutButtonText"));    // NOI18N
        _addButton.setToolTipText(Bundle.getMessage("HintAddButton"));       // NOI18N
        _addButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                addPressed();
            }
        });
        _addButtonPanel = new JPanel();
        _addButtonPanel.add(_addButton);
        _leftButtonBar.add(_addButtonPanel);

        // ------------ Duplicate Button ------------
        _duplicateButton = new JButton(Bundle.getMessage("DuplicateLayoutButtonText"));    // NOI18N
        _duplicateButton.setToolTipText(Bundle.getMessage("HintDuplicateButton"));       // NOI18N
        _duplicateButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                duplicatePressed();
            }
        });
        _duplicateButtonPanel = new JPanel();
        _duplicateButtonPanel.add(_duplicateButton);
        _leftButtonBar.add(_duplicateButtonPanel);

        // ------------ Copy Button ------------
        _copyButton = new JButton(Bundle.getMessage("CopyStopsButton"));    // NOI18N
        _copyButton.setToolTipText(Bundle.getMessage("HintCopyButton"));       // NOI18N
        _copyButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                copyPressed();
            }
        });
        _copyButtonPanel = new JPanel();
        _copyButtonPanel.add(_copyButton);
        _leftButtonBar.add(_copyButtonPanel);

        // ------------ Delete Button ------------
        _deleteButton = new JButton(Bundle.getMessage("DeleteLayoutButtonText")); // NOI18N
        _deleteButton.setToolTipText(Bundle.getMessage("HintDeleteButton"));    // NOI18N
        _deleteButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                deletePressed();
            }
        });
        _deleteButtonPanel = new JPanel();
        _deleteButtonPanel.add(_deleteButton);
        _deleteButtonPanel.setVisible(false);
        _leftButtonBar.add(_deleteButtonPanel);

        // ------------ Move Buttons ------------
        JLabel moveLabel = new JLabel(Bundle.getMessage("LabelMove"));      // NOI18N

        JButton upButton = new JButton(Bundle.getMessage("ButtonUp"));      // NOI18N
        upButton.setToolTipText(Bundle.getMessage("HintUpButton"));         // NOI18N
        JButton downButton = new JButton(Bundle.getMessage("ButtonDown"));  // NOI18N
        downButton.setToolTipText(Bundle.getMessage("HintDownButton"));     // NOI18N

        upButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                downButton.setEnabled(false);
                upButton.setEnabled(false);
                upPressed();
            }
        });

        downButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                upButton.setEnabled(false);
                downButton.setEnabled(false);
                downPressed();
            }
        });

        _moveButtonPanel = new JPanel();
        _moveButtonPanel.add(moveLabel);
        _moveButtonPanel.add(upButton);
        _moveButtonPanel.add(new JLabel("|"));
        _moveButtonPanel.add(downButton);
        _moveButtonPanel.setVisible(false);
        _leftButtonBar.add(_moveButtonPanel);

        // ------------ Graph Buttons ------------
        JLabel graphLabel = new JLabel(Bundle.getMessage("LabelGraph"));      // NOI18N

        _displayButton = new JButton(Bundle.getMessage("ButtonDisplay"));  // NOI18N
        _displayButton.setToolTipText(Bundle.getMessage("HintDisplayButton"));     // NOI18N
        _displayButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                graphPressed("Display");  // NOI18N
            }
        });

        _printButton = new JButton(Bundle.getMessage("ButtonPrint"));  // NOI18N
        _printButton.setToolTipText(Bundle.getMessage("HintPrintButton"));     // NOI18N
        _printButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                graphPressed("Print");  // NOI18N
            }
        });

        _graphButtonPanel = new JPanel();
        _graphButtonPanel.add(graphLabel);
        _graphButtonPanel.add(_displayButton);
        _graphButtonPanel.add(new JLabel("|"));
        _graphButtonPanel.add(_printButton);
        _leftButtonBar.add(_graphButtonPanel);

        footer.add(_leftButtonBar, BorderLayout.WEST);
        JPanel rightButtonBar = new JPanel();

        // ------------ Save Button ------------
        _saveButton = new JButton(Bundle.getMessage("ButtonSave"));  // NOI18N
        _saveButton.setToolTipText(Bundle.getMessage("HintSaveButton"));     // NOI18N
        _saveButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                savePressed();
            }
        });
        JPanel saveButtonPanel = new JPanel();
        saveButtonPanel.add(_saveButton);
        rightButtonBar.add(saveButtonPanel);

        // ------------ Done Button ------------
        JButton doneButton = new JButton(Bundle.getMessage("ButtonDone"));  // NOI18N
        doneButton.setToolTipText(Bundle.getMessage("HintDoneButton"));     // NOI18N
        doneButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                donePressed();
            }
        });
        JPanel doneButtonPanel = new JPanel();
        doneButtonPanel.add(doneButton);
        rightButtonBar.add(doneButtonPanel);

        footer.add(rightButtonBar, BorderLayout.EAST);
        contentPane.add(footer, BorderLayout.SOUTH);

        addWindowListener(new java.awt.event.WindowAdapter() {
            @Override
            public void windowClosing(java.awt.event.WindowEvent e) {
                donePressed();
            }
        });
        setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);

        pack();
        _addButtonPanel.setVisible(false);
        _duplicateButtonPanel.setVisible(false);
        _copyButtonPanel.setVisible(false);
        _deleteButtonPanel.setVisible(false);
        _graphButtonPanel.setVisible(false);
    }

    /**
     * Create a Options/Tools menu.
     * - Option: Show train times on the graph.
     * - Option: Enable two page graph printing.
     * - Tool: Import a SchedGen data file.
     * - Tool: Import a CSV data file.
     * - Tool: Export a CSV data file.
     * Include the standard Windows and Help menu bar items.
     */
    void createMenu() {
        _showTrainTimes = InstanceManager.getDefault(jmri.UserPreferencesManager.class).
                getSimplePreferenceState("jmri.jmrit.timetable:TrainTimes");      // NOI18N

        JCheckBoxMenuItem trainTime = new JCheckBoxMenuItem(Bundle.getMessage("MenuTrainTimes"));  // NOI18N
        trainTime.setSelected(_showTrainTimes);
        trainTime.addActionListener((ActionEvent event) -> {
            _showTrainTimes = trainTime.isSelected();
            InstanceManager.getDefault(jmri.UserPreferencesManager.class).
                    setSimplePreferenceState("jmri.jmrit.timetable:TrainTimes", _showTrainTimes);  // NOI18N
        });

        _twoPage = InstanceManager.getDefault(jmri.UserPreferencesManager.class).
                getSimplePreferenceState("jmri.jmrit.timetable:TwoPage");      // NOI18N

        JCheckBoxMenuItem twoPage = new JCheckBoxMenuItem(Bundle.getMessage("MenuTwoPage"));  // NOI18N
        twoPage.setSelected(_twoPage);
        twoPage.addActionListener((ActionEvent event) -> {
            _twoPage = twoPage.isSelected();
            InstanceManager.getDefault(jmri.UserPreferencesManager.class).
                    setSimplePreferenceState("jmri.jmrit.timetable:TwoPage", _twoPage);  // NOI18N
        });

        JMenuItem impsgn = new JMenuItem(Bundle.getMessage("MenuImportSgn"));  // NOI18N
        impsgn.addActionListener((ActionEvent event) -> importPressed());

        JMenuItem impcsv = new JMenuItem(Bundle.getMessage("MenuImportCsv"));  // NOI18N
        impcsv.addActionListener((ActionEvent event) -> importCsvPressed());

        JMenuItem impopr = new JMenuItem(Bundle.getMessage("MenuImportOperations"));  // NOI18N
        impopr.addActionListener((ActionEvent event) -> importFromOperationsPressed());

        JMenuItem expcsv = new JMenuItem(Bundle.getMessage("MenuExportCsv"));  // NOI18N
        expcsv.addActionListener((ActionEvent event) -> exportCsvPressed());

        JMenu ttMenu = new JMenu(Bundle.getMessage("MenuTimetable"));  // NOI18N
        ttMenu.add(trainTime);
        ttMenu.addSeparator();
        ttMenu.add(twoPage);
        ttMenu.addSeparator();
        ttMenu.add(impsgn);
        ttMenu.add(impcsv);
        ttMenu.add(impopr);
        ttMenu.add(expcsv);

        JMenuBar menuBar = new JMenuBar();
        menuBar.add(ttMenu);
        setJMenuBar(menuBar);

        //setup Help menu
        addHelpMenu("html.tools.TimeTable", true);  // NOI18N
    }

    /**
     * Initialize components.
     * Add Focus and Change listeners to activate edit mode.
     * Create the color selector for train types.
     */
    void buildComponents() {
        // Layout
        _editLayoutName = new JTextField(20);
        _editScale = new JComboBox<>();
        _editScale.addItemListener(layoutScaleItemEvent);
        _editFastClock = new JTextField(5);
        _editThrottles = new JTextField(5);
        _editMetric = new JCheckBox();
        _showScaleMK = new JLabel();

        _editLayoutName.addFocusListener(detailFocusEvent);
        _editScale.addFocusListener(detailFocusEvent);
        _editFastClock.addFocusListener(detailFocusEvent);
        _editThrottles.addFocusListener(detailFocusEvent);
        _editMetric.addChangeListener(detailChangeEvent);

        // TrainType
        _editTrainTypeName = new JTextField(20);
        _editTrainTypeColor = new JColorChooser(Color.BLACK);
        _editTrainTypeColor.setPreviewPanel(new JPanel()); // remove the preview panel
        AbstractColorChooserPanel[] editTypeColorPanels = {new SplitButtonColorChooserPanel()};
        _editTrainTypeColor.setChooserPanels(editTypeColorPanels);

        _editTrainTypeName.addFocusListener(detailFocusEvent);
        _editTrainTypeColor.getSelectionModel().addChangeListener(detailChangeEvent);

        // Segment
        _editSegmentName = new JTextField(20);

        _editSegmentName.addFocusListener(detailFocusEvent);

        // Station
        _editStationName = new JTextField(20);
        _editDistance = new JTextField(5);
        _editDoubleTrack = new JCheckBox();
        _editSidings = new JSpinner(new SpinnerNumberModel(0, 0, null, 1));
        _editStaging = new JSpinner(new SpinnerNumberModel(0, 0, null, 1));

        _editStationName.addFocusListener(detailFocusEvent);
        _editDistance.addFocusListener(detailFocusEvent);
        _editDoubleTrack.addChangeListener(detailChangeEvent);
        _editSidings.addChangeListener(detailChangeEvent);
        _editStaging.addChangeListener(detailChangeEvent);

        // Schedule
        _editScheduleName = new JTextField(20);
        _editEffDate = new JTextField(10);
        _editStartHour = new JSpinner(new SpinnerNumberModel(0, 0, 23, 1));
        _editDuration = new JSpinner(new SpinnerNumberModel(24, 1, 24, 1));

        _editScheduleName.addFocusListener(detailFocusEvent);
        _editEffDate.addFocusListener(detailFocusEvent);
        _editStartHour.addChangeListener(detailChangeEvent);
        _editDuration.addChangeListener(detailChangeEvent);

        // Train
        _editTrainName = new JTextField(10);
        _editTrainDesc = new JTextField(20);
        _editTrainType = new JComboBox<>();
        _editDefaultSpeed = new JTextField(5);
        _editTrainStartTime = new JTextField(5);
        _editThrottle = new JSpinner(new SpinnerNumberModel(0, 0, null, 1));
        _editTrainNotes = new JTextArea(4, 30);
        _showRouteDuration = new JLabel();

        _editTrainName.addFocusListener(detailFocusEvent);
        _editTrainDesc.addFocusListener(detailFocusEvent);
        _editTrainType.addFocusListener(detailFocusEvent);
        _editDefaultSpeed.addFocusListener(detailFocusEvent);
        _editTrainStartTime.addFocusListener(detailFocusEvent);
        _editThrottle.addChangeListener(detailChangeEvent);
        _editTrainNotes.addFocusListener(detailFocusEvent);

        // Stop
        _showStopSeq = new JLabel();
        _editStopStation = new JComboBox<>();
        _editStopDuration = new JTextField(5);
        _editNextSpeed = new JTextField(5);
        _editStagingTrack = new JSpinner(new SpinnerNumberModel(0, 0, null, 1));
        _editStopNotes = new JTextArea(4, 30);
        _showArriveTime = new JLabel();
        _showDepartTime = new JLabel();

        _editStopStation.addFocusListener(detailFocusEvent);
        _editStopStation.addItemListener(stopStationItemEvent);
        _editStopDuration.addFocusListener(detailFocusEvent);
        _editNextSpeed.addFocusListener(detailFocusEvent);
        _editStagingTrack.addChangeListener(detailChangeEvent);
        _editStopNotes.addFocusListener(detailFocusEvent);
    }

    /**
     * Enable edit mode.  Used for JTextFields and JComboBoxs.
     */
    transient FocusListener detailFocusEvent = new FocusListener() {
        @Override
        public void focusGained(FocusEvent e) {
            if (!_editActive) {
                setEditMode(true);
            }
        }

        @Override
        public void focusLost(FocusEvent e) {
        }
    };

    /**
     * Enable edit mode.  Used for JCheckBoxs, JSpinners and JColorChoosers.
     */
    transient ChangeListener detailChangeEvent = new ChangeListener() {
        @Override
        public void stateChanged(ChangeEvent e) {
            if (!_editActive) {
                setEditMode(true);
            }
        }
    };

    /**
     * Change the max spinner value based on the station data.
     * The number of staging tracks varies depending on the selected station.
     */
    transient ItemListener stopStationItemEvent = new ItemListener() {
        @Override
        public void itemStateChanged(ItemEvent e) {
            if (e.getStateChange() == ItemEvent.SELECTED) {
                TimeTableDataManager.SegmentStation segmentStation = (TimeTableDataManager.SegmentStation) e.getItem();
                int stagingTracks = _dataMgr.getStation(segmentStation.getStationId()).getStaging();
                Stop stop = _dataMgr.getStop(_curNodeId);
                if (stop.getStagingTrack() <= stagingTracks) {
                    _editStagingTrack.setModel(new SpinnerNumberModel(stop.getStagingTrack(), 0, stagingTracks, 1));
                }
            }
        }
    };

    /**
     * If the custom scale item is selected provide a dialog to set the scale ratio
     */
    transient ItemListener layoutScaleItemEvent = new ItemListener() {
        @Override
        public void itemStateChanged(ItemEvent e) {
            if (e.getStateChange() == ItemEvent.SELECTED) {
                if (_editScale.hasFocus()) {
                    Scale scale = (Scale) _editScale.getSelectedItem();
                    if (scale.getScaleName().equals("CUSTOM")) {  // NOI18N
                        String ans = JmriJOptionPane.showInputDialog( _editScale,
                                Bundle.getMessage("ScaleRatioChange"),  // NOI18N
                                String.valueOf(scale.getScaleRatio())
                                );
                        if (ans != null) {
                            try {
                                double newRatio = Double.parseDouble(ans);
                                scale.setScaleRatio(newRatio);
                            } catch (java.lang.IllegalArgumentException
                                    | java.beans.PropertyVetoException ex) {
                                log.warn("Unable to change custom ratio: {}", ex.getMessage());  // NOI18N
                                JmriJOptionPane.showMessageDialog( _editScale,
                                        Bundle.getMessage("NumberFormatError", ans, "Custom ratio"),  // NOI18N
                                        Bundle.getMessage("WarningTitle"),  // NOI18N
                                        JmriJOptionPane.WARNING_MESSAGE);
                                Layout layout = _dataMgr.getLayout(_curNodeId);
                                _editScale.setSelectedItem(layout.getScale());
                            }
                        }
                    }
                }
            }
        }
    };

    // ------------ Create GridBag panels ------------

    /**
     * Build new GridBag content. The grid panel is hidden, emptied, re-built and
     * made visible.
     *
     * @param gridType The type of grid to create
     */
    void makeDetailGrid(String gridType) {
        _detailGrid.setVisible(false);
        _detailGrid.removeAll();
        _detailFooter.setVisible(true);

        _gridPanel = new JPanel(new GridBagLayout());
        GridBagConstraints c = new GridBagConstraints();
        c.gridwidth = 1;
        c.gridheight = 1;
        c.ipadx = 5;

        switch (gridType) {
            case EMPTY_GRID:  // NOI18N
                makeEmptyGrid(c);
                _detailFooter.setVisible(false);
                break;

            case "Layout":  // NOI18N
                makeLayoutGrid(c);
                break;

            case "TrainType":  // NOI18N
                makeTrainTypeGrid(c);
                break;

            case "Segment":  // NOI18N
                makeSegmentGrid(c);
                break;

            case "Station":  // NOI18N
                makeStationGrid(c);
                break;

            case "Schedule":  // NOI18N
                makeScheduleGrid(c);
                break;

            case "Train":  // NOI18N
                makeTrainGrid(c);
                break;

            case "Stop":  // NOI18N
                makeStopGrid(c);
                break;

            default:
                log.warn("Invalid grid type: '{}'", gridType);  // NOI18N
                makeEmptyGrid(c);
        }

        _detailGrid.add(_gridPanel);
        _detailGrid.setVisible(true);
    }

    /**
     * This grid is used when there are no edit grids required.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeEmptyGrid(GridBagConstraints c) {
        // Variable type box
        c.gridy = 0;
        c.gridx = 0;
        c.anchor = java.awt.GridBagConstraints.CENTER;
        JLabel rowLabel = new JLabel(Bundle.getMessage("LabelBlank"));  // NOI18N
        _gridPanel.add(rowLabel, c);
    }

    /**
     * This grid is used to edit Layout data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeLayoutGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelLayoutName", "HintLayoutName", c);  // NOI18N
        _gridPanel.add(_editLayoutName, c);

        makeGridLabel(1, "LabelScale", "HintScale", c);  // NOI18N
        _gridPanel.add(_editScale, c);

        makeGridLabel(2, "LabelFastClock", "HintFastClock", c);  // NOI18N
        _gridPanel.add(_editFastClock, c);

        makeGridLabel(3, "LabelThrottles", "HintThrottles", c);  // NOI18N
        _gridPanel.add(_editThrottles, c);

        makeGridLabel(4, "LabelMetric", "HintMetric", c);  // NOI18N
        _gridPanel.add(_editMetric, c);

        makeGridLabel(5, "LabelScaleMK", "HintScaleMK", c);  // NOI18N
        _gridPanel.add(_showScaleMK, c);
    }

    /**
     * This grid is used to edit the Train Type data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeTrainTypeGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelTrainTypeName", "HintTrainTypeName", c);  // NOI18N
        _gridPanel.add(_editTrainTypeName, c);

        makeGridLabel(1, "LabelTrainTypeColor", "HintTrainTypeColor", c);  // NOI18N
        _gridPanel.add(_editTrainTypeColor, c);
    }

    /**
     * This grid is used to edit the Segment data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeSegmentGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelSegmentName", "HintSegmentName", c);  // NOI18N
        _gridPanel.add(_editSegmentName, c);
    }

    /**
     * This grid is used to edit the Station data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeStationGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelStationName", "HintStationName", c);  // NOI18N
        _gridPanel.add(_editStationName, c);

        makeGridLabel(1, "LabelDistance", "HintDistance", c);  // NOI18N
        _gridPanel.add(_editDistance, c);

        makeGridLabel(2, "LabelDoubleTrack", "HintDoubleTrack", c);  // NOI18N
        _gridPanel.add(_editDoubleTrack, c);

        makeGridLabel(3, "LabelSidings", "HintSidings", c);  // NOI18N
        _gridPanel.add(_editSidings, c);

        makeGridLabel(4, "LabelStaging", "HintStaging", c);  // NOI18N
        _gridPanel.add(_editStaging, c);
    }

    /**
     * This grid is used to edit the Schedule data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeScheduleGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelScheduleName", "HintScheduleName", c);  // NOI18N
        _gridPanel.add(_editScheduleName, c);

        makeGridLabel(1, "LabelEffDate", "HintEffDate", c);  // NOI18N
        _gridPanel.add(_editEffDate, c);

        makeGridLabel(2, "LabelStartHour", "HintStartHour", c);  // NOI18N
        _gridPanel.add(_editStartHour, c);

        makeGridLabel(3, "LabelDuration", "HintDuration", c);  // NOI18N
        _gridPanel.add(_editDuration, c);
    }

    /**
     * This grid is used to edit the Train data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeTrainGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelTrainName", "HintTrainName", c);  // NOI18N
        _gridPanel.add(_editTrainName, c);

        makeGridLabel(1, "LabelTrainDesc", "HintTrainDesc", c);  // NOI18N
        _gridPanel.add(_editTrainDesc, c);

        makeGridLabel(2, "LabelTrainType", "HintTrainType", c);  // NOI18N
        _gridPanel.add(_editTrainType, c);

        makeGridLabel(3, "LabelDefaultSpeed", "HintDefaultSpeed", c);  // NOI18N
        _gridPanel.add(_editDefaultSpeed, c);

        makeGridLabel(4, "LabelTrainStartTime", "HintTrainStartTime", c);  // NOI18N
        _gridPanel.add(_editTrainStartTime, c);

        makeGridLabel(5, "LabelThrottle", "HintThrottle", c);  // NOI18N
        _gridPanel.add(_editThrottle, c);

        makeGridLabel(6, "LabelRouteDuration", "HintRouteDuration", c);  // NOI18N
        _gridPanel.add(_showRouteDuration, c);

        makeGridLabel(7, "LabelTrainNotes", "HintTrainNotes", c);  // NOI18N
        _gridPanel.add(_editTrainNotes, c);
    }

    /**
     * This grid is used to edit the Stop data.
     *
     * @param c The constraints object used for the grid construction
     */
    void makeStopGrid(GridBagConstraints c) {
        makeGridLabel(0, "LabelStopSeq", "HintStopSeq", c);  // NOI18N
        _gridPanel.add(_showStopSeq, c);

        makeGridLabel(1, "LabelStopStation", "HintStopStation", c);  // NOI18N
        _gridPanel.add(_editStopStation, c);

        makeGridLabel(2, "LabelStopDuration", "HintStopDuration", c);  // NOI18N
        _gridPanel.add(_editStopDuration, c);

        makeGridLabel(3, "LabelNextSpeed", "HintNextSpeed", c);  // NOI18N
        _gridPanel.add(_editNextSpeed, c);

        makeGridLabel(4, "LabelStagingTrack", "HintStagingTrack", c);  // NOI18N
        _gridPanel.add(_editStagingTrack, c);

        makeGridLabel(5, "LabelArriveTime", "HintArriveTime", c);  // NOI18N
        _gridPanel.add(_showArriveTime, c);

        makeGridLabel(6, "LabelDepartTime", "HintDepartTime", c);  // NOI18N
        _gridPanel.add(_showDepartTime, c);

        makeGridLabel(7, "LabelStopNotes", "HintStopNotes", c);  // NOI18N
        _gridPanel.add(_editStopNotes, c);
    }

    /**
     * Create the label portion of a grid row.
     * @param row The grid row number.
     * @param label The bundle key for the label text.
     * @param hint The bundle key for the label tool tip.
     * @param c The grid bag contraints object.
     */
    void makeGridLabel(int row, String label, String hint, GridBagConstraints c) {
        c.gridy = row;
        c.gridx = 0;
        c.anchor = java.awt.GridBagConstraints.EAST;
        JLabel rowLabel = new JLabel(Bundle.getMessage(label));
        rowLabel.setToolTipText(Bundle.getMessage(hint));
        _gridPanel.add(rowLabel, c);
        c.gridx = 1;
        c.anchor = java.awt.GridBagConstraints.WEST;
    }

    // ------------ Process button bar and tree events ------------

    /**
     * Add new items.
     */
    void addPressed() {
        switch (_curNodeType) {
            case "Layout":     // NOI18N
                addLayout();
                break;

            case "TrainTypes": // NOI18N
                addTrainType();
                break;

            case "Segments":   // NOI18N
                addSegment();
                break;

            case "Segment":    // NOI18N
                addStation();
                break;

            case "Schedules":  // NOI18N
                addSchedule();
                break;

            case "Schedule":   // NOI18N
                addTrain();
                break;

            case "Train":      // NOI18N
                addStop();
                break;

            default:
                log.error("Add called for unsupported node type: '{}'", _curNodeType);  // NOI18N
        }
    }

    /**
     * Create a new Layout object with default values.
     * Add the layout node and the TrainTypes, Segments and Schedules collection nodes.
     */
    void addLayout() {
        Layout newLayout = new Layout();
        setShowReminder(true);

        // Build tree components
        _curNode = new TimeTableTreeNode(newLayout.getLayoutName(), "Layout", newLayout.getLayoutId(), 0);    // NOI18N
        _timetableRoot.add(_curNode);
        _leafNode = new TimeTableTreeNode(buildNodeText("TrainTypes", null, 0), "TrainTypes", 0, 0);    // NOI18N
        _curNode.add(_leafNode);
        _leafNode = new TimeTableTreeNode(buildNodeText("Segments", null, 0), "Segments", 0, 0);    // NOI18N
        _curNode.add(_leafNode);
        _leafNode = new TimeTableTreeNode(buildNodeText("Schedules", null, 0), "Schedules", 0, 0);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_timetableRoot);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_curNode.getPath()));
    }

    /**
     * Create a new Train Type object.
     * The default color is black.
     */
    void addTrainType() {
        TimeTableTreeNode layoutNode = (TimeTableTreeNode) _curNode.getParent();
        int layoutId = layoutNode.getId();
        TrainType newType = new TrainType(layoutId);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(newType.getTypeName(), "TrainType", newType.getTypeId(), 0);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_curNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a new Segment object with default values.
     */
    void addSegment() {
        TimeTableTreeNode layoutNode = (TimeTableTreeNode) _curNode.getParent();
        int layoutId = layoutNode.getId();
        Segment newSegment = new Segment(layoutId);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(newSegment.getSegmentName(), "Segment", newSegment.getSegmentId(), 0);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_curNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a new Station object with default values.
     */
    void addStation() {
        Station newStation = new Station(_curNodeId);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(newStation.getStationName(), "Station", newStation.getStationId(), 0);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_curNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a new Schedule object with default values.
     */
    void addSchedule() {
        TimeTableTreeNode layoutNode = (TimeTableTreeNode) _curNode.getParent();
        int layoutId = layoutNode.getId();
        Schedule newSchedule = new Schedule(layoutId);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(newSchedule.getScheduleName(), "Schedule", newSchedule.getScheduleId(), 0);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_curNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    void addTrain() {
        Train newTrain = new Train(_curNodeId);
        newTrain.setStartTime(_dataMgr.getSchedule(_curNodeId).getStartHour() * 60);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(newTrain.getTrainName(), "Train", newTrain.getTrainId(), 0);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_curNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    void addStop() {
        int newSeq = _dataMgr.getStops(_curNodeId, 0, false).size();
        Stop newStop = new Stop(_curNodeId, newSeq + 1);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(String.valueOf(newSeq + 1), "Stop", newStop.getStopId(), newSeq + 1);    // NOI18N
        _curNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(_curNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Duplicate selected item.
     */
    void duplicatePressed() {
        _dataMgr.setLockCalculate(true);
        switch (_curNodeType) {
            case "Layout":     // NOI18N
                duplicateLayout(_curNodeId);
                break;

            case "TrainType": // NOI18N
                duplicateTrainType(0, _curNodeId, (TimeTableTreeNode) _curNode.getParent());
                break;

            case "Segment":    // NOI18N
                duplicateSegment(0, _curNodeId,  (TimeTableTreeNode) _curNode.getParent());
                break;

            case "Station":    // NOI18N
                duplicateStation(0, _curNodeId, (TimeTableTreeNode) _curNode.getParent());
                break;

            case "Schedule":  // NOI18N
                duplicateSchedule(0, _curNodeId, (TimeTableTreeNode) _curNode.getParent());
                break;

            case "Train":   // NOI18N
                duplicateTrain(0, _curNodeId, 0, (TimeTableTreeNode) _curNode.getParent());
                break;

            case "Stop":      // NOI18N
                duplicateStop(0, _curNodeId, 0, 0, (TimeTableTreeNode) _curNode.getParent());
                break;

            default:
                log.error("Duplicate called for unsupported node type: '{}'", _curNodeType);  // NOI18N
        }
        _dataMgr.setLockCalculate(false);
    }

    // Trains have references to train types and stops have references to stations.
    // When a layout is copied, the references have to be changed to the copied element.
    private HashMap<Integer, Integer> typeMap = new HashMap<>();      // THe key is the source train type, the value is the destination train type.
    private HashMap<Integer, Integer> stationMap = new HashMap<>();   // THe key is the source layout stations, the value is the destination stations.

    private boolean dupLayout = false;

    /**
     * Create a copy of a layout.
     * @param layoutId The id of the layout to be duplicated.
     */
    void duplicateLayout(int layoutId) {
        dupLayout = true;
        Layout layout = _dataMgr.getLayout(layoutId);
        Layout newLayout = layout.getCopy();
        setShowReminder(true);

        // Build tree components
        _curNode = new TimeTableTreeNode(newLayout.getLayoutName(), "Layout", newLayout.getLayoutId(), 0);    // NOI18N
        _timetableRoot.add(_curNode);

        _leafNode = new TimeTableTreeNode(buildNodeText("TrainTypes", null, 0), "TrainTypes", 0, 0);    // NOI18N
        _curNode.add(_leafNode);
        var typesNode = _leafNode;

        _leafNode = new TimeTableTreeNode(buildNodeText("Segments", null, 0), "Segments", 0, 0);    // NOI18N
        _curNode.add(_leafNode);
        var segmentsNode = _leafNode;

        _leafNode = new TimeTableTreeNode(buildNodeText("Schedules", null, 0), "Schedules", 0, 0);    // NOI18N
        _curNode.add(_leafNode);
        var schedlulesNode = _leafNode;

        _timetableModel.nodeStructureChanged(_timetableRoot);


        // Copy train types
        typeMap.clear();
        for (var type : _dataMgr.getTrainTypes(layoutId, true)) {
            duplicateTrainType(newLayout.getLayoutId(), type.getTypeId(), typesNode);
        }

        // Copy segments
        stationMap.clear();
        for (var segment : _dataMgr.getSegments(layoutId, true)) {
            duplicateSegment(newLayout.getLayoutId(), segment.getSegmentId(), segmentsNode);
        }

        // schedules
        for (var schedule : _dataMgr.getSchedules(layoutId, true)) {
            duplicateSchedule(newLayout.getLayoutId(), schedule.getScheduleId(), schedlulesNode);
        }

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_curNode.getPath()));

        dupLayout = false;
    }

    /**
     * Create a copy of a train type.
     * @param layoutId The id for the parent layout.  Zero if within the same layout.
     * @param typeId The id of the train type to be duplicated.
     * @param typesNode The types node which will be parent for the new train type.
     */
    void duplicateTrainType(int layoutId, int typeId, TimeTableTreeNode typesNode) {
        TrainType type = _dataMgr.getTrainType(typeId);
        TrainType newType = type.getCopy(layoutId);
        setShowReminder(true);

        // If part of duplicating a layout, create a type map entry.
        if (dupLayout) {
            typeMap.put(type.getTypeId(), newType.getTypeId());
        }

        // Build tree components
        _leafNode = new TimeTableTreeNode(newType.getTypeName(), "TrainType", newType.getTypeId(), 0);    // NOI18N
        typesNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(typesNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a copy of a segment.
     * @param layoutId The id for the parent layout.  Zero if within the same layout.
     * @param segmentId The id of the segment to be duplicated.
     * @param segmentsNode The segments node which will be parent for the new segment.
     */
    void duplicateSegment(int layoutId, int segmentId, TimeTableTreeNode segmentsNode) {
        Segment segment = _dataMgr.getSegment(segmentId);
        Segment newSegment = segment.getCopy(layoutId);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(newSegment.getSegmentName(), "Segment", newSegment.getSegmentId(), 0);    // NOI18N
        segmentsNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(segmentsNode);

        // Duplicate the stations using the stations from the orignal segment
        var segmentNode = _leafNode;
        for (var station : _dataMgr.getStations(segmentId, true)) {
            duplicateStation(newSegment.getSegmentId(), station.getStationId(), segmentNode);
        }

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a copy of a station.
     * @param segmentId The id for the parent segment.  Zero if within the same segment.
     * @param stationId The id of the station to be duplicated.
     * @param segmentNode The segment node which will be parent for the new station.
     */
    void duplicateStation(int segmentId, int stationId, TimeTableTreeNode segmentNode) {
        Station station = _dataMgr.getStation(stationId);
        Station newStation = station.getCopy(segmentId);
        setShowReminder(true);

        // If part of duplicating a layout, create a station map entry.
        if (dupLayout) {
            stationMap.put(station.getStationId(), newStation.getStationId());
        }

        // Build tree components
        _leafNode = new TimeTableTreeNode(newStation.getStationName(), "Station", newStation.getStationId(), 0);    // NOI18N
        segmentNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(segmentNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a copy of a schedule.
     * @param layoutId The id for the parent layout.  Zero if within the same layout.
     * @param scheduleId The id of the schedule to be duplicated.
     * @param schedulesNode The schedules node which will be parent for the new schedule.
     */
    void duplicateSchedule(int layoutId, int scheduleId, TimeTableTreeNode schedulesNode) {
        Schedule schedule = _dataMgr.getSchedule(scheduleId);
        Schedule newSchedule = schedule.getCopy(layoutId);
        setShowReminder(true);

        // Build tree components
        _leafNode = new TimeTableTreeNode(buildNodeText("Schedule", newSchedule, 0), "Schedule", newSchedule.getScheduleId(), 0);    // NOI18N
        schedulesNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(schedulesNode);

        // Duplicate the trains using the trains from the orignal schedule
        TimeTableTreeNode scheduleNode = _leafNode;
        for (Train train : _dataMgr.getTrains(scheduleId, 0, true)) {
            duplicateTrain(newSchedule.getScheduleId(), train.getTrainId(), 0, scheduleNode);
        }

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a copy of a train.
     * @param schedId The id for the parent schedule.  Zero if within the same schedule.
     * @param trainId The id of the train to be duplicated.
     * @param typeId The id of the train type.  If zero use the source train type.
     * @param schedNode The schedule node which will be parent for the new train.
     */
    void duplicateTrain(int schedId, int trainId, int typeId, TimeTableTreeNode schedNode ) {
        Train train = _dataMgr.getTrain(trainId);
        if (typeMap != null && typeMap.containsKey(train.getTypeId())) typeId = typeMap.get(train.getTypeId());
        Train newTrain = train.getCopy(schedId, typeId);
        setShowReminder(true);

        // If part of duplicating a layout, update the type reference.
        if (dupLayout && typeMap.containsKey(train.getTypeId())) {
            newTrain.setTypeId(typeMap.get(train.getTypeId()));
        }

        // Build tree components
        _leafNode = new TimeTableTreeNode(newTrain.toString(), "Train", newTrain.getTrainId(), 0);    // NOI18N
        schedNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(schedNode);

        // Duplicate the stops using the stops from the orignal train
        TimeTableTreeNode trainNode = _leafNode;
        for (Stop stop : _dataMgr.getStops(trainId, 0, true)) {
            duplicateStop(newTrain.getTrainId(), stop.getStopId(), 0, stop.getSeq(), trainNode);
        }

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Create a copy of a stop.
     * @param trainId The id for the parent train.  Zero if within the same train.
     * @param stopId The id of the stop to be duplicated.
     * @param stationId The id of the station.  If zero use the source station.
     * @param seq The sequence for the new stop.  If zero calculate the next sequence number.
     * @param trainNode The train node which will be parent for the new stop.
     */
    void duplicateStop(int trainId, int stopId, int stationId, int seq, TimeTableTreeNode trainNode) {
        Stop stop = _dataMgr.getStop(stopId);
        if (seq == 0) seq = _dataMgr.getStops(stop.getTrainId(), 0, false).size() + 1;
        Stop newStop = stop.getCopy(trainId, stationId, seq);
        setShowReminder(true);

        // If part of duplicating a layout, update the station reference.
        if (dupLayout && stationMap.containsKey(stop.getStationId())) {
            newStop.setStationId(stationMap.get(stop.getStationId()));
        }

        // Build tree components
        _leafNode = new TimeTableTreeNode(buildNodeText("Stop", newStop, 0), "Stop", newStop.getStopId(), seq);    // NOI18N
        trainNode.add(_leafNode);
        _timetableModel.nodeStructureChanged(trainNode);

        // Switch to new node
        _timetableTree.setSelectionPath(new TreePath(_leafNode.getPath()));
    }

    /**
     * Copy the stops from an existing train.
     */
    void copyPressed() {
        var selectedTrain = copyTrainSelection();
        if (selectedTrain != null) {
            for (var stop : _dataMgr.getStops(selectedTrain.getTrainId(), 0, true)) {
                // Create stop
                var newSeq = _dataMgr.getStops(_curNodeId, 0, false).size();
                var newStop = new Stop(_curNodeId, newSeq + 1);

                // Clone stop
                newStop.setStationId(stop.getStationId());
                newStop.setDuration(stop.getDuration());
                newStop.setNextSpeed(stop.getNextSpeed());
                newStop.setStagingTrack(stop.getStagingTrack());
                newStop.setStopNotes(stop.getStopNotes());

                // Build tree content
                _leafNode = new TimeTableTreeNode(buildNodeText("Stop", newStop, 0),  // NOI18N
                         "Stop", newStop.getStopId(), newSeq + 1);    // NOI18N
                _curNode.add(_leafNode);
                _timetableModel.nodeStructureChanged(_curNode);
            }
        }
    }

    /**
     * Select the train whose stops will be added to the new train.
     * @return the selected train or null if there is no selection made.
     */
    Train copyTrainSelection() {
        var newTrain = _dataMgr.getTrain(_curNodeId);
        var trainList = _dataMgr.getTrains(newTrain.getScheduleId(), 0, true);
        trainList.remove(newTrain);

        var trainArray = new Train[trainList.size()];
        trainList.toArray(trainArray);

        try {
            var icon = new ImageIcon(jmri.util.FileUtil.getProgramPath() + jmri.Application.getLogo());
            var choice = JmriJOptionPane.showInputDialog(
                    null,
                    Bundle.getMessage("LabelCopyStops"),  // NOI18N
                    Bundle.getMessage("TitleCopyStops"),  // NOI18N
                    JmriJOptionPane.QUESTION_MESSAGE,
                    icon,
                    trainArray,
                    null);
            return (Train) choice;
        } catch (HeadlessException ex) {
            return null;
        }
    }

    /**
     * Set up the edit environment for the selected node Called from
     * {@link #treeRowSelected}. This takes the place of an actual button.
     */
    void editPressed() {
        switch (_curNodeType) {
            case "Layout":     // NOI18N
                editLayout();
                makeDetailGrid("Layout");  // NOI18N
                break;

            case "TrainType":     // NOI18N
                editTrainType();
                makeDetailGrid("TrainType");  // NOI18N
                break;

            case "Segment":     // NOI18N
                editSegment();
                makeDetailGrid("Segment");  // NOI18N
                break;

            case "Station":     // NOI18N
                editStation();
                makeDetailGrid("Station");  // NOI18N
                break;

            case "Schedule":     // NOI18N
                editSchedule();
                makeDetailGrid("Schedule");  // NOI18N
                break;

            case "Train":     // NOI18N
                editTrain();
                makeDetailGrid("Train");  // NOI18N
                break;

            case "Stop":     // NOI18N
                editStop();
                makeDetailGrid("Stop");  // NOI18N
                break;

            default:
                log.error("Edit called for unsupported node type: '{}'", _curNodeType);  // NOI18N
        }
        setEditMode(false);
    }

    /*
     * Set Layout edit variables and labels
     */
    void editLayout() {
        Layout layout = _dataMgr.getLayout(_curNodeId);
        _editLayoutName.setText(layout.getLayoutName());
        _editFastClock.setText(Integer.toString(layout.getFastClock()));
        _editThrottles.setText(Integer.toString(layout.getThrottles()));
        _editMetric.setSelected(layout.getMetric());
        String unitMeasure = (layout.getMetric())
                ? Bundle.getMessage("LabelRealMeters") // NOI18N
                : Bundle.getMessage("LabelRealFeet"); // NOI18N
        _showScaleMK.setText(String.format("%.2f %s", layout.getScaleMK(), unitMeasure)); // NOI18N

        _editScale.removeAllItems();
        for (Scale scale : ScaleManager.getScales()) {
            _editScale.addItem(scale);
        }
        jmri.util.swing.JComboBoxUtil.setupComboBoxMaxRows(_editScale);
        _editScale.setSelectedItem(layout.getScale());
    }

    /*
     * Set TrainType edit variables and labels
     */
    void editTrainType() {
        TrainType type = _dataMgr.getTrainType(_curNodeId);
        _editTrainTypeName.setText(type.getTypeName());
        _editTrainTypeColor.setColor(Color.decode(type.getTypeColor()));
    }

    /*
     * Set Segment edit variables and labels
     */
    void editSegment() {
        Segment segment = _dataMgr.getSegment(_curNodeId);
        _editSegmentName.setText(segment.getSegmentName());
    }

    /*
     * Set Station edit variables and labels
     */
    void editStation() {
        Station station = _dataMgr.getStation(_curNodeId);
        _editStationName.setText(station.getStationName());
        _editDistance.setText(NumberFormat.getNumberInstance().format(station.getDistance()));
        _editDoubleTrack.setSelected(station.getDoubleTrack());
        _editSidings.setValue(station.getSidings());
        _editStaging.setValue(station.getStaging());
    }

    /*
     * Set Schedule edit variables and labels
     */
    void editSchedule() {
        Schedule schedule = _dataMgr.getSchedule(_curNodeId);
        _editScheduleName.setText(schedule.getScheduleName());
        _editEffDate.setText(schedule.getEffDate());
        _editStartHour.setValue(schedule.getStartHour());
        _editDuration.setValue(schedule.getDuration());
    }

    /*
     * Set Train edit variables and labels
     */
    void editTrain() {
        Train train = _dataMgr.getTrain(_curNodeId);
        int layoutId = _dataMgr.getSchedule(train.getScheduleId()).getLayoutId();

        _editTrainName.setText(train.getTrainName());
        _editTrainDesc.setText(train.getTrainDesc());
        _editDefaultSpeed.setText(Integer.toString(train.getDefaultSpeed()));
        _editTrainStartTime.setText(String.format("%02d:%02d",  // NOI18N
                train.getStartTime() / 60,
                train.getStartTime() % 60));
        _editThrottle.setModel(new SpinnerNumberModel(train.getThrottle(), 0, _dataMgr.getLayout(layoutId).getThrottles(), 1));
        _editTrainNotes.setText(train.getTrainNotes());
        _showRouteDuration.setText(String.format("%02d:%02d",  // NOI18N
                train.getRouteDuration() / 60,
                train.getRouteDuration() % 60));

        _editTrainType.removeAllItems();
        for (TrainType type : _dataMgr.getTrainTypes(layoutId, true)) {
            _editTrainType.addItem(type);
        }
        jmri.util.swing.JComboBoxUtil.setupComboBoxMaxRows(_editTrainType);
        if (train.getTypeId() > 0) {
            _editTrainType.setSelectedItem(_dataMgr.getTrainType(train.getTypeId()));
        }
    }

    /*
     * Set Stop edit variables and labels
     * The station combo box uses a data manager internal class to present
     * both the segment name and the station name.  This is needed since a station
     * can be in multiple segments.
     */
    void editStop() {
        Stop stop = _dataMgr.getStop(_curNodeId);
        Layout layout = _dataMgr.getLayoutForStop(_curNodeId);

        _showStopSeq.setText(Integer.toString(stop.getSeq()));
        _editStopDuration.setText(Integer.toString(stop.getDuration()));
        _editNextSpeed.setText(Integer.toString(stop.getNextSpeed()));
        _editStopNotes.setText(stop.getStopNotes());
        _showArriveTime.setText(String.format("%02d:%02d",  // NOI18N
                stop.getArriveTime() / 60,
                stop.getArriveTime() % 60));
        _showDepartTime.setText(String.format("%02d:%02d",  // NOI18N
                stop.getDepartTime() / 60,
                stop.getDepartTime() % 60));

        _editStopStation.removeAllItems();
        for (TimeTableDataManager.SegmentStation segmentStation : _dataMgr.getSegmentStations(layout.getLayoutId())) {
            _editStopStation.addItem(segmentStation);
            if (stop.getStationId() == segmentStation.getStationId()) {
                // This also triggers stopStationItemEvent which will set _editStagingTrack
                _editStopStation.setSelectedItem(segmentStation);
            }
        }
        jmri.util.swing.JComboBoxUtil.setupComboBoxMaxRows(_editStopStation);
        setMoveButtons();
    }

    /**
     * Apply the updates to the current node.
     */
    void updatePressed() {
        switch (_curNodeType) {
            case "Layout":     // NOI18N
                updateLayout();
                break;

            case "TrainType":     // NOI18N
                updateTrainType();
                break;

            case "Segment":     // NOI18N
                updateSegment();
                break;

            case "Station":     // NOI18N
                updateStation();
                break;

            case "Schedule":     // NOI18N
                updateSchedule();
                break;

            case "Train":     // NOI18N
                updateTrain();
                break;

            case "Stop":     // NOI18N
                updateStop();
                break;

            default:
                log.warn("Invalid update button press");  // NOI18N
        }
        setEditMode(false);
        _timetableTree.setSelectionPath(_curTreePath);
        _timetableTree.grabFocus();
        editPressed();
    }

    /**
     * Update the layout information.
     * If the fast clock or metric values change, a recalc will be required.
     * The throttles value cannot be less than the highest throttle assigned to a train.
     */
    void updateLayout() {
        Layout layout = _dataMgr.getLayout(_curNodeId);

        // Pre-validate and convert inputs
        String newName = _editLayoutName.getText().trim();
        Scale newScale = (Scale) _editScale.getSelectedItem();
        int newFastClock = parseNumber(_editFastClock, "fast clock");  // NOI18N
        if (newFastClock < 1) {
            newFastClock = layout.getFastClock();
        }
        int newThrottles = parseNumber(_editThrottles, "throttles");  // NOI18N
        if (newThrottles < 0) {
            newThrottles = layout.getThrottles();
        }
        boolean newMetric =_editMetric.isSelected();

        boolean update = false;
        List<String> exceptionList = new ArrayList<>();

        // Perform updates
        if (!layout.getLayoutName().equals(newName)) {
            layout.setLayoutName(newName);
            _curNode.setText(newName);
            _timetableModel.nodeChanged(_curNode);
            update = true;
        }

        if (!layout.getScale().equals(newScale)) {
            try {
                layout.setScale(newScale);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (layout.getFastClock() != newFastClock) {
            try {
                layout.setFastClock(newFastClock);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (layout.getMetric() != newMetric) {
            try {
                layout.setMetric(newMetric);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (layout.getThrottles() != newThrottles) {
            try {
                layout.setThrottles(newThrottles);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (update) {
            setShowReminder(true);
        }

        // Display exceptions if necessary
        if (!exceptionList.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("LayoutUpdateErrors"));  // NOI18N
            for (String keyWord : exceptionList) {
                if (keyWord.startsWith(TimeTableDataManager.TIME_OUT_OF_RANGE)) {
                    String[] comps = keyWord.split("~");
                    msg.append(Bundle.getMessage(comps[0], comps[1], comps[2]));
                } else if (keyWord.startsWith(TimeTableDataManager.SCALE_NF)) {
                    String[] scaleMsg = keyWord.split("~");
                    msg.append(Bundle.getMessage(scaleMsg[0], scaleMsg[1]));
                } else {
                    msg.append(String.format("%n%s", Bundle.getMessage(keyWord)));
                    if (keyWord.equals(TimeTableDataManager.THROTTLES_IN_USE)) {
                        // Add the affected trains
                        for (Schedule schedule : _dataMgr.getSchedules(_curNodeId, true)) {
                            for (Train train : _dataMgr.getTrains(schedule.getScheduleId(), 0, true)) {
                                if (train.getThrottle() > newThrottles) {
                                    msg.append(String.format("%n      %s [ %d ]", train.getTrainName(), train.getThrottle()));
                                }
                            }
                        }
                    }
                }
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
        }
    }

    /**
     * Update the train type information.
     */
    void updateTrainType() {
        TrainType type = _dataMgr.getTrainType(_curNodeId);

        String newName = _editTrainTypeName.getText().trim();
        Color newColor = _editTrainTypeColor.getColor();
        String newColorHex = jmri.util.ColorUtil.colorToHexString(newColor);

        boolean update = false;

        if (!type.getTypeName().equals(newName)) {
            type.setTypeName(newName);
            _curNode.setText(newName);
            update = true;
        }
        if (!type.getTypeColor().equals(newColorHex)) {
            type.setTypeColor(newColorHex);
            update = true;
        }
        _timetableModel.nodeChanged(_curNode);

        if (update) {
            setShowReminder(true);
        }
    }

    /**
     * Update the segment information.
     */
    void updateSegment() {
        String newName = _editSegmentName.getText().trim();

        Segment segment = _dataMgr.getSegment(_curNodeId);
        if (!segment.getSegmentName().equals(newName)) {
            segment.setSegmentName(newName);
            _curNode.setText(newName);
            setShowReminder(true);
        }
        _timetableModel.nodeChanged(_curNode);
    }

    /**
     * Update the station information.
     * The staging track value cannot be less than any train references.
     */
    void updateStation() {
        Station station = _dataMgr.getStation(_curNodeId);

        // Pre-validate and convert inputs
        String newName = _editStationName.getText().trim();
        double newDistance;
        try {
            newDistance = NumberFormat.getNumberInstance().parse(_editDistance.getText()).floatValue();
        } catch (NumberFormatException | ParseException ex) {
            log.warn("'{}' is not a valid number for {}", _editDistance.getText(), "station distance");  // NOI18N
            JmriJOptionPane.showMessageDialog(this,
                    Bundle.getMessage("NumberFormatError", _editDistance.getText(), "station distance"),  // NOI18N
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
            newDistance = station.getDistance();
        }
        boolean newDoubleTrack =_editDoubleTrack.isSelected();
        int newSidings = (int) _editSidings.getValue();
        int newStaging = (int) _editStaging.getValue();

        boolean update = false;
        List<String> exceptionList = new ArrayList<>();

        // Perform updates
        if (!station.getStationName().equals(newName)) {
            station.setStationName(newName);
            _curNode.setText(newName);
            _timetableModel.nodeChanged(_curNode);
            update = true;
        }

        if (newDistance < 0.0) {
            newDistance = station.getDistance();
        }
        if (Math.abs(station.getDistance() - newDistance) > .01 ) {
            try {
                station.setDistance(newDistance);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (station.getDoubleTrack() != newDoubleTrack) {
            station.setDoubleTrack(newDoubleTrack);
            update = true;
        }

        if (station.getSidings() != newSidings) {
            station.setSidings(newSidings);
            update = true;
        }

        if (station.getStaging() != newStaging) {
            try {
                station.setStaging(newStaging);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (update) {
            setShowReminder(true);
        }

        // Display exceptions if necessary
        if (!exceptionList.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("StationUpdateErrors"));  // NOI18N
            for (String keyWord : exceptionList) {
                if (keyWord.startsWith(TimeTableDataManager.TIME_OUT_OF_RANGE)) {
                    String[] comps = keyWord.split("~");
                    msg.append(Bundle.getMessage(comps[0], comps[1], comps[2]));
                } else {
                    msg.append(String.format("%n%s", Bundle.getMessage(keyWord)));
                    if (keyWord.equals(TimeTableDataManager.STAGING_IN_USE)) {
                        // Add the affected stops
                        for (Stop stop : _dataMgr.getStops(0, _curNodeId, false)) {
                            if (stop.getStagingTrack() > newStaging) {
                                Train train = _dataMgr.getTrain(stop.getTrainId());
                                msg.append(String.format("%n      %s, %d", train.getTrainName(), stop.getSeq()));
                            }
                        }
                    }
                }
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
        }
    }

    /**
     * Update the schedule information.
     * Changes to the schedule times cannot make a train start time or
     * a stop's arrival or departure times invalid.
     */
    void updateSchedule() {
        Schedule schedule = _dataMgr.getSchedule(_curNodeId);

        // Pre-validate and convert inputs
        String newName = _editScheduleName.getText().trim();
        String newEffDate = _editEffDate.getText().trim();
        int newStartHour = (int) _editStartHour.getValue();
        if (newStartHour < 0 || newStartHour > 23) {
            newStartHour = schedule.getStartHour();
        }
        int newDuration = (int) _editDuration.getValue();
        if (newDuration < 1 || newDuration > 24) {
            newDuration = schedule.getDuration();
        }

        boolean update = false;
        List<String> exceptionList = new ArrayList<>();

        // Perform updates
        if (!schedule.getScheduleName().equals(newName)) {
            schedule.setScheduleName(newName);
            update = true;
        }

        if (!schedule.getEffDate().equals(newEffDate)) {
            schedule.setEffDate(newEffDate);
            update = true;
        }

        if (update) {
            _curNode.setText(buildNodeText("Schedule", schedule, 0));  // NOI18N
            _timetableModel.nodeChanged(_curNode);
        }

        if (schedule.getStartHour() != newStartHour) {
            try {
                schedule.setStartHour(newStartHour);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (schedule.getDuration() != newDuration) {
            try {
                schedule.setDuration(newDuration);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (update) {
            setShowReminder(true);
        }

        // Display exceptions if necessary
        if (!exceptionList.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("ScheduleUpdateErrors"));  // NOI18N
            for (String keyWord : exceptionList) {
                if (keyWord.startsWith(TimeTableDataManager.TIME_OUT_OF_RANGE)) {
                    String[] comps = keyWord.split("~");
                    msg.append(Bundle.getMessage(comps[0], comps[1], comps[2]));
                } else {
                    msg.append(String.format("%n%s", Bundle.getMessage(keyWord)));
                }
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
        }
    }

    /**
     * Update the train information.
     * The train start time has to have a h:mm format and cannot fall outside
     * of the schedules times.
     */
    void updateTrain() {
        Train train = _dataMgr.getTrain(_curNodeId);
        List<String> exceptionList = new ArrayList<>();

        // Pre-validate and convert inputs
        String newName = _editTrainName.getText().trim();
        String newDesc = _editTrainDesc.getText().trim();
        int newType = ((TrainType) _editTrainType.getSelectedItem()).getTypeId();
        int newSpeed = parseNumber(_editDefaultSpeed, "default train speed");  // NOI18N
        if (newSpeed < 0) {
            newSpeed = train.getDefaultSpeed();
        }

        LocalTime newTime;
        int newStart;
        try {
            newTime = LocalTime.parse(_editTrainStartTime.getText().trim(), DateTimeFormatter.ofPattern("H:mm"));  // NOI18N
            newStart = newTime.getHour() * 60 + newTime.getMinute();
        } catch (java.time.format.DateTimeParseException ex) {
            exceptionList.add(TimeTableDataManager.START_TIME_FORMAT + "~" + ex.getParsedString());
            newStart = train.getStartTime();
        }

        int newThrottle = (int) _editThrottle.getValue();
        String newNotes = _editTrainNotes.getText();

        boolean update = false;

        // Perform updates
        if (!train.getTrainName().equals(newName)) {
            train.setTrainName(newName);
            update = true;
        }

        if (!train.getTrainDesc().equals(newDesc)) {
            train.setTrainDesc(newDesc);
            update = true;
        }

        if (update) {
            _curNode.setText(buildNodeText("Train", train, 0));  // NOI18N
            _timetableModel.nodeChanged(_curNode);
        }

        if (train.getTypeId() != newType) {
            train.setTypeId(newType);
            update = true;
        }

        if (train.getDefaultSpeed() != newSpeed) {
            try {
                train.setDefaultSpeed(newSpeed);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (train.getStartTime() != newStart) {
            try {
                train.setStartTime(newStart);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (train.getThrottle() != newThrottle) {
            try {
                train.setThrottle(newThrottle);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (!train.getTrainNotes().equals(newNotes)) {
            train.setTrainNotes(newNotes);
            update = true;
        }

        if (update) {
            setShowReminder(true);
        }

        // Display exceptions if necessary
        if (!exceptionList.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("TrainUpdateErrors"));  // NOI18N
            for (String keyWord : exceptionList) {
                log.info("kw = {}", keyWord);
                if (keyWord.startsWith(TimeTableDataManager.TIME_OUT_OF_RANGE)) {
                    String[] comps = keyWord.split("~");
                    msg.append(Bundle.getMessage(comps[0], comps[1], comps[2]));
                } else if (keyWord.startsWith(TimeTableDataManager.START_TIME_FORMAT)) {
                    String[] timeMsg = keyWord.split("~");
                    msg.append(Bundle.getMessage(timeMsg[0], timeMsg[1]));
                } else if (keyWord.startsWith(TimeTableDataManager.START_TIME_RANGE)) {
                    String[] schedMsg = keyWord.split("~");
                    msg.append(Bundle.getMessage(schedMsg[0], schedMsg[1], schedMsg[2]));
                } else {
                    msg.append(String.format("%n%s", Bundle.getMessage(keyWord)));
                }
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
        }
    }

    /**
     * Update the stop information.
     */
    void updateStop() {
        Stop stop = _dataMgr.getStop(_curNodeId);

        // Pre-validate and convert inputs
        TimeTableDataManager.SegmentStation stopSegmentStation =
                (TimeTableDataManager.SegmentStation) _editStopStation.getSelectedItem();
        int newStation = stopSegmentStation.getStationId();
        int newDuration = parseNumber(_editStopDuration, "stop duration");  // NOI18N
        if (newDuration < 0) {
            newDuration = stop.getDuration();
        }
        int newSpeed = parseNumber(_editNextSpeed, "next speed");  // NOI18N
        if (newSpeed < 0) {
            newSpeed = stop.getNextSpeed();
        }
        int newStagingTrack = (int) _editStagingTrack.getValue();
        String newNotes = _editStopNotes.getText();

        boolean update = false;
        List<String> exceptionList = new ArrayList<>();

        // Perform updates
        if (stop.getStationId() != newStation) {
            stop.setStationId(newStation);
            _curNode.setText(buildNodeText("Stop", stop, 0));  // NOI18N
            _timetableModel.nodeChanged(_curNode);
            update = true;
        }

        if (stop.getDuration() != newDuration) {
            try {
                stop.setDuration(newDuration);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (stop.getNextSpeed() != newSpeed) {
            try {
                stop.setNextSpeed(newSpeed);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (stop.getStagingTrack() != newStagingTrack) {
            try {
                stop.setStagingTrack(newStagingTrack);
                update = true;
            } catch (IllegalArgumentException ex) {
                exceptionList.add(ex.getMessage());
            }
        }

        if (!stop.getStopNotes().equals(newNotes)) {
            stop.setStopNotes(newNotes);
            update = true;
        }

        if (update) {
            setShowReminder(true);
        }

        // Display exceptions if necessary
        if (!exceptionList.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("StopUpdateErrors"));  // NOI18N
            for (String keyWord : exceptionList) {
                if (keyWord.startsWith(TimeTableDataManager.TIME_OUT_OF_RANGE)) {
                    String[] comps = keyWord.split("~");
                    msg.append(Bundle.getMessage(comps[0], comps[1], comps[2]));
                } else {
                    msg.append(String.format("%n%s", Bundle.getMessage(keyWord)));
                }
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
        }
    }

    /**
     * Convert text input to an integer.
     * @param textField JTextField containing the probable integer.
     * @param fieldName The name of the field for the dialog.
     * @return the valid number or -1 for an invalid input.
     */
    int parseNumber(JTextField textField, String fieldName) {
        String text = textField.getText().trim();
        try {
            return Integer.parseInt(text);
        } catch (NumberFormatException ex) {
            log.warn("'{}' is not a valid number for {}", text, fieldName);  // NOI18N
            JmriJOptionPane.showMessageDialog(textField,
                    Bundle.getMessage("NumberFormatError", text, fieldName),  // NOI18N
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
            return -1;
        }
    }

    /**
     * Process the node delete request.
     */
    void deletePressed() {
        switch (_curNodeType) {
            case "Layout":  // NOI18N
                deleteLayout();
                break;

            case "TrainType":  // NOI18N
                deleteTrainType();
                break;

            case "Segment":  // NOI18N
                deleteSegment();
                break;

            case "Station":  // NOI18N
                deleteStation();
                break;

            case "Schedule":  // NOI18N
                deleteSchedule();
                break;

            case "Train":  // NOI18N
                deleteTrain();
                break;

            case "Stop":
                deleteStop();  // NOI18N
                break;

            default:
                log.error("Delete called for unsupported node type: '{}'", _curNodeType);  // NOI18N
        }
    }

    /**
     * After confirmation, perform a cascade delete of the layout and its components.
     */
    void deleteLayout() {
        Object[] options = {Bundle.getMessage("ButtonNo"), Bundle.getMessage("ButtonYes")};  // NOI18N
        int selectedOption = JmriJOptionPane.showOptionDialog(this,
                Bundle.getMessage("LayoutCascade"), // NOI18N
                Bundle.getMessage("QuestionTitle"),   // NOI18N
                JmriJOptionPane.DEFAULT_OPTION,
                JmriJOptionPane.QUESTION_MESSAGE,
                null, options, options[0]);
        if (selectedOption != 1) { // return if option is not array position 1, YES
            return;
        }

        _dataMgr.setLockCalculate(true);

        // Delete the components
        for (Schedule schedule : _dataMgr.getSchedules(_curNodeId, false)) {
            for (Train train : _dataMgr.getTrains(schedule.getScheduleId(), 0, false)) {
                for (Stop stop : _dataMgr.getStops(train.getTrainId(), 0, false)) {
                    _dataMgr.deleteStop(stop.getStopId());
                }
                _dataMgr.deleteTrain(train.getTrainId());
            }
            _dataMgr.deleteSchedule(schedule.getScheduleId());
        }

        for (Segment segment : _dataMgr.getSegments(_curNodeId, false)) {
            for (Station station : _dataMgr.getStations(segment.getSegmentId(), false)) {
                _dataMgr.deleteStation(station.getStationId());
            }
            _dataMgr.deleteSegment(segment.getSegmentId());
        }

        for (TrainType type : _dataMgr.getTrainTypes(_curNodeId, false)) {
            _dataMgr.deleteTrainType(type.getTypeId());
        }

        // delete the Layout
        _dataMgr.deleteLayout(_curNodeId);
        setShowReminder(true);

        // Update the tree
//         TreePath parentPath = _curTreePath.getParentPath();
        TreeNode parentNode = _curNode.getParent();
        _curNode.removeFromParent();
        _curNode = null;
        _timetableModel.nodeStructureChanged(parentNode);
//         _timetableTree.setSelectionPath(parentPath);
        _dataMgr.setLockCalculate(false);
    }

    /**
     * Delete a train type after checking for usage.
     */
    void deleteTrainType() {
        // Check train references
        ArrayList<String> typeReference = new ArrayList<>();
        for (Train train : _dataMgr.getTrains(0, _curNodeId, true)) {
            typeReference.add(train.getTrainName());
        }
        if (!typeReference.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("DeleteWarning", _curNodeType));  // NOI18N
            for (String trainName : typeReference) {
                msg.append("\n    " + trainName);  // NOI18N
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
            return;
        }
        _dataMgr.deleteTrainType(_curNodeId);
        setShowReminder(true);

        // Update the tree
        TreePath parentPath = _curTreePath.getParentPath();
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        parentNode.remove(_curNode);
        _timetableModel.nodeStructureChanged(parentNode);
        _curNode = null;
        _timetableTree.setSelectionPath(parentPath);
    }

    /**
     * Delete a Segment.
     * If the segment contains inactive stations, provide the option to perform
     * a cascade delete.
     */
    void deleteSegment() {
        List<Station> stationList = new ArrayList<>(_dataMgr.getStations(_curNodeId, true));
        if (!stationList.isEmpty()) {
            // The segment still has stations.  See if any are still used by Stops
            List<Station> activeList = new ArrayList<>();
            for (Station checkActive : stationList) {
                List<Stop> stopList = new ArrayList<>(_dataMgr.getStops(0, checkActive.getStationId(), true));
                if (!stopList.isEmpty()) {
                    activeList.add(checkActive);
                }
            }
            if (!activeList.isEmpty()) {
                // Cannot delete the Segment
                StringBuilder msg = new StringBuilder(Bundle.getMessage("DeleteWarning", _curNodeType));  // NOI18N
                for (Station activeStation : activeList) {
                    msg.append("\n    " + activeStation.getStationName());  // NOI18N
                }
                JmriJOptionPane.showMessageDialog(this,
                        msg.toString(),
                        Bundle.getMessage("WarningTitle"),  // NOI18N
                        JmriJOptionPane.WARNING_MESSAGE);
                return;
            }
            // Present the option to delete the stations and the segment
            Object[] options = {Bundle.getMessage("ButtonNo"), Bundle.getMessage("ButtonYes")};  // NOI18N
            int selectedOption = JmriJOptionPane.showOptionDialog(this,
                    Bundle.getMessage("SegmentCascade"), // NOI18N
                    Bundle.getMessage("QuestionTitle"),   // NOI18N
                    JmriJOptionPane.DEFAULT_OPTION,
                    JmriJOptionPane.QUESTION_MESSAGE,
                    null, options, options[0]);
            if (selectedOption != 1) {  // return if option is not array position 1, YES
                return;
            }
            for (Station delStation : stationList) {
                _dataMgr.deleteStation(delStation.getStationId());
            }
        }
        // delete the segment
        _dataMgr.deleteSegment(_curNodeId);
        setShowReminder(true);

        // Update the tree
        TreePath parentPath = _curTreePath.getParentPath();
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        _curNode.removeFromParent();
        _curNode = null;
        _timetableModel.nodeStructureChanged(parentNode);
        _timetableTree.setSelectionPath(parentPath);
    }

    /**
     * Delete a Station after checking for usage.
     */
    void deleteStation() {
        // Check stop references
        List<String> stopReference = new ArrayList<>();
        for (Stop stop : _dataMgr.getStops(0, _curNodeId, true)) {
            Train train = _dataMgr.getTrain(stop.getTrainId());
            String trainSeq = String.format("%s : %d", train.getTrainName(), stop.getSeq());  // NOI18N
            stopReference.add(trainSeq);
        }
        if (!stopReference.isEmpty()) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("DeleteWarning", _curNodeType));  // NOI18N
            for (String stopTrainSeq : stopReference) {
                msg.append("\n    " + stopTrainSeq);  // NOI18N
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("WarningTitle"),  // NOI18N
                    JmriJOptionPane.WARNING_MESSAGE);
            return;
        }
        _dataMgr.deleteStation(_curNodeId);
        setShowReminder(true);

        // Update the tree
        TreePath parentPath = _curTreePath.getParentPath();
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        parentNode.remove(_curNode);
        _timetableModel.nodeStructureChanged(parentNode);
        _curNode = null;
        _timetableTree.setSelectionPath(parentPath);
    }

    /**
     * Delete a Schedule.
     * If the schedule contains trains, provide the option to perform
     * a cascade delete of trains and their stops.
     */
    void deleteSchedule() {
        List<Train> trainList = new ArrayList<>(_dataMgr.getTrains(_curNodeId, 0, true));
        if (!trainList.isEmpty()) {
            // The schedule still has trains.
            // Present the option to delete the stops, trains and the schedule
            Object[] options = {Bundle.getMessage("ButtonNo"), Bundle.getMessage("ButtonYes")};  // NOI18N
            int selectedOption = JmriJOptionPane.showOptionDialog(this,
                    Bundle.getMessage("ScheduleCascade"), // NOI18N
                    Bundle.getMessage("QuestionTitle"),   // NOI18N
                    JmriJOptionPane.DEFAULT_OPTION,
                    JmriJOptionPane.QUESTION_MESSAGE,
                    null, options, options[0]);
            if (selectedOption != 1) { // return if option is not array position 1, YES
                return;
            }
            for (Train train : trainList) {
                for (Stop stop : _dataMgr.getStops(train.getTrainId(), 0, false)) {
                    _dataMgr.deleteStop(stop.getStopId());
                }
                _dataMgr.deleteTrain(train.getTrainId());
            }
        }
        // delete the schedule
        _dataMgr.deleteSchedule(_curNodeId);
        setShowReminder(true);

        // Update the tree
        TreePath parentPath = _curTreePath.getParentPath();
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        _curNode.removeFromParent();
        _curNode = null;
        _timetableModel.nodeStructureChanged(parentNode);
        _timetableTree.setSelectionPath(parentPath);
    }

    /**
     * Delete a Train.
     * If the train contains stops, provide the option to perform
     * a cascade delete of the stops.
     */
    void deleteTrain() {
        List<Stop> stopList = new ArrayList<>(_dataMgr.getStops(_curNodeId, 0, true));
        if (!stopList.isEmpty()) {
            // The trains still has stops.
            // Present the option to delete the stops and the train
            Object[] options = {Bundle.getMessage("ButtonNo"), Bundle.getMessage("ButtonYes")};  // NOI18N
            int selectedOption = JmriJOptionPane.showOptionDialog(this,
                    Bundle.getMessage("TrainCascade"), // NOI18N
                    Bundle.getMessage("QuestionTitle"),   // NOI18N
                    JmriJOptionPane.DEFAULT_OPTION,
                    JmriJOptionPane.QUESTION_MESSAGE,
                    null, options, options[0]);
            if (selectedOption != 1) { // return if option is not array position 1, YES
                return;
            }
            for (Stop stop : stopList) {
                _dataMgr.deleteStop(stop.getStopId());
            }
        }
        // delete the train
        _dataMgr.deleteTrain(_curNodeId);
        setShowReminder(true);

        // Update the tree
        TreePath parentPath = _curTreePath.getParentPath();
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        _curNode.removeFromParent();
        _curNode = null;
        _timetableModel.nodeStructureChanged(parentNode);
        _timetableTree.setSelectionPath(parentPath);
    }

    /**
     * Delete a Stop.
     */
    void deleteStop() {
        // delete the stop
        _dataMgr.deleteStop(_curNodeId);
        setShowReminder(true);

        // Update the tree
        TreePath parentPath = _curTreePath.getParentPath();
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        _curNode.removeFromParent();
        _curNode = null;
        _timetableModel.nodeStructureChanged(parentNode);
        _timetableTree.setSelectionPath(parentPath);
    }

    /**
     * Cancel the current node edit.
     */
    void cancelPressed() {
        setEditMode(false);
        _timetableTree.setSelectionPath(_curTreePath);
        _timetableTree.grabFocus();
    }

    /**
     * Move a Stop row up 1 row.
     */
    void upPressed() {
        setShowReminder(true);

        DefaultMutableTreeNode prevNode = _curNode.getPreviousSibling();
        if (!(prevNode instanceof TimeTableTreeNode)) {
            log.warn("At first node, cannot move up");  // NOI18N
            return;
        }
        int prevStopId = ((TimeTableTreeNode) prevNode).getId();
        Stop prevStop = _dataMgr.getStop(prevStopId);
        prevStop.setSeq(prevStop.getSeq() + 1);
        Stop currStop = _dataMgr.getStop(_curNodeId);
        currStop.setSeq(currStop.getSeq() - 1);
        moveTreeNode("Up");     // NOI18N
    }

    /**
     * Move a Stop row down 1 row.
     */
    void downPressed() {
        setShowReminder(true);

        DefaultMutableTreeNode nextNode = _curNode.getNextSibling();
        if (!(nextNode instanceof TimeTableTreeNode)) {
            log.warn("At last node, cannot move down");  // NOI18N
            return;
        }
        int nextStopId = ((TimeTableTreeNode) nextNode).getId();
        Stop nextStop = _dataMgr.getStop(nextStopId);
        nextStop.setSeq(nextStop.getSeq() - 1);
        Stop currStop = _dataMgr.getStop(_curNodeId);
        currStop.setSeq(currStop.getSeq() + 1);
        moveTreeNode("Down");     // NOI18N
    }

    /**
     * Move a tree node in response to a up or down request.
     *
     * @param direction The direction of movement, Up or Down
     */
    void moveTreeNode(String direction) {
        // Update the node
        if (direction.equals("Up")) {    // NOI18N
            _curNodeRow -= 1;
        } else {
            _curNodeRow += 1;
        }
        _curNode.setRow(_curNodeRow);
        _timetableModel.nodeChanged(_curNode);

        // Update the sibling
        DefaultMutableTreeNode siblingNode;
        TimeTableTreeNode tempNode;
        if (direction.equals("Up")) {    // NOI18N
            siblingNode = _curNode.getPreviousSibling();
            if (siblingNode instanceof TimeTableTreeNode) {
                tempNode = (TimeTableTreeNode) siblingNode;
                tempNode.setRow(tempNode.getRow() + 1);
            }
        } else {
            siblingNode = _curNode.getNextSibling();
            if (siblingNode instanceof TimeTableTreeNode) {
                tempNode = (TimeTableTreeNode) siblingNode;
                tempNode.setRow(tempNode.getRow() - 1);
            }
        }
        _timetableModel.nodeChanged(siblingNode);

        // Update the tree
        TimeTableTreeNode parentNode = (TimeTableTreeNode) _curNode.getParent();
        parentNode.insert(_curNode, _curNodeRow - 1);
        _timetableModel.nodeStructureChanged(parentNode);
        _timetableTree.setSelectionPath(new TreePath(_curNode.getPath()));
        setMoveButtons();

        // Update times
        _dataMgr.calculateTrain(_dataMgr.getStop(_curNodeId).getTrainId(), true);
    }

    /**
     * Enable/Disable the Up and Down buttons based on the postion in the list.
     */
    void setMoveButtons() {
        if (_curNode == null) {
            return;
        }

        Component[] compList = _moveButtonPanel.getComponents();
        JButton up = (JButton) compList[1];
        JButton down = (JButton) compList[3];

        up.setEnabled(true);
        down.setEnabled(true);

        int rows = _curNode.getSiblingCount();
        if (_curNodeRow < 2) {
            up.setEnabled(false);
        }
        if (_curNodeRow > rows - 1) {
            down.setEnabled(false);
        }

        // Disable move buttons during Variable or Action add or edit processing, or nothing selected
        if (_editActive) {
            up.setEnabled(false);
            down.setEnabled(false);
        }

        _moveButtonPanel.setVisible(true);
    }

    void graphPressed(String graphType) {

        // select a schedule if necessary
        Segment segment = _dataMgr.getSegment(_curNodeId);
        Layout layout = _dataMgr.getLayout(segment.getLayoutId());
        int scheduleId;
        List<Schedule> schedules = _dataMgr.getSchedules(layout.getLayoutId(), true);

        if (schedules.size() == 0) {
            log.warn("no schedule");  // NOI18N
            return;
        } else {
            scheduleId = schedules.get(0).getScheduleId();
            if (schedules.size() > 1) {
                // do selection dialog
                Schedule[] schedArr = new Schedule[schedules.size()];
                schedArr = schedules.toArray(schedArr);
                Schedule schedSelected = (Schedule) JmriJOptionPane.showInputDialog(
                        null,
                        Bundle.getMessage("GraphScheduleMessage"),  // NOI18N
                        Bundle.getMessage("QuestionTitle"),  // NOI18N
                        JmriJOptionPane.QUESTION_MESSAGE,
                        null,
                        schedArr,
                        schedArr[0]
                );
                if (schedSelected == null) {
                    log.warn("Schedule not selected, graph request cancelled");  // NOI18N
                    return;
                }
                scheduleId = schedSelected.getScheduleId();
            }
        }

        if (graphType.equals("Display")) {
            TimeTableDisplayGraph graph = new TimeTableDisplayGraph(_curNodeId, scheduleId, _showTrainTimes);

            JmriJFrame f = new JmriJFrame(Bundle.getMessage("TitleTimeTableGraph"), true, true);  // NOI18N
            f.setMinimumSize(new Dimension(600, 300));
            f.getContentPane().add(graph);
            f.pack();
            f.addHelpMenu("html.tools.TimeTable", true);  // NOI18N
            f.setVisible(true);
        }

        if (graphType.equals("Print")) {
            TimeTablePrintGraph print = new TimeTablePrintGraph(_curNodeId, scheduleId, _showTrainTimes, _twoPage);
            print.printGraph();
        }
    }

    JFileChooser fileChooser;
    void importPressed() {
        fileChooser = jmri.jmrit.XmlFile.userFileChooser("SchedGen File", "sgn");  // NOI18N
        int retVal = fileChooser.showOpenDialog(null);
        if (retVal == JFileChooser.APPROVE_OPTION) {
            File file = fileChooser.getSelectedFile();
            try {
                new TimeTableImport().importSgn(_dataMgr, file);
            } catch (IOException ex) {
                log.error("Import exception", ex);  // NOI18N
                JmriJOptionPane.showMessageDialog(this,
                        Bundle.getMessage("ImportFailed", "SGN"),  // NOI18N
                        Bundle.getMessage("ErrorTitle"),  // NOI18N
                        JmriJOptionPane.ERROR_MESSAGE);
                return;
            }
            savePressed();
            JmriJOptionPane.showMessageDialog(this,
                    Bundle.getMessage("ImportCompleted", "SGN"),  // NOI18N
                    Bundle.getMessage("MessageTitle"),  // NOI18N
                    JmriJOptionPane.INFORMATION_MESSAGE);
        }
    }

    List<String> feedbackList;
    void importCsvPressed() {
        fileChooser = new jmri.util.swing.JmriJFileChooser(jmri.util.FileUtil.getUserFilesPath());
        fileChooser.setFileFilter(new FileNameExtensionFilter("Import File", "csv"));
        int retVal = fileChooser.showOpenDialog(null);
        if (retVal == JFileChooser.APPROVE_OPTION) {
            File file = fileChooser.getSelectedFile();
            completeImport(file);
        }
    }

    void completeImport(File file) {
        try {
            feedbackList = new TimeTableCsvImport().importCsv(file);
        } catch (IOException ex) {
            log.error("Import exception", ex); // NOI18N
            JmriJOptionPane.showMessageDialog(this,
                    Bundle.getMessage("ImportCsvFailed", "CVS"), // NOI18N
                    Bundle.getMessage("ErrorTitle"), // NOI18N
                    JmriJOptionPane.ERROR_MESSAGE);
            return;
        }
        if (feedbackList.size() > 0) {
            StringBuilder msg = new StringBuilder(Bundle.getMessage("ImportCsvErrors")); // NOI18N
            for (String feedback : feedbackList) {
                msg.append(feedback + "\n");
            }
            JmriJOptionPane.showMessageDialog(this,
                    msg.toString(),
                    Bundle.getMessage("ErrorTitle"), // NOI18N
                    JmriJOptionPane.ERROR_MESSAGE);
            return;
        }
        savePressed();
        JmriJOptionPane.showMessageDialog(this,
                Bundle.getMessage("ImportCompleted", "CSV"), // NOI18N
                Bundle.getMessage("MessageTitle"), // NOI18N
                JmriJOptionPane.INFORMATION_MESSAGE);
    }

    void importFromOperationsPressed() {
        ExportTimetable ex = new ExportTimetable();
        new ExportTimetable().writeOperationsTimetableFile();
        completeImport(ex.getExportFile());
    }

    void exportCsvPressed() {
        // Select layout
        List<Layout> layouts = _dataMgr.getLayouts(true);
        if (layouts.size() == 0) {
            JmriJOptionPane.showMessageDialog(this,
                    Bundle.getMessage("ExportLayoutError"),  // NOI18N
                    Bundle.getMessage("ErrorTitle"),  // NOI18N
                    JmriJOptionPane.ERROR_MESSAGE);
            return;
        }
        int layoutId = layouts.get(0).getLayoutId();
        if (layouts.size() > 1) {
            Layout layout = (Layout) JmriJOptionPane.showInputDialog(
                    this,
                    Bundle.getMessage("ExportSelectLayout"),  // NOI18N
                    Bundle.getMessage("QuestionTitle"),  // NOI18N
                    JmriJOptionPane.PLAIN_MESSAGE,
                    null,
                    layouts.toArray(),
                    null);
            if (layout == null) return;
            layoutId = layout.getLayoutId();
        }

        // Select segment
        List<Segment> segments = _dataMgr.getSegments(layoutId, true);
        if (segments.size() == 0) {
            JmriJOptionPane.showMessageDialog(this,
                    Bundle.getMessage("ExportSegmentError"),  // NOI18N
                    Bundle.getMessage("ErrorTitle"),  // NOI18N
                    JmriJOptionPane.ERROR_MESSAGE);
            return;
        }
        int segmentId = segments.get(0).getSegmentId();
        if (segments.size() > 1) {
            Segment segment = (Segment) JmriJOptionPane.showInputDialog(
                    this,
                    Bundle.getMessage("ExportSelectSegment"),  // NOI18N
                    Bundle.getMessage("QuestionTitle"),  // NOI18N
                    JmriJOptionPane.PLAIN_MESSAGE,
                    null,
                    segments.toArray(),
                    null);
            if (segment == null) return;
            segmentId = segment.getSegmentId();
        }

        // Select schedule
        List<Schedule> schedules = _dataMgr.getSchedules(layoutId, true);
        if (schedules.size() == 0) {
            JmriJOptionPane.showMessageDialog(this,
                    Bundle.getMessage("ExportScheduleError"),  // NOI18N
                    Bundle.getMessage("ErrorTitle"),  // NOI18N
                    JmriJOptionPane.ERROR_MESSAGE);
            return;
        }
        int scheduleId = schedules.get(0).getScheduleId();
        if (schedules.size() > 1) {
            Schedule schedule = (Schedule) JmriJOptionPane.showInputDialog(
                    this,
                    Bundle.getMessage("ExportSelectSchedule"),  // NOI18N
                    Bundle.getMessage("QuestionTitle"),  // NOI18N
                    JmriJOptionPane.PLAIN_MESSAGE,
                    null,
                    schedules.toArray(),
                    null);
            if (schedule == null) return;
            scheduleId = schedule.getScheduleId();
        }

        fileChooser = new jmri.util.swing.JmriJFileChooser(jmri.util.FileUtil.getUserFilesPath());
        fileChooser.setFileFilter(new FileNameExtensionFilter("Export as CSV File", "csv"));  // NOI18N
        int retVal = fileChooser.showSaveDialog(null);
        if (retVal == JFileChooser.APPROVE_OPTION) {
            File file = fileChooser.getSelectedFile();
            String fileName = file.getAbsolutePath();
            String fileNameLC = fileName.toLowerCase();
            if (!fileNameLC.endsWith(".csv")) {  // NOI18N
                fileName = fileName + ".csv";  // NOI18N
                file = new File(fileName);
            }
            if (file.exists()) {
                if (JmriJOptionPane.showConfirmDialog(this,
                        Bundle.getMessage("FileOverwriteWarning", file.getName()),  // NOI18N
                        Bundle.getMessage("QuestionTitle"),  // NOI18N
                        JmriJOptionPane.OK_CANCEL_OPTION,
                        JmriJOptionPane.QUESTION_MESSAGE) != JmriJOptionPane.OK_OPTION) {
                    return;
                }
            }


            boolean hasErrors;
            try {
                hasErrors = new TimeTableCsvExport().exportCsv(file, layoutId, segmentId, scheduleId);
            } catch (IOException ex) {
                log.error("Export exception", ex);  // NOI18N
                JmriJOptionPane.showMessageDialog(this,
                        Bundle.getMessage("ExportFailed"),  // NOI18N
                        Bundle.getMessage("ErrorTitle"),  // NOI18N
                        JmriJOptionPane.ERROR_MESSAGE);
                return;
            }

            if (hasErrors) {
                JmriJOptionPane.showMessageDialog(this,
                        Bundle.getMessage("ExportFailed"),  // NOI18N
                        Bundle.getMessage("ErrorTitle"),  // NOI18N
                        JmriJOptionPane.ERROR_MESSAGE);
            } else {
                JmriJOptionPane.showMessageDialog(this,
                        Bundle.getMessage("ExportCompleted", file),  // NOI18N
                        Bundle.getMessage("MessageTitle"),  // NOI18N
                        JmriJOptionPane.INFORMATION_MESSAGE);
            }
        }
    }

    /**
     * Save the current set of timetable data.
     */
    void savePressed() {
        TimeTableXml.doStore();
        setShowReminder(false);
    }

    /**
     * Check for pending updates and close if none or approved.
     */
    void donePressed() {
        if (_isDirty) {
            Object[] options = {Bundle.getMessage("ButtonNo"), Bundle.getMessage("ButtonYes")};  // NOI18N
            int selectedOption = JmriJOptionPane.showOptionDialog(this,
                    Bundle.getMessage("DirtyDataWarning"), // NOI18N
                    Bundle.getMessage("WarningTitle"),   // NOI18N
                    JmriJOptionPane.DEFAULT_OPTION,
                    JmriJOptionPane.WARNING_MESSAGE,
                    null, options, options[0]);
            if (selectedOption == 0) {
                return;
            }
        }
        InstanceManager.reset(TimeTableFrame.class);
        dispose();
    }

    // ------------  Tree Content and Navigation ------------

    /**
     * Create the TimeTable tree structure.
     *
     * @return _timetableTree The tree ddefinition with its content
     */
    JTree buildTree() {
        _timetableRoot = new DefaultMutableTreeNode("Root Node");      // NOI18N
        _timetableModel = new DefaultTreeModel(_timetableRoot);
        _timetableTree = new JTree(_timetableModel);

        createTimeTableContent();

        // build the tree GUI
        _timetableTree.expandPath(new TreePath(_timetableRoot));
        _timetableTree.setRootVisible(false);
        _timetableTree.setShowsRootHandles(true);
        _timetableTree.setScrollsOnExpand(true);
        _timetableTree.setExpandsSelectedPaths(true);
        _timetableTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);

        // tree listeners
        _timetableTree.addTreeSelectionListener(_timetableListener = new TreeSelectionListener() {
            @Override
            public void valueChanged(TreeSelectionEvent e) {
                if (_editActive) {
                    if (e.getNewLeadSelectionPath() != _curTreePath) {
                        _timetableTree.setSelectionPath(e.getOldLeadSelectionPath());
                        showNodeEditMessage();
                    }
                    return;
                }

                _curTreePath = _timetableTree.getSelectionPath();
                if (_curTreePath != null) {
                    Object chkLast = _curTreePath.getLastPathComponent();
                    if (chkLast instanceof TimeTableTreeNode) {
                        treeRowSelected((TimeTableTreeNode) chkLast);
                    }
                }
            }
        });

        return _timetableTree;
    }

    /**
     * Create the tree content.
     * Level 1 -- Layouts
     * Level 2 -- Train Type, Segment and Schedule Containers
     * Level 3 -- Train Types, Segments, Schedules
     * Level 4 -- Stations, Trains
     * Level 5 -- Stops
     */
    void createTimeTableContent() {
        for (Layout l : _dataMgr.getLayouts(true)) {
            _layoutNode = new TimeTableTreeNode(l.getLayoutName(), "Layout", l.getLayoutId(), 0);    // NOI18N
            _timetableRoot.add(_layoutNode);

            _typeHead = new TimeTableTreeNode(buildNodeText("TrainTypes", null, 0), "TrainTypes", 0, 0);    // NOI18N
            _layoutNode.add(_typeHead);
            for (TrainType y : _dataMgr.getTrainTypes(l.getLayoutId(), true)) {
                _typeNode = new TimeTableTreeNode(y.getTypeName(), "TrainType", y.getTypeId(), 0);    // NOI18N
                _typeHead.add(_typeNode);
            }

            _segmentHead = new TimeTableTreeNode(buildNodeText("Segments", null, 0), "Segments", 0, 0);    // NOI18N
            _layoutNode.add(_segmentHead);
            for (Segment sg : _dataMgr.getSegments(l.getLayoutId(), true)) {
                _segmentNode = new TimeTableTreeNode(sg.getSegmentName(), "Segment", sg.getSegmentId(), 0);    // NOI18N
                _segmentHead.add(_segmentNode);
                for (Station st : _dataMgr.getStations(sg.getSegmentId(), true)) {
                    _leafNode = new TimeTableTreeNode(st.getStationName(), "Station", st.getStationId(), 0);    // NOI18N
                    _segmentNode.add(_leafNode);
                }
            }

            _scheduleHead = new TimeTableTreeNode(buildNodeText("Schedules", null, 0), "Schedules", 0, 0);    // NOI18N
            _layoutNode.add(_scheduleHead);
            for (Schedule c : _dataMgr.getSchedules(l.getLayoutId(), true)) {
                _scheduleNode = new TimeTableTreeNode(buildNodeText("Schedule", c, 0), "Schedule", c.getScheduleId(), 0);    // NOI18N
                _scheduleHead.add(_scheduleNode);
                for (Train tr : _dataMgr.getTrains(c.getScheduleId(), 0, true)) {
                    _trainNode = new TimeTableTreeNode(buildNodeText("Train", tr, 0), "Train", tr.getTrainId(), 0);    // NOI18N
                    _scheduleNode.add(_trainNode);
                    for (Stop sp : _dataMgr.getStops(tr.getTrainId(), 0, true)) {
                        _leafNode = new TimeTableTreeNode(buildNodeText("Stop", sp, 0), "Stop", sp.getStopId(), sp.getSeq());    // NOI18N
                        _trainNode.add(_leafNode);
                    }
                }
            }
        }
    }

    /**
     * Create the localized node text display strings based on node type.
     *
     * @param nodeType  The type of the node
     * @param component The object or child object
     * @param idx       Optional index value
     * @return nodeText containing the text to display on the node
     */
    String buildNodeText(String nodeType, Object component, int idx) {
        switch (nodeType) {
            case "TrainTypes":
                return Bundle.getMessage("LabelTrainTypes");  // NOI18N
            case "Segments":
                return Bundle.getMessage("LabelSegments");  // NOI18N
            case "Schedules":
                return Bundle.getMessage("LabelSchedules");  // NOI18N
            case "Schedule":
                Schedule schedule = (Schedule) component;
                return Bundle.getMessage("LabelSchedule", schedule.getScheduleName(), schedule.getEffDate());  // NOI18N
            case "Train":
                Train train = (Train) component;
                return Bundle.getMessage("LabelTrain", train.getTrainName(), train.getTrainDesc());  // NOI18N
            case "Stop":
                Stop stop = (Stop) component;
                int stationId = stop.getStationId();
                return Bundle.getMessage("LabelStop", stop.getSeq(), _dataMgr.getStation(stationId).getStationName());  // NOI18N
            default:
                return "None";  // NOI18N
        }
    }

    /**
     * Change the button row based on the currently selected node type. Invoke
     * edit where appropriate.
     *
     * @param selectedNode The node object
     */
    void treeRowSelected(TimeTableTreeNode selectedNode) {
        // Set the current node variables
        _curNode = selectedNode;
        _curNodeId = selectedNode.getId();
        _curNodeType = selectedNode.getType();
        _curNodeText = selectedNode.getText();
        _curNodeRow = selectedNode.getRow();

        // Reset button bar
        _addButtonPanel.setVisible(false);
        _duplicateButtonPanel.setVisible(false);
        _copyButtonPanel.setVisible(false);
        _deleteButtonPanel.setVisible(false);
        _moveButtonPanel.setVisible(false);
        _graphButtonPanel.setVisible(false);

        switch (_curNodeType) {
            case "Layout":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddLayoutButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);
                _duplicateButton.setText(Bundle.getMessage("DuplicateLayoutButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteLayoutButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                editPressed();
                break;

            case "TrainTypes":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddTrainTypeButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);
                makeDetailGrid(EMPTY_GRID);  // NOI18N
                break;

            case "TrainType":     // NOI18N
                _duplicateButton.setText(Bundle.getMessage("DuplicateTrainTypeButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteTrainTypeButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                editPressed();
                break;

            case "Segments":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddSegmentButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);
                makeDetailGrid(EMPTY_GRID);  // NOI18N
                break;

            case "Segment":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddStationButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);
                _duplicateButton.setText(Bundle.getMessage("DuplicateSegmentButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteSegmentButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                _graphButtonPanel.setVisible(true);
                editPressed();
                break;

            case "Station":     // NOI18N
                _duplicateButton.setText(Bundle.getMessage("DuplicateStationButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteStationButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                editPressed();
                break;

            case "Schedules":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddScheduleButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);
                makeDetailGrid(EMPTY_GRID);  // NOI18N
                break;

            case "Schedule":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddTrainButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);
                _duplicateButton.setText(Bundle.getMessage("DuplicateScheduleButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteScheduleButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                editPressed();
                break;

            case "Train":     // NOI18N
                _addButton.setText(Bundle.getMessage("AddStopButtonText"));  // NOI18N
                _addButtonPanel.setVisible(true);

                var stops = _dataMgr.getStops(_curNodeId, 0, false);
                if (stops.size() == 0) {
                    _copyButtonPanel.setVisible(true);
                }

                _duplicateButton.setText(Bundle.getMessage("DuplicateTrainButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteTrainButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                editPressed();
                break;

            case "Stop":     // NOI18N
                _duplicateButton.setText(Bundle.getMessage("DuplicateStopButtonText"));  // NOI18N
                _duplicateButtonPanel.setVisible(true);
                _deleteButton.setText(Bundle.getMessage("DeleteStopButtonText"));  // NOI18N
                _deleteButtonPanel.setVisible(true);
                editPressed();
                break;

            default:
                log.warn("Should not be here");  // NOI18N
        }
    }

    /**
     * Display reminder to save.
     */
    void showNodeEditMessage() {
        if (InstanceManager.getNullableDefault(jmri.UserPreferencesManager.class) != null) {
            InstanceManager.getDefault(jmri.UserPreferencesManager.class).
                    showInfoMessage( this, Bundle.getMessage("NodeEditTitle"), // NOI18N
                            Bundle.getMessage("NodeEditText"), // NOI18N
                            getClassName(),
                            "SkipTimeTableEditMessage"); // NOI18N
        }
    }

    /**
     * Set/clear dirty flag and save button
     * @param dirty True if changes have been made that are not saved.
     */
    public void setShowReminder(boolean dirty) {
        _isDirty = dirty;
        _saveButton.setEnabled(dirty);
    }

    /**
     * Enable/disable buttons based on edit state.
     * The edit state controls the ability to select tree nodes.
     *
     * @param active True to make edit active, false to make edit inactive
     */
    void setEditMode(boolean active) {
        _editActive = active;
        _cancelAction.setEnabled(active);
        _updateAction.setEnabled(active);
        _addButton.setEnabled(!active);
        _deleteButton.setEnabled(!active);
        if (_curNodeType != null && _curNodeType.equals("Stop")) {  // NOI18N
            setMoveButtons();
        }
    }

    /**
     * Timetable Tree Node Definition.
     */
    static class TimeTableTreeNode extends DefaultMutableTreeNode {

        private String ttText;
        private String ttType;
        private int ttId;
        private int ttRow;

        public TimeTableTreeNode(String nameText, String type, int sysId, int row) {
            this.ttText = nameText;
            this.ttType = type;
            this.ttId = sysId;
            this.ttRow = row;
        }

        public String getType() {
            return ttType;
        }

        public int getId() {
            return ttId;
        }

        public void setId(int newId) {
            ttId = newId;
        }

        public int getRow() {
            return ttRow;
        }

        public void setRow(int newRow) {
            ttRow = newRow;
        }

        public String getText() {
            return ttText;
        }

        public void setText(String newText) {
            ttText = newText;
        }

        @Override
        public String toString() {
            return ttText;
        }
    }

    protected String getClassName() {
        return TimeTableFrame.class.getName();
    }

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TimeTableFrame.class);
}