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