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}