001package jmri.jmrit.z21server;
002
003import java.awt.BorderLayout;
004import java.awt.Color;
005import java.awt.event.ActionEvent;
006import java.beans.PropertyChangeEvent;
007import java.beans.PropertyChangeListener;
008import java.util.ArrayList;
009import java.util.List;
010import java.util.regex.Pattern;
011
012import javax.annotation.Nonnull;
013import javax.swing.*;
014import javax.swing.event.TableModelEvent;
015import javax.swing.event.TableModelListener;
016import javax.swing.table.*;
017
018import org.slf4j.Logger;
019import org.slf4j.LoggerFactory;
020
021import jmri.*;
022import jmri.util.JmriJFrame;
023
024/**
025 * This class handles the turnout number mapping window.
026 * It contains multiple tabs, one for each supported component type (turnout, light, etc...)
027 * 
028 * Each tab display all components of that type with a column containing a Z21 turnout number,
029 * which can be edited by the user.
030 * 
031 * @author Eckart Meyer Copyright (C) 2025
032 * 
033 * Inspired from jmri.jmrit.withrottle.ControllerFilterFrame.
034 */
035public class NumberMapFrame extends JmriJFrame implements TableModelListener {
036
037    private static final String[] COLUMN_NAMES = {
038        Bundle.getMessage("ColumnSystemName"), //from jmri.jmrit.Bundle
039        Bundle.getMessage("ColumnUserName"), //from jmri.jmrit.Bundle
040        Bundle.getMessage("ColumnTurnoutNumber")};
041
042    private final List<JTable> tablelList = new ArrayList<>(); //one JTable for each component type
043
044/**
045 * Constructor.
046 * Set the windows title.
047 */
048    public NumberMapFrame() {
049        super(Bundle.getMessage("TitleNumberMapFrame"), true, true);
050    }
051
052/**
053 * Build the frame.
054 * Add a tab for each JMRI component types (turnout, light, etc.)
055 */
056    @Override
057    public void initComponents() {
058        JTabbedPane tabbedPane = new JTabbedPane();
059        
060        // NOTE: This list should match the classes used in TurnoutNumberMapHandler.java
061        addTab(Turnout.class, TurnoutManager.class, "Turnouts", "ToolTipTurnoutTab", "LabelTurnoutTab", tabbedPane);
062        addTab(Route.class, RouteManager.class, "Routes", "ToolTipRouteTab", "LabelRouteTab", tabbedPane);
063        addTab(Light.class, LightManager.class, "Lights", "ToolTipLightTab", "LabelLightTab", tabbedPane);
064        addTab(SignalMast.class, SignalMastManager.class, "SignalMasts", "ToolTipSignalMastTab", "LabelSignalMastTab", tabbedPane);
065        addTab(SignalHead.class, SignalHeadManager.class, "SignalHeads", "ToolTipSignalHeadTab", "LabelSignalHeadTab", tabbedPane);
066        addTab(Sensor.class, SensorManager.class, "Sensors", "ToolTipSensorTab", "LabelSensorTab", tabbedPane);
067
068        add(tabbedPane);
069
070        pack();
071
072        addHelpMenu("package.jmri.jmrit.z21server.z21server", true);
073    }
074    
075/**
076 * Build a tab for a given component type.
077 * 
078 * @param <T>
079 * @param type - component type such as Turnout.class, Light.class
080 * @param mgrType - component manager type such as TurnoutManager.class, LightManager.class
081 * @param tabName - name on the tab
082 * @param tabToolTip - tool tip for the tab
083 * @param tabLabel - text displayed as the description of the table in the tab
084 * @param tabbedPane - the pane to which to add the tab
085 */
086    @SuppressWarnings("unchecked")
087    private <T extends NamedBean> void addTab(@Nonnull Class<T> type, @Nonnull Class<?> mgrType, String tabName, String tabToolTip, String tabLabel, JTabbedPane tabbedPane ) {
088        
089        
090        Manager<T> mgr = (Manager<T>)InstanceManager.getNullableDefault(mgrType);
091        if (mgr == null) {
092            return;
093        }
094        
095        JPanel tPanel = new JPanel(new BorderLayout());
096        JLabel label = new JLabel(Bundle.getMessage(tabLabel), SwingConstants.CENTER);
097        tPanel.add(label, BorderLayout.NORTH);
098        tPanel.add(addCancelSavePanel(), BorderLayout.WEST);
099
100        JLabel messageField = new JLabel();
101        final MapTableModel<T, Manager<T>> mapTableModel = new MapTableModel<>(mgrType, messageField);
102        JTable table = new JTable(mapTableModel);
103        tablelList.add(table);
104        mapTableModel.setTable(table);
105        
106        buildTable(table);
107
108        JScrollPane scrollPane = new JScrollPane(table);
109        tPanel.add(scrollPane, BorderLayout.CENTER);
110
111        tPanel.add(addButtonsPanel(messageField, mapTableModel), BorderLayout.SOUTH);
112
113        tabbedPane.addTab(Bundle.getMessage(tabName), null, tPanel, Bundle.getMessage(tabToolTip));
114    }
115
116/**
117 * Build a table. Identical for all types.
118 * 
119 * @param table - given table object
120 */
121    private void buildTable(JTable table) {
122        table.getModel().addTableModelListener(this);
123
124        //table.setRowSelectionAllowed(false);
125        table.setPreferredScrollableViewportSize(new java.awt.Dimension(580, 240));
126
127        //table.getTableHeader().setBackground(Color.lightGray);
128        //table.setShowGrid(false);
129        table.setShowHorizontalLines(true);
130        table.setGridColor(Color.gray);
131        //table.setRowHeight(30);
132        table.setAutoCreateRowSorter(true);
133        
134        TableColumnModel columnModel = table.getColumnModel();
135
136        TableColumn tNumber = columnModel.getColumn(MapTableModel.TNUMCOL);
137        tNumber.setResizable(false);
138        tNumber.setMinWidth(60);
139        tNumber.setMaxWidth(200);
140
141        TableColumn sName = columnModel.getColumn(MapTableModel.SNAMECOL);
142        sName.setResizable(true);
143        sName.setMinWidth(80);
144        sName.setPreferredWidth(80);
145        sName.setMaxWidth(340);
146
147        TableColumn uName = columnModel.getColumn(MapTableModel.UNAMECOL);
148        uName.setResizable(true);
149        uName.setMinWidth(180);
150        uName.setPreferredWidth(300);
151        uName.setMaxWidth(440);
152    }
153
154/**
155 * Construct a pane with some elements to be placed under the table.
156 * 
157 * @param messageField - message field for error messages as JLabel
158 * @param fm - the table model to be used with action events when a button is pressed.
159 * @return a new panel containing the new elements.
160 */
161    private JPanel addButtonsPanel(JLabel messageField, final MapTableModel<?,?> fm) {
162        JPanel pane = new JPanel();
163        pane.setLayout(new BoxLayout(pane, BoxLayout.X_AXIS));
164        pane.add(Box.createHorizontalGlue());
165
166        pane.add(messageField);
167        pane.add(Box.createHorizontalStrut(10));
168        JButton removeAllButton = new JButton(Bundle.getMessage("ButtonRemoveAll"));
169        removeAllButton.addActionListener((ActionEvent event) -> {
170            fm.removeAllMapNumbers();
171        });
172        pane.add(removeAllButton);
173
174        return pane;
175    }
176
177/**
178 * Construct a panel containing a Cancel button and a Save button.
179 * 
180 * @return a new panel containing the buttons.
181 */
182    private JPanel addCancelSavePanel() {
183        JPanel p = new JPanel();
184        p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
185        p.add(Box.createVerticalGlue());
186
187        JButton cancelButton = new JButton(Bundle.getMessage("ButtonCancel"));
188        cancelButton.setAlignmentX(CENTER_ALIGNMENT);
189        cancelButton.setToolTipText(Bundle.getMessage("ToolTipCancel"));
190        cancelButton.addActionListener((ActionEvent event) -> {
191            dispose();
192        });
193        p.add(cancelButton);
194
195        JButton saveButton = new JButton(Bundle.getMessage("ButtonSave"));
196        saveButton.setAlignmentX(CENTER_ALIGNMENT);
197        saveButton.setToolTipText(Bundle.getMessage("ToolTipSave"));
198        saveButton.addActionListener((ActionEvent event) -> {
199            storeValues();
200            dispose();
201        });
202        p.add(saveButton);
203
204        return p;
205    }
206
207/**
208 * Store the full XML file to disk.
209 */
210    @Override
211    protected void storeValues() {
212        new jmri.configurexml.StoreXmlUserAction().actionPerformed(null);
213    }
214
215/**
216 * Event handler for table changes. Set the frame modified.
217 * @param e - table model event
218 */
219    @Override
220    public void tableChanged(TableModelEvent e) {
221        if (log.isDebugEnabled()) {
222            log.debug("Set mod flag true for: {}", getTitle());
223        }
224        this.setModifiedFlag(true);
225    }
226    
227/**
228 * Called when the window closes.
229 * Shut down all table model instances to free resources.
230 */
231    @Override
232    public void dispose() {
233        log.trace("dispose - remove table models and its listeners from ");
234        for (JTable t : tablelList) {
235            MapTableModel<?,?> model = (MapTableModel<?,?>)t.getModel();
236            model.dispose();
237        }
238        tablelList.clear();
239        super.dispose();
240    }
241    
242/**
243 * Internal TableModel class.
244 * There will be one instance for each component type (turnout, light, etc.)
245 * The Table Model is identical for all types, but uses different classes.
246 * 
247 * @param <E> - type of component, e.g. Turnout, Light
248 * @param <M> - type of component manager, e.g. TurnoutManager, LightManager.
249 */
250    private static class MapTableModel<E extends NamedBean, M extends Manager<E>> extends AbstractTableModel implements PropertyChangeListener {
251        
252        private final Manager<E> mgr;
253        private final JLabel messageField;
254        private JTable table;
255        private String lastInvalid = null; //to prevent endless loop
256
257/**
258 * Constructor.
259 * Get and save manager object
260 * Fill list with system names
261 * 
262 * @param mgrType - component manager class, e.g. TurnoutManager.class
263 * @param messageField - JLabel field to write messages to
264 */
265        @SuppressWarnings("unchecked")
266        MapTableModel(Class<?> mgrType, JLabel messageField) {
267            mgr = (Manager<E>)InstanceManager.getDefault(mgrType);
268            sysNameList = new java.util.ArrayList<>(mgr.getNamedBeanSet().size());
269            mgr.getNamedBeanSet().forEach(bean -> {
270                sysNameList.add(bean.getSystemName());
271            });
272            mgr.addPropertyChangeListener(this);
273            this.messageField = messageField;
274        }
275        
276/**
277 * Set the corresponding JTable object, so we can reset the tables field values.
278 * 
279 * @param table table
280 */
281        public void setTable(JTable table) {
282            this.table = table; //used to rollback invalid map values
283        }
284        
285/**
286 * The model takes the value and fills the table field.
287 * 
288 * @param r - table row
289 * @param c - table column
290 * @return the value to display in the table field
291 */
292        @Override
293        public Object getValueAt(int r, int c) {
294
295            // some error checking
296            if (r >= sysNameList.size()) {
297                log.debug("row is greater than list size");
298                return null;
299            }
300            E t = mgr.getBySystemName(sysNameList.get(r));
301            switch (c) {
302                case TNUMCOL:
303                    if (t != null) {
304                        Object o = t.getProperty(TurnoutNumberMapHandler.beanProperty);
305                        if (o != null) {
306                            return o.toString();
307                        }
308                    }
309                    return null;
310                case SNAMECOL:
311                    return sysNameList.get(r);
312                case UNAMECOL:
313                    return t != null ? t.getUserName() : null;
314                default:
315                    return null;
316            }
317        }
318
319/**
320 * The model calls this function with the new value set by the user.
321 * Only the mapping value (this is the Z21 Turnout Number) can be edited.
322 * The new value is validated. If it is valid, it will be written as a
323 * property of the bean.
324 * If it is invalid, the previous value will be set. If that also fails,
325 * The field contents and the bean property will be removed.
326 * 
327 * @param type - the value to set. Always as String here.
328 * @param r - table row
329 * @param c - table column
330 */
331        @Override
332        public void setValueAt(Object type, int r, int c) {
333            log.trace("field modified {}: row: {}, col: {}", type, r, c);
334            E t = mgr.getBySystemName(sysNameList.get(r));
335            if (t != null) {
336                switch (c) {
337                    case TNUMCOL:
338                        if (type == null  ||  type.toString().isEmpty()) {
339                            t.removeProperty(TurnoutNumberMapHandler.beanProperty);
340                            lastInvalid = null;
341                            messageField.setText(null);
342                        }
343                        else {
344                            log.trace("old value: {}, new value {}" , getValueAt(r, c), type);
345                            if (Pattern.matches("^(#.*|(\\d+))$", type.toString())) {
346                                t.setProperty(TurnoutNumberMapHandler.beanProperty, type);
347                                lastInvalid = null;
348                                messageField.setText(null);
349                            }
350                            else {
351                                log.warn("Invalid value: '{}'", type);
352                                if (lastInvalid.equals(getValueAt(r, c))) {
353                                    t.removeProperty(TurnoutNumberMapHandler.beanProperty); //remove value on double failure
354                                    lastInvalid = null;
355                                }
356                                else {
357                                    table.setValueAt(getValueAt(r, c), r, c); //rollback to old value
358                                    lastInvalid = type.toString(); //prevent double failure - would result in endless loop - just in case...
359                                    messageField.setText(Bundle.getMessage("MessageInvalidValue", type.toString()));
360                                }
361                            }
362
363                        }
364                        if (!isDirty) {
365                            this.fireTableChanged(new TableModelEvent(this));
366                            isDirty = true;
367                        }
368                        TurnoutNumberMapHandler.getInstance().propertyChange(new PropertyChangeEvent(this, "NumberMapChanged", null, t));
369                        break;
370                    default:
371                        log.warn("Unhandled col: {}", c);
372                        break;
373                }
374            }
375        }
376
377/**
378 * Remove all our bean properties. Then the table will be redrawn.
379 */
380        public void removeAllMapNumbers() {
381            for (String sysName : sysNameList) {
382                E t = mgr.getBySystemName(sysName);
383                if (t != null) {
384                    t.removeProperty(TurnoutNumberMapHandler.beanProperty);
385                }
386            }
387            messageField.setText(null);
388            fireTableDataChanged();
389        }
390
391
392
393        List<String> sysNameList = null;
394        boolean isDirty;
395
396/**
397 * Return the field class of each column. All types will be returned as String.class
398 * 
399 * @param c - column
400 * @return field class
401 */
402        @Override
403        public Class<?> getColumnClass(int c) {
404            return String.class;
405        }
406
407/**
408 * Called if the manager instance has changes.
409 * Always rebuild the table contents.
410 * 
411 * @param e - the change event
412 */
413        @Override
414        public void propertyChange(java.beans.PropertyChangeEvent e) {
415            log.trace("property changed: {}", e.getPropertyName());
416            fireTableDataChanged();
417        }
418
419/**
420 * Before the model is deleted, remove the model instance from the manager instance property change listener list.
421 */
422        public void dispose() {
423            log.trace("dispose MapTableModel - remove listeners from {}", mgr.getClass().getName());
424            mgr.removePropertyChangeListener(this);
425        }
426
427/**
428 * Get the column names displayed in the header line.
429 * 
430 * @param c - column
431 * @return header text for the column
432 */
433        @Override
434        public String getColumnName(int c) {
435            return COLUMN_NAMES[c];
436        }
437
438/**
439 * We have three columns
440 * 
441 * @return number of columns
442 */
443        @Override
444        public int getColumnCount() {
445            return 3;
446        }
447
448/**
449 * Get the current row count - that is also the length of out system name list.
450 * 
451 * @return current row count
452 */
453        @Override
454        public int getRowCount() {
455            return sysNameList.size();
456        }
457
458/**
459 * Only the map value column is editable
460 * 
461 * @param r - row (not used)
462 * @param c - column
463 * @return true for the map value column, false for all others
464 */
465        @Override
466        public boolean isCellEditable(int r, int c) {
467            return (c == TNUMCOL);
468        }
469
470        public static final int SNAMECOL = 0;
471        public static final int UNAMECOL = 1;
472        public static final int TNUMCOL = 2;
473    }
474    
475    private final static Logger log = LoggerFactory.getLogger(NumberMapFrame.class);
476
477}