001package jmri.jmrix.dccpp.swing.exrail; 002 003import java.awt.BorderLayout; 004import java.awt.Color; 005import java.awt.Component; 006import java.awt.Dimension; 007import java.awt.Font; 008import java.beans.PropertyChangeListener; 009import java.util.ArrayDeque; 010import java.util.ArrayList; 011import java.util.LinkedHashMap; 012import java.util.List; 013import java.util.Map; 014import java.util.OptionalInt; 015import java.util.Queue; 016 017import javax.swing.JButton; 018import javax.swing.JScrollPane; 019import javax.swing.JTable; 020import javax.swing.JToggleButton; 021import javax.swing.table.AbstractTableModel; 022import javax.swing.table.TableCellRenderer; 023 024import jmri.jmrix.ConnectionStatus; 025import jmri.jmrix.dccpp.DCCppConstants; 026import jmri.jmrix.dccpp.DCCppExrailEntry; 027import jmri.jmrix.dccpp.DCCppInterface; 028import jmri.jmrix.dccpp.DCCppListener; 029import jmri.jmrix.dccpp.DCCppMessage; 030import jmri.jmrix.dccpp.DCCppReply; 031import jmri.jmrix.dccpp.DCCppSystemConnectionMemo; 032import jmri.jmrix.dccpp.DCCppTrafficController; 033import jmri.util.JmriJFrame; 034import jmri.util.ThreadingUtil; 035import jmri.util.swing.JmriJOptionPane; 036import jmri.util.table.ButtonEditor; 037 038/** 039 * Displays DCC-EX EXRAIL Routes and Automations and allows triggering them. 040 * 041 * @author Chad Francis Copyright (C) 2026 042 */ 043public class DCCppExrailFrame extends JmriJFrame implements DCCppListener { 044 045 private static final int COL_ID = 0; 046 private static final int COL_NAME = 1; 047 private static final int COL_TRIGGER = 2; 048 private static final int COL_TYPE = 3; 049 050 private final DCCppTrafficController _tc; 051 private final DCCppSystemConnectionMemo _memo; 052 053 private final Map<Integer, DCCppExrailEntry> _entries = new LinkedHashMap<>(); 054 private final Queue<Integer> _pendingIds = new ArrayDeque<>(); 055 private ExrailTableModel _tableModel; 056 private JTable _table; 057 private PropertyChangeListener _connListener; 058 private String _lastLocoAddress; // last valid address entered this session; null until first successful trigger 059 060 public DCCppExrailFrame(DCCppSystemConnectionMemo memo) { 061 super(true, false); // save position; let user resize freely 062 _memo = memo; 063 _tc = memo.getDCCppTrafficController(); 064 _tableModel = new ExrailTableModel(); 065 setTitle(Bundle.getMessage("ExrailFrameTitle") + " (" + _memo.getSystemPrefix() + ")"); 066 } 067 068 @Override 069 public void initComponents() { 070 super.initComponents(); 071 _table = new JTable(_tableModel) { 072 @Override 073 public Component prepareRenderer(javax.swing.table.TableCellRenderer renderer, int row, int column) { 074 Component c = super.prepareRenderer(renderer, row, column); 075 if (column != COL_TRIGGER) { 076 c.setBackground(row % 2 == 0 ? Color.WHITE : new Color(240, 240, 240)); 077 } 078 return c; 079 } 080 }; 081 _table.setFillsViewportHeight(true); 082 _table.setPreferredScrollableViewportSize(new Dimension(560, 200)); 083 _table.setAutoCreateRowSorter(true); 084 _table.setRowSelectionAllowed(false); 085 _table.setColumnSelectionAllowed(false); 086 _table.setCellSelectionEnabled(false); 087 088 _table.getColumnModel().getColumn(COL_ID).setPreferredWidth(40); 089 _table.getColumnModel().getColumn(COL_ID).setMaxWidth(60); 090 _table.getColumnModel().getColumn(COL_TYPE).setPreferredWidth(90); 091 _table.getColumnModel().getColumn(COL_TYPE).setMaxWidth(110); 092 093 _table.getTableHeader().setFont( 094 _table.getTableHeader().getFont().deriveFont(Font.BOLD)); 095 096 // Active entries show the trigger button pressed; disabled entries gray it out. 097 _table.setDefaultRenderer(TriggerCellValue.class, new TriggerRenderer()); 098 _table.setDefaultEditor(TriggerCellValue.class, new ButtonEditor(new JButton())); 099 JToggleButton sample = new JToggleButton(Bundle.getMessage("ExrailButtonSet")); 100 _table.setRowHeight(sample.getPreferredSize().height); 101 _table.getColumnModel().getColumn(COL_TRIGGER).setPreferredWidth(80); 102 103 _tc.addDCCppListener(DCCppInterface.FEEDBACK, this); 104 _tc.sendDCCppMessage(DCCppMessage.makeAutomationIDsMsg(), this); 105 106 _connListener = evt -> { 107 if (ConnectionStatus.CONNECTION_UP.equals( 108 ConnectionStatus.instance().getConnectionState(_memo))) { 109 _entries.clear(); 110 _pendingIds.clear(); 111 _tc.sendDCCppMessage(DCCppMessage.makeAutomationIDsMsg(), this); 112 ThreadingUtil.runOnGUI(this::redrawTable); 113 } 114 }; 115 ConnectionStatus.instance().addPropertyChangeListener(_memo, _connListener); 116 117 getContentPane().setLayout(new BorderLayout()); 118 getContentPane().add(new JScrollPane(_table), BorderLayout.CENTER); 119 pack(); 120 if (getX() == 0 && getY() == 0) { 121 setLocationRelativeTo(null); // center on first-ever open 122 } 123 } 124 125 private void redrawTable() { 126 _tableModel.fireTableDataChanged(); 127 } 128 129 /** Prompt for loco address if needed and fire the entry. */ 130 private void handleRowTrigger(DCCppExrailEntry entry) { 131 if (entry == null) return; 132 if (!isDisplayable()) return; // window already closing 133 if (entry.isAutomation()) { 134 OptionalInt addr = promptLocoAddress(); 135 if (addr.isPresent()) triggerEntry(entry, addr.getAsInt()); 136 } else { 137 triggerEntry(entry, 0); 138 } 139 } 140 141 /** Prompts repeatedly until a valid loco address is entered or the user cancels. */ 142 private OptionalInt promptLocoAddress() { 143 while (true) { 144 String input = (String) JmriJOptionPane.showInputDialog(this, 145 Bundle.getMessage("ExrailLabelLocoAddress"), 146 Bundle.getMessage("ExrailTitleLocoDialog"), 147 JmriJOptionPane.QUESTION_MESSAGE, 148 null, null, _lastLocoAddress); 149 if (input == null) return OptionalInt.empty(); 150 try { 151 int addr = Integer.parseInt(input.trim()); 152 if (addr >= 1 && addr <= DCCppConstants.MAX_LOCO_ADDRESS) { 153 _lastLocoAddress = input.trim(); 154 return OptionalInt.of(addr); 155 } 156 } catch (NumberFormatException ex) { // invalid input — fall through to error dialog 157 } 158 showLocoAddressError(); 159 } 160 } 161 162 /** Shows an error dialog when the entered loco address is out of range or non-numeric. */ 163 private void showLocoAddressError() { 164 JmriJOptionPane.showMessageDialog(this, 165 Bundle.getMessage("ExrailLocoAddressInvalid", DCCppConstants.MAX_LOCO_ADDRESS), 166 Bundle.getMessage("ExrailLocoAddressInvalidTitle"), 167 JmriJOptionPane.ERROR_MESSAGE); 168 } 169 170 /** Returns the last loco address entered this session; used by tests. */ 171 String getLastLocoAddress() { 172 return _lastLocoAddress; 173 } 174 175 void triggerEntry(DCCppExrailEntry entry, int locoAddress) { 176 if (entry.isAutomation()) { 177 log.debug("Triggering EXRAIL automation id={} locoAddress={}", entry.getId(), locoAddress); 178 _tc.sendDCCppMessage(DCCppMessage.makeStartExrailMsg(entry.getId(), locoAddress), null); 179 } else { 180 log.debug("Triggering EXRAIL route id={}", entry.getId()); 181 _tc.sendDCCppMessage(DCCppMessage.makeStartExrailMsg(entry.getId()), null); 182 } 183 } 184 185 private void requestNextId() { 186 Integer id = _pendingIds.poll(); 187 if (id != null) { 188 _tc.sendDCCppMessage(DCCppMessage.makeAutomationIDMsg(id), this); 189 } 190 } 191 192 @Override 193 public void message(DCCppReply reply) { 194 if (reply.isAutomationIDsReply()) { 195 for (int id : reply.getAutomationIDList()) { 196 if (!_entries.containsKey(id) && !_pendingIds.contains(id)) { 197 _pendingIds.add(id); 198 } 199 } 200 requestNextId(); 201 } else if (reply.isAutomationIDReply()) { 202 int id = reply.getAutomationIDInt(); 203 _entries.put(id, new DCCppExrailEntry(id, reply.getAutomationTypeString(), reply.getAutomationDescString())); 204 ThreadingUtil.runOnGUIEventually(this::redrawTable); 205 requestNextId(); 206 } else if (reply.isAutomationStateReply()) { 207 DCCppExrailEntry entry = _entries.get(reply.getAutomationIDInt()); 208 if (entry != null) { 209 entry.setState(DCCppExrailEntry.State.fromValue(Integer.parseInt(reply.getAutomationStateString()))); 210 ThreadingUtil.runOnGUIEventually(this::redrawTable); 211 } 212 } else if (reply.isAutomationCaptionReply()) { 213 DCCppExrailEntry entry = _entries.get(reply.getAutomationIDInt()); 214 if (entry != null) { 215 entry.setCaption(reply.getAutomationCaptionString()); 216 ThreadingUtil.runOnGUIEventually(this::redrawTable); 217 } 218 } 219 } 220 221 @Override 222 public void message(DCCppMessage msg) {} 223 224 @Override 225 public void notifyTimeout(DCCppMessage msg) {} 226 227 @Override 228 public void dispose() { 229 if (_table != null && _table.isEditing()) { 230 _table.getCellEditor().cancelCellEditing(); // prevent spurious trigger on window close 231 } 232 if (_connListener != null) { 233 ConnectionStatus.instance().removePropertyChangeListener(_memo, _connListener); 234 _connListener = null; 235 } 236 _tc.removeDCCppListener(DCCppInterface.FEEDBACK, this); 237 super.dispose(); 238 } 239 240 /** Returns number of visible (non-hidden) entries; used by tests. */ 241 int getEntryCount() { 242 return _tableModel.getRowCount(); 243 } 244 245 /** Returns entry by id; used by tests. */ 246 DCCppExrailEntry getEntry(int id) { 247 return _entries.get(id); 248 } 249 250 /** Returns whether the trigger button on the given visible row is enabled; used by tests. */ 251 boolean isRowTriggerEnabled(int row) { 252 return _tableModel.isCellEditable(row, COL_TRIGGER); 253 } 254 255 /** Returns the trigger button label for the given visible row; used by tests. */ 256 String getRowButtonLabel(int row) { 257 Object val = _tableModel.getValueAt(row, COL_TRIGGER); 258 return val instanceof TriggerCellValue ? ((TriggerCellValue) val).label : ""; 259 } 260 261 /** Returns the Name column value for the given visible row; used by tests. */ 262 String getRowName(int row) { 263 return (String) _tableModel.getValueAt(row, COL_NAME); 264 } 265 266 /** Simulate a click on the trigger button for the given visible row; used by tests. */ 267 void triggerRowForTest(int row) { 268 _tableModel.setValueAt(null, row, COL_TRIGGER); 269 } 270 271 private static final class TriggerCellValue { 272 final String label; 273 final boolean active; 274 final boolean enabled; 275 TriggerCellValue(String label, boolean active, boolean enabled) { 276 this.label = label; this.active = active; this.enabled = enabled; 277 } 278 @Override public String toString() { return label; } 279 } 280 281 private static final class TriggerRenderer extends JToggleButton implements TableCellRenderer { 282 283 TriggerRenderer() { 284 setOpaque(true); 285 putClientProperty("JComponent.sizeVariant", "small"); 286 putClientProperty("JButton.buttonType", "square"); 287 } 288 289 @Override 290 public Component getTableCellRendererComponent(JTable table, Object value, 291 boolean isSelected, boolean hasFocus, int row, int column) { 292 if (value instanceof TriggerCellValue) { 293 TriggerCellValue v = (TriggerCellValue) value; 294 setText(v.label); 295 setSelected(v.active); 296 setEnabled(v.enabled); 297 } 298 return this; 299 } 300 } 301 302 private class ExrailTableModel extends AbstractTableModel { 303 304 private final String[] columns = { 305 Bundle.getMessage("ExrailColId"), 306 Bundle.getMessage("ExrailColName"), 307 "", // trigger button column, no header 308 Bundle.getMessage("ExrailColType"), 309 }; 310 311 /** Entries visible in the table — Hidden (state 2) are excluded. */ 312 private List<DCCppExrailEntry> visibleEntries() { 313 List<DCCppExrailEntry> list = new ArrayList<>(); 314 for (DCCppExrailEntry entry : _entries.values()) { 315 if (entry.getState() != DCCppExrailEntry.State.HIDDEN) list.add(entry); 316 } 317 return list; 318 } 319 320 DCCppExrailEntry getEntryForRow(int modelRow) { 321 List<DCCppExrailEntry> list = visibleEntries(); 322 if (modelRow < 0 || modelRow >= list.size()) return null; 323 return list.get(modelRow); 324 } 325 326 @Override 327 public int getRowCount() { return visibleEntries().size(); } 328 329 @Override 330 public int getColumnCount() { return columns.length; } 331 332 @Override 333 public String getColumnName(int col) { return columns[col]; } 334 335 @Override 336 public Class<?> getColumnClass(int col) { 337 if (col == COL_ID) return Integer.class; 338 if (col == COL_TRIGGER) return TriggerCellValue.class; 339 return String.class; 340 } 341 342 @Override 343 public boolean isCellEditable(int row, int col) { 344 if (col != COL_TRIGGER) return false; 345 DCCppExrailEntry entry = getEntryForRow(row); 346 if (entry == null) return false; 347 return entry.getState() != DCCppExrailEntry.State.DISABLED; 348 } 349 350 @Override 351 public Object getValueAt(int row, int col) { 352 List<DCCppExrailEntry> list = visibleEntries(); 353 if (row >= list.size()) return ""; 354 DCCppExrailEntry entry = list.get(row); 355 if (col == COL_ID) return entry.getId(); 356 if (col == COL_NAME) return entry.getDescription(); 357 if (col == COL_TRIGGER) { 358 boolean active = entry.getState() == DCCppExrailEntry.State.ACTIVE; 359 boolean enabled = entry.getState() != DCCppExrailEntry.State.DISABLED; 360 String label = entry.getCaption() != null ? entry.getCaption() : Bundle.getMessage("ExrailButtonSet"); 361 return new TriggerCellValue(label, active, enabled); 362 } 363 if (col == COL_TYPE) return entry.isRoute() 364 ? Bundle.getMessage("ExrailTypeRoute") 365 : Bundle.getMessage("ExrailTypeAutomation"); 366 return ""; 367 } 368 369 @Override 370 public void setValueAt(Object value, int row, int col) { 371 if (col != COL_TRIGGER) return; 372 DCCppExrailEntry entry = getEntryForRow(row); 373 if (entry == null) return; 374 ThreadingUtil.runOnGUIEventually(() -> handleRowTrigger(entry)); 375 } 376 } 377 378 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DCCppExrailFrame.class); 379}