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}