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}