001package jmri.jmrix.openlcb.swing.eventtable;
002
003import java.awt.*;
004import java.awt.event.*;
005import java.beans.*;
006import java.nio.charset.StandardCharsets;
007import java.io.*;
008import java.util.*;
009
010import javax.swing.*;
011import javax.swing.table.*;
012
013import jmri.*;
014import jmri.jmrix.can.CanSystemConnectionMemo;
015import jmri.jmrix.openlcb.*;
016import jmri.util.ThreadingUtil;
017
018import jmri.swing.JmriJTablePersistenceManager;
019import jmri.util.swing.MultiLineCellRenderer;
020
021import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
022
023import org.apache.commons.csv.CSVFormat;
024import org.apache.commons.csv.CSVPrinter;
025import org.apache.commons.csv.CSVRecord;
026
027import org.openlcb.*;
028import org.openlcb.implementations.*;
029import org.openlcb.swing.*;
030
031
032/**
033 * Pane for displaying a table of relationships of nodes, producers and consumers
034 *
035 * @author Bob Jacobsen Copyright (C) 2023
036 * @since 5.3.4
037 */
038public class EventTablePane extends jmri.util.swing.JmriPanel
039        implements jmri.jmrix.can.swing.CanPanelInterface {
040
041    protected CanSystemConnectionMemo memo;
042    Connection connection;
043    NodeID nid;
044    OlcbEventNameStore nameStore;
045    OlcbNodeGroupStore groupStore;
046
047    MimicNodeStore mimcStore;
048    EventTableDataModel model;
049    JTable table;
050    Monitor monitor;
051
052    JComboBox<String> matchGroupName;   // required group name to display; index <= 0 is all
053    JCheckBox showRequiresLabel; // requires a user-provided name to display
054    JCheckBox showRequiresMatch; // requires at least one consumer and one producer exist to display
055    JCheckBox popcorn;           // popcorn mode displays events in real time
056
057    JFormattedTextField findID;
058    JTextField findTextID;
059
060    private transient TableRowSorter<EventTableDataModel> sorter;
061
062    public String getTitle(String menuTitle) {
063        return Bundle.getMessage("TitleEventTable");
064    }
065
066    @Override
067    public void initComponents(CanSystemConnectionMemo memo) {
068        this.memo = memo;
069        this.connection = memo.get(Connection.class);
070        this.nid = memo.get(NodeID.class);
071        this.nameStore = memo.get(OlcbEventNameStore.class);
072        this.groupStore = InstanceManager.getDefault(OlcbNodeGroupStore.class);
073        this.mimcStore = memo.get(MimicNodeStore.class);
074        EventTable stdEventTable = memo.get(OlcbInterface.class).getEventTable();
075        if (stdEventTable == null) log.warn("no OLCB EventTable found");
076
077        model = new EventTableDataModel(mimcStore, stdEventTable, nameStore);
078        sorter = new TableRowSorter<>(model);
079
080
081        setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
082
083        // Add to GUI here
084
085        table = new JTable(model);
086
087        model.table = table;
088        model.sorter = sorter;
089        table.setAutoCreateRowSorter(true);
090        table.setRowSorter(sorter);
091        table.setDefaultRenderer(String.class, new MultiLineCellRenderer());
092        table.setShowGrid(true);
093        table.setGridColor(Color.BLACK);
094        table.getTableHeader().setBackground(Color.LIGHT_GRAY);
095        table.setName("jmri.jmrix.openlcb.swing.eventtable.EventTablePane.table"); // for persistence
096        table.setColumnSelectionAllowed(true);
097        table.setRowSelectionAllowed(true);
098        
099        // render in fixed size font
100        var defaultFont = table.getFont();
101        var fixedFont = new Font(Font.MONOSPACED, Font.PLAIN, defaultFont.getSize());
102        table.setFont(fixedFont);
103
104        var scrollPane = new JScrollPane(table);
105
106        // restore the column layout and start monitoring it
107        InstanceManager.getOptionalDefault(JmriJTablePersistenceManager.class).ifPresent((tpm) -> {
108            tpm.resetState(table);
109            tpm.persist(table);
110        });
111
112        add(scrollPane);
113
114        var buttonPanel = new JToolBar();
115        buttonPanel.setLayout(new jmri.util.swing.WrapLayout());
116
117        add(buttonPanel);
118
119        var updateButton = new JButton(Bundle.getMessage("ButtonUpdate"));
120        updateButton.addActionListener(this::sendRequestEvents); 
121        updateButton.setToolTipText(Bundle.getMessage("ButtonUpdateTt"));
122        buttonPanel.add(updateButton);
123        
124        matchGroupName = new JComboBox<>();
125        updateMatchGroupName();     // before adding listener
126        matchGroupName.addActionListener((ActionEvent e) -> {
127            filter();
128        });
129        groupStore.addPropertyChangeListener((PropertyChangeEvent evt) -> {
130            updateMatchGroupName();
131        });
132        buttonPanel.add(matchGroupName);
133        
134        showRequiresLabel = new JCheckBox(Bundle.getMessage("BoxShowRequiresLabel"));
135        showRequiresLabel.addActionListener((ActionEvent e) -> {
136            filter();
137        });
138        showRequiresLabel.setToolTipText(Bundle.getMessage("BoxShowRequiresLabelTt"));
139        showRequiresLabel.setOpaque(false); // make transparent
140        buttonPanel.add(showRequiresLabel);
141
142        showRequiresMatch = new JCheckBox(Bundle.getMessage("BoxShowRequiresMatch"));
143        showRequiresMatch.addActionListener((ActionEvent e) -> {
144            filter();
145        });
146        showRequiresMatch.setToolTipText(Bundle.getMessage("BoxShowRequiresMatchTt"));
147        showRequiresMatch.setOpaque(false); // make transparent
148        buttonPanel.add(showRequiresMatch);
149
150        popcorn = new JCheckBox(Bundle.getMessage("BoxPopcorn"));
151        popcorn.addActionListener((ActionEvent e) -> {
152            popcornButtonChanged();
153        });
154        popcorn.setToolTipText(Bundle.getMessage("BoxPopcornTt"));
155        popcorn.setOpaque(false); // make transparent
156        buttonPanel.add(popcorn);
157
158        JPanel findpanel = new JPanel(); // keep button and text together
159        findpanel.setOpaque(false); // make transparent
160        findpanel.setToolTipText(Bundle.getMessage("FindPanelFindEventTt"));
161        buttonPanel.add(findpanel);
162        
163        JLabel find = new JLabel(Bundle.getMessage("FindPanelFindEvent"));
164        findpanel.add(find);
165
166        findID = new EventIdTextField();
167        findID.setToolTipText(Bundle.getMessage("FindPanelFindEventFieldTt"));
168        findID.addActionListener(this::findRequested);
169        findID.addKeyListener(new KeyListener() {
170            @Override
171            public void keyTyped(KeyEvent keyEvent) {
172           }
173
174            @Override
175            public void keyReleased(KeyEvent keyEvent) {
176                // on release so the searchField has been updated
177                log.trace("keyTyped {} content {}", keyEvent.getKeyCode(), findTextID.getText());
178                findRequested(null);
179            }
180
181            @Override
182            public void keyPressed(KeyEvent keyEvent) {
183            }
184        });
185        findpanel.add(findID);
186        JButton addButton = new JButton(Bundle.getMessage("FindPanelButtonAdd"));
187        addButton.addActionListener(this::addRequested);
188        addButton.setToolTipText(Bundle.getMessage("FindPanelButtonAddTt"));        
189        findpanel.add(addButton);
190
191        findpanel = new JPanel();  // keep button and text together
192        findpanel.setOpaque(false); // make transparent
193        findpanel.setToolTipText(Bundle.getMessage("FindPanelFindNameTt"));
194        buttonPanel.add(findpanel);
195
196        JLabel findText = new JLabel(Bundle.getMessage("FindPanelFindName"));
197        findpanel.add(findText);
198
199        findTextID = new JTextField(16);
200        findTextID.addActionListener(this::findTextRequested);
201        findTextID.setToolTipText(Bundle.getMessage("FindPanelFindNameTt"));
202        findTextID.addKeyListener(new KeyListener() {
203            @Override
204            public void keyTyped(KeyEvent keyEvent) {
205           }
206
207            @Override
208            public void keyReleased(KeyEvent keyEvent) {
209                // on release so the searchField has been updated
210                log.trace("keyTyped {} content {}", keyEvent.getKeyCode(), findTextID.getText());
211                findTextRequested(null);
212            }
213
214            @Override
215            public void keyPressed(KeyEvent keyEvent) {
216            }
217        });
218        findpanel.add(findTextID);        
219
220        JButton sensorButton = new JButton(Bundle.getMessage("FindPanelButtonSensor"));
221        sensorButton.addActionListener(this::sensorRequested);
222        sensorButton.setToolTipText(Bundle.getMessage("FindPanelButtonSensorTt"));
223        buttonPanel.add(sensorButton);
224        
225        JButton turnoutButton = new JButton(Bundle.getMessage("FindPanelButtonTurnout"));
226        turnoutButton.addActionListener(this::turnoutRequested);
227        turnoutButton.setToolTipText(Bundle.getMessage("FindPanelButtonTurnoutTt"));
228        buttonPanel.add(turnoutButton);
229
230        buttonPanel.setMaximumSize(buttonPanel.getPreferredSize());
231
232        // hook up to receive traffic
233        monitor = new Monitor(model);
234        memo.get(OlcbInterface.class).registerMessageListener(monitor);
235    }
236
237    public EventTablePane() {
238        // interface and connections built in initComponents(..)
239    }
240    
241    // load updateMatchGroup combobox with current contents
242    protected void updateMatchGroupName() {
243        matchGroupName.removeAllItems();
244        matchGroupName.addItem(Bundle.getMessage("FrameAllGroups"));
245        
246        var list = groupStore.getGroupNames();
247        for (String group : list) {
248            matchGroupName.addItem(group);
249        }        
250
251        matchGroupName.setVisible(matchGroupName.getItemCount() > 1);
252    }
253
254    @Override
255    public void dispose() {
256        // Save the column layout
257        InstanceManager.getOptionalDefault(JmriJTablePersistenceManager.class).ifPresent((tpm) -> {
258           tpm.stopPersisting(table);
259        });
260        // remove traffic connection
261        memo.get(OlcbInterface.class).unRegisterMessageListener(monitor);
262        // drop model connections
263        model = null;
264        monitor = null;
265        // and complete this
266        super.dispose();
267    }
268
269    @Override
270    public java.util.List<JMenu> getMenus() {
271        // create a file menu
272        var retval = new ArrayList<JMenu>();
273        var fileMenu = new JMenu(Bundle.getMessage("PaneMenuFile"));
274        fileMenu.setMnemonic(KeyEvent.VK_F);
275        
276        var csvWriteItem = new JMenuItem(Bundle.getMessage("PaneSaveToCsv"), KeyEvent.VK_S);
277        KeyStroke ctrlSKeyStroke = KeyStroke.getKeyStroke("control S");
278        if (jmri.util.SystemType.isMacOSX()) {
279            ctrlSKeyStroke = KeyStroke.getKeyStroke("meta S");
280        }
281        csvWriteItem.setAccelerator(ctrlSKeyStroke);
282        csvWriteItem.addActionListener(this::writeToCsvFile);
283        fileMenu.add(csvWriteItem);
284        
285        var csvReadItem = new JMenuItem(Bundle.getMessage("PaneReadFromCsv"), KeyEvent.VK_O);
286        KeyStroke ctrlOKeyStroke = KeyStroke.getKeyStroke("control O");
287        if (jmri.util.SystemType.isMacOSX()) {
288            ctrlOKeyStroke = KeyStroke.getKeyStroke("meta O");
289        }
290        csvReadItem.setAccelerator(ctrlOKeyStroke);
291        csvReadItem.addActionListener(this::readFromCsvFile);
292        fileMenu.add(csvReadItem);
293        
294        retval.add(fileMenu);
295        return retval;
296    }
297
298    @Override
299    public String getHelpTarget() {
300        return "package.jmri.jmrix.openlcb.swing.eventtable.EventTablePane";
301    }
302
303    @Override
304    public String getTitle() {
305        if (memo != null) {
306            return (memo.getUserName() + " " + Bundle.getMessage("TitleEventTable"));
307        }
308        return getTitle(Bundle.getMessage("TitleEventTable"));
309    }
310
311    public void sendRequestEvents(java.awt.event.ActionEvent e) {
312        model.clear();
313
314        model.loadIdTagEventIDs();
315        model.handleTableUpdate(-1, -1);
316
317        final int IDENTIFY_EVENTS_DELAY = 125; // msec between operations - 64 events at speed
318        int nextDelay = 0;
319
320        // assumes that a VerifyNodes has been done and all nodes are in the MimicNodeStore
321        for (var memo : mimcStore.getNodeMemos()) {
322
323            jmri.util.ThreadingUtil.runOnLayoutDelayed(() -> {
324                var destNodeID = memo.getNodeID();
325                log.trace("send IdentifyEventsAddressedMessage {} {}", nid, destNodeID);
326                Message m = new IdentifyEventsAddressedMessage(nid, destNodeID);
327                connection.put(m, null);
328            }, nextDelay);
329
330            nextDelay += IDENTIFY_EVENTS_DELAY;
331        }
332        // Our reference to the node names in the MimicNodeStore will
333        // trigger a SNIP request if we don't have them yet.  In case that happens
334        // we want to trigger a table refresh to make sure they get displayed.
335        final int REFRESH_INTERVAL = 1000;
336        jmri.util.ThreadingUtil.runOnGUIDelayed(() -> {
337            model.handleTableUpdate(-1,-1);
338        }, nextDelay+REFRESH_INTERVAL);
339        jmri.util.ThreadingUtil.runOnGUIDelayed(() -> {
340            model.handleTableUpdate(-1,-1);
341        }, nextDelay+REFRESH_INTERVAL*2);
342        jmri.util.ThreadingUtil.runOnGUIDelayed(() -> {
343            model.handleTableUpdate(-1,-1);
344        }, nextDelay+REFRESH_INTERVAL*4);
345
346    }
347
348    void popcornButtonChanged() {
349        model.popcornModeActive = popcorn.isSelected();
350        log.debug("Popcorn mode {}", model.popcornModeActive);
351    }
352
353
354    public void findRequested(java.awt.event.ActionEvent e) {
355        var text = findID.getText();
356        // take off all the trailing .00
357        text = text.strip().replaceAll("(.00)*$", "");
358        log.debug("Request find event [{}]", text);
359        // just search event ID
360        table.clearSelection();
361        if (findTextSearch(text, EventTableDataModel.COL_EVENTID)) return;
362    }
363    
364    public void findTextRequested(java.awt.event.ActionEvent e) {
365        String text = findTextID.getText();
366        log.debug("Request find text {}", text);
367        // first search event name, then from config, then producer name, then consumer name
368        table.clearSelection();
369        if (findTextSearch(text, EventTableDataModel.COL_EVENTNAME)) return;
370        if (findTextSearch(text, EventTableDataModel.COL_CONTEXT_INFO)) return;
371        if (findTextSearch(text, EventTableDataModel.COL_PRODUCER_NAME)) return;
372        if (findTextSearch(text, EventTableDataModel.COL_CONSUMER_NAME)) return;
373        return;
374
375        //model.highlightEvent(new EventID(findID.getText()));
376    }
377    
378    protected boolean findTextSearch(String text, int column) {
379        text = text.toUpperCase();
380        try {
381            for (int row = 0; row < model.getRowCount(); row++) {
382                var cell = table.getValueAt(row, column);
383                if (cell == null) continue;
384                var value = cell.toString().toUpperCase();
385                if (value.startsWith(text)) {
386                    table.changeSelection(row, column, false, false);
387                    return true;
388                }
389            }
390        } catch (RuntimeException e) {
391            // we get ArrayIndexOutOfBoundsException occasionally for no known reason
392            log.debug("unexpected AIOOBE");
393        }
394        return false;
395    }
396    
397    public void addRequested(java.awt.event.ActionEvent e) {
398        var text = findID.getText();
399        EventID eventID = new EventID(text);
400        // first, add the event
401        var memo = new EventTableDataModel.TripleMemo(
402                            eventID,
403                            "",
404                            null,
405                            "",
406                            null,
407                            ""
408                        );
409        // check to see if already in there:
410        boolean found = false;
411        for (var check : EventTableDataModel.memos) {
412            if (memo.eventID.equals(check.eventID)) {
413                found = true;
414                break;
415            }
416        }
417        if (! found) {
418            EventTableDataModel.memos.add(memo);
419        }
420        model.fireTableDataChanged();
421        // now select that one
422        findRequested(e);
423        
424    }
425    
426    public void sensorRequested(java.awt.event.ActionEvent e) {
427        // loop over sensors to find the OpenLCB ones
428        var beans = InstanceManager.getDefault(SensorManager.class).getNamedBeanSet();
429        for (NamedBean bean : beans ) {
430            if (bean instanceof OlcbSensor) {
431                oneSensorToTag(true,  bean); // active
432                oneSensorToTag(false, bean); // inactive
433            }
434        }
435    }
436
437    private void oneSensorToTag(boolean isActive, NamedBean bean) {
438        var sensor = (OlcbSensor) bean;
439        var sensorID = sensor.getEventID(isActive);
440        if (! isEventNamePresent(sensorID)) {
441            // add the association
442            nameStore.addMatch(sensorID, sensor.getEventName(isActive));
443        }
444    }
445
446    public void turnoutRequested(java.awt.event.ActionEvent e) {
447        // loop over turnouts to find the OpenLCB ones
448        var beans = InstanceManager.getDefault(TurnoutManager.class).getNamedBeanSet();
449        for (NamedBean bean : beans ) {
450            if (bean instanceof OlcbTurnout) {
451                oneTurnoutToTag(true,  bean); // thrown
452                oneTurnoutToTag(false, bean); // closed
453            }
454        }
455    }
456
457    private void oneTurnoutToTag(boolean isThrown, NamedBean bean) {
458        var turnout = (OlcbTurnout) bean;
459        var turnoutID = turnout.getEventID(isThrown);
460        if (! isEventNamePresent(turnoutID)) {
461            // add the association
462            nameStore.addMatch(turnoutID, turnout.getEventName(isThrown));
463        }
464    }
465    
466    
467    // CSV file chooser
468    // static to remember choice from one use to another.
469    static JFileChooser fileChooser = null;
470
471    /**
472     * Write out contents in CSV form
473     * @param e Needed for signature of method, but ignored here
474     */
475    public void writeToCsvFile(ActionEvent e) {
476
477        if (fileChooser == null) {
478            fileChooser = new jmri.util.swing.JmriJFileChooser();
479        }
480        fileChooser.setDialogTitle(Bundle.getMessage("PaneSaveCsvFile"));
481        fileChooser.rescanCurrentDirectory();
482        fileChooser.setSelectedFile(new File("eventtable.csv"));
483
484        int retVal = fileChooser.showSaveDialog(this);
485
486        if (retVal == JFileChooser.APPROVE_OPTION) {
487            File file = fileChooser.getSelectedFile();
488            if (log.isDebugEnabled()) {
489                log.debug("start to export to CSV file {}", file);
490            }
491
492            try (CSVPrinter str = new CSVPrinter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8), CSVFormat.DEFAULT)) {
493                str.printRecord("Event ID", "Event Name", "Producer Node", "Producer Node Name",
494                                "Consumer Node", "Consumer Node Name", "Paths");                
495                for (int i = 0; i < model.getRowCount(); i++) {
496
497                    str.print(model.getValueAt(i, EventTableDataModel.COL_EVENTID));
498                    str.print(model.getValueAt(i, EventTableDataModel.COL_EVENTNAME));
499                    str.print(model.getValueAt(i, EventTableDataModel.COL_PRODUCER_NODE));
500                    str.print(model.getValueAt(i, EventTableDataModel.COL_PRODUCER_NAME));
501                    str.print(model.getValueAt(i, EventTableDataModel.COL_CONSUMER_NODE));
502                    str.print(model.getValueAt(i, EventTableDataModel.COL_CONSUMER_NAME));
503
504                    String[] contexts = model.getValueAt(i, EventTableDataModel.COL_CONTEXT_INFO).toString().split("\n"); // multi-line cell
505                    for (String context : contexts) {
506                        str.print(context);
507                    }
508                    
509                    str.println();
510                }
511                str.flush();
512            } catch (IOException ex) {
513                log.error("Error writing file", ex);
514            }
515        }
516    }
517
518    /**
519     * Read event names from a CSV file
520     * @param e Needed for signature of method, but ignored here
521     */
522    public void readFromCsvFile(ActionEvent e) {
523
524        if (fileChooser == null) {
525            fileChooser = new jmri.util.swing.JmriJFileChooser();
526        }
527        fileChooser.setDialogTitle(Bundle.getMessage("PaneOpenCsvFile"));
528        fileChooser.rescanCurrentDirectory();
529
530        int retVal = fileChooser.showOpenDialog(this);
531
532        if (retVal == JFileChooser.APPROVE_OPTION) {
533            File file = fileChooser.getSelectedFile();
534            if (log.isDebugEnabled()) {
535                log.debug("start to read from CSV file {}", file);
536            }
537
538            try (Reader in = new FileReader(file)) {
539                Iterable<CSVRecord> records = CSVFormat.RFC4180.parse(in);
540                
541                for (CSVRecord record : records) {
542                    String eventIDname = record.get(0);
543                     // Is the 1st column really an event ID
544                    EventID eid;
545                    try {
546                        eid = new EventID(eventIDname);
547                    } catch (IllegalArgumentException e1) {
548                        // really shouldn't happen, as table manages column contents
549                        log.warn("Column 0 doesn't contain an EventID: {}", eventIDname);
550                        continue;
551                    }
552                    // here we have a valid EventID, assign the name if currently blank
553                    if (! isEventNamePresent(eid)) {
554                        String eventName = record.get(1);
555                        nameStore.addMatch(eid, eventName);
556                    }         
557                }
558                log.debug("File reading complete");
559                // cause the table to update
560                model.fireTableDataChanged();
561                
562            } catch (IOException ex) {
563                log.error("Error reading file", ex);
564            }
565        }
566    }
567
568    /**
569     * Check whether a Event Name tag is defined or not.
570     * Check for other uses before changing this.
571     * @param eventID EventID in native form
572     * @return true is the event name tag is present
573     */
574    public boolean isEventNamePresent(EventID eventID) {
575        return nameStore.hasEventName(eventID);
576    }
577    
578    /**
579     * Set up filtering of displayed rows
580     */
581    private void filter() {
582        RowFilter<EventTableDataModel, Integer> rf = new RowFilter<EventTableDataModel, Integer>() {
583            /**
584             * @return true if row is to be displayed
585             */
586            @Override
587            public boolean include(RowFilter.Entry<? extends EventTableDataModel, ? extends Integer> entry) {
588
589                int row = entry.getIdentifier();
590
591                var name = model.getValueAt(row, EventTableDataModel.COL_EVENTNAME);
592                if ( showRequiresLabel.isSelected() && (name == null || name.toString().isEmpty()) ) return false;
593
594                if ( showRequiresMatch.isSelected()) {
595                    var memo = model.getTripleMemo(row);
596
597                    if (memo.producer == null && !model.producerPresent(memo.eventID)) {
598                        // no matching producer
599                        return false;
600                    }
601
602                    if (memo.consumer == null && !model.consumerPresent(memo.eventID)) {
603                        // no matching consumer
604                        return false;
605                    }
606                }
607
608                // check for group match
609                if ( matchGroupName.getSelectedIndex() > 0) {  // -1 is empty combobox
610                    String group = matchGroupName.getSelectedItem().toString();
611                    var memo = model.getTripleMemo(row);
612                    if ( (! groupStore.isNodeInGroup(memo.producer, group))
613                        && (! groupStore.isNodeInGroup(memo.consumer, group)) ) {
614                            return false;
615                    }
616                }
617                
618                // passed all filters
619                return true;
620            }
621        };
622        sorter.setRowFilter(rf);
623    }
624
625    /**
626     * Nested class to hold data model
627     */
628    protected static class EventTableDataModel extends AbstractTableModel {
629
630        EventTableDataModel(MimicNodeStore store, EventTable stdEventTable, OlcbEventNameStore nameStore) {
631            this.store = store;
632            this.stdEventTable = stdEventTable;
633            this.nameStore = nameStore;
634
635            loadIdTagEventIDs();
636        }
637
638        static final int COL_EVENTID = 0;
639        static final int COL_EVENTNAME = 1;
640        static final int COL_PRODUCER_NODE = 2;
641        static final int COL_PRODUCER_NAME = 3;
642        static final int COL_CONSUMER_NODE = 4;
643        static final int COL_CONSUMER_NAME = 5;
644        static final int COL_CONTEXT_INFO = 6;
645        static final int COL_COUNT = 7;
646
647        MimicNodeStore store;
648        EventTable stdEventTable;
649        OlcbEventNameStore nameStore;
650        IdTagManager tagManager;
651        JTable table;
652        TableRowSorter<EventTableDataModel> sorter;
653        boolean popcornModeActive = false;
654
655        TripleMemo getTripleMemo(int row) {
656            if (row >= memos.size()) {
657                return null;
658            }
659            return memos.get(row);
660        }
661
662        void loadIdTagEventIDs() {
663            // are there events in the IdTags? If so, add them
664            for (var eventID: nameStore.getMatches()) {
665                var memo = new TripleMemo(
666                                    eventID,
667                                    "",
668                                    null,
669                                    "",
670                                    null,
671                                    ""
672                                );
673                // check to see if already in there:
674                boolean found = false;
675                for (var check : memos) {
676                    if (memo.eventID.equals(check.eventID)) {
677                        found = true;
678                        break;
679                    }
680                }
681                if (! found) {
682                    memos.add(memo);
683                }
684            }
685        }
686
687
688        @Override
689        public Object getValueAt(int row, int col) {
690            if (row >= memos.size()) {
691                log.warn("request out of range: {} greater than {}", row, memos.size());
692                return "Illegal col "+row+" "+col;
693            }
694            var memo = memos.get(row);
695            switch (col) {
696                case COL_EVENTID: 
697                    String retval = memo.eventID.toShortString();
698                    if (!memo.rangeSuffix.isEmpty()) retval += " - "+memo.rangeSuffix;
699                    return retval;
700                case COL_EVENTNAME:
701                    if (nameStore.hasEventName(memo.eventID)) {
702                        return nameStore.getEventName(memo.eventID);
703                    } else {
704                        return "";
705                    }
706                    
707                case COL_PRODUCER_NODE:
708                    return memo.producer != null ? memo.producer.toString() : "";
709                case COL_PRODUCER_NAME: return memo.producerName;
710                case COL_CONSUMER_NODE:
711                    return memo.consumer != null ? memo.consumer.toString() : "";
712                case COL_CONSUMER_NAME: return memo.consumerName;
713                case COL_CONTEXT_INFO:
714
715                    // When table is constrained, these rows don't match up, need to find constrained row
716                    var viewRow = sorter.convertRowIndexToView(row);
717
718                    if (lineIncrement <= 0) { // load cache variable?
719                        if (viewRow >= 0) {
720                            lineIncrement = table.getRowHeight(viewRow); // do this if valid row
721                        } else {
722                            lineIncrement = table.getFont().getSize()*13/10; // line spacing from font if not valid row
723                        }
724                     }
725
726                    var result = new StringBuilder();
727
728                    var height = lineIncrement/3; // for margins
729                    var first = true;   // no \n before first line
730
731                    // interpret eventID and start with that if present
732                    String interp = memo.eventID.parse();
733                    if (interp != null && !interp.isEmpty()) {
734                        height += lineIncrement;
735                        result.append(interp);                        
736                        first = false;
737                    }
738
739                    // scan the CD/CDI information as available
740                    for (var entry : stdEventTable.getEventInfo(memo.eventID).getAllEntries()) {
741                        if (!first) result.append("\n");
742                        first = false;
743                        height += lineIncrement;
744                        result.append(entry.getDescription());
745                    }
746
747                    // set height for multi-line output in the cell
748                    if (viewRow >= 0) { // make sure it's a valid visible row in the table; -1 signals not
749                        // set height
750                        if (height < lineIncrement) {
751                            height = height+lineIncrement; // when no lines, assume 1
752                        }
753                        table.setRowHeight(viewRow, height);
754                    } else {
755                        lineIncrement = -1;  // reload on next request, hoping for a viewed row
756                    }
757                    return new String(result);
758                default: return "Illegal row "+row+" "+col;
759            }
760        }
761
762        int lineIncrement = -1; // cache the line spacing for multi-line cells; 
763                                // this gets the value before any adjustments done
764
765        @Override
766        public void setValueAt(Object value, int row, int col) {
767            if (col != COL_EVENTNAME) return;
768            if (row >= memos.size()) {
769                log.warn("request out of range: {} greater than {}", row, memos.size());
770                return;
771            }
772            var memo = memos.get(row);
773            nameStore.addMatch(memo.eventID, value.toString());
774        }
775
776        @Override
777        public int getColumnCount() {
778            return COL_COUNT;
779        }
780
781        @Override
782        public String getColumnName(int col) {
783            switch (col) {
784                case COL_EVENTID:       return Bundle.getMessage("TableColEventId");
785                case COL_EVENTNAME:     return Bundle.getMessage("TableColEventName");
786                case COL_PRODUCER_NODE: return Bundle.getMessage("TableColProducerNode");
787                case COL_PRODUCER_NAME: return Bundle.getMessage("TableColProducerName");
788                case COL_CONSUMER_NODE: return Bundle.getMessage("TableColConsumerNode");
789                case COL_CONSUMER_NAME: return Bundle.getMessage("TableColConsumerName");
790                case COL_CONTEXT_INFO:  return Bundle.getMessage("TableColContextInfo");
791                default: return "ERROR "+col;
792            }
793        }
794
795        @Override
796        public int getRowCount() {
797            return memos.size();
798        }
799
800        @Override
801        public boolean isCellEditable(int row, int col) {
802            return col == COL_EVENTNAME;
803        }
804
805        @Override
806        public Class<?> getColumnClass(int col) {
807            return String.class;
808        }
809
810        /**
811         * Remove all existing data, generally just in advance of an update
812         */
813        @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") // Swing thread deconflicts
814        void clear() {
815            memos = new ArrayList<>();
816            fireTableDataChanged();  // don't queue this one, must be immediate
817        }
818
819        // static so the data remains available through a window close-open cycle
820        static ArrayList<TripleMemo> memos = new ArrayList<>();
821
822        /**
823         * Notify the table that the contents have changed.
824         * To reduce CPU load, this batches the changes
825         * @param start first row changed; -1 means entire table (not used yet)
826         * @param end   last row changed; -1 means entire table (not used yet)
827         */
828        void handleTableUpdate(int start, int end) {
829            log.trace("handleTableUpdated");
830            final int DELAY = 500;
831
832            if (!pending) {
833                jmri.util.ThreadingUtil.runOnGUIDelayed(() -> {
834                    pending = false;
835                    log.debug("handleTableUpdated fires table changed");
836                    fireTableDataChanged();
837                }, DELAY);
838                pending = true;
839            }
840
841        }
842        boolean pending = false;
843
844        /**
845         * Record an event-producer pair
846         * @param eventID Observed event
847         * @param nodeID  Node that is known to produce the event
848         * @param rangeSuffix the range mask string or "" for single events
849         */
850        void recordProducer(EventID eventID, NodeID nodeID, String rangeSuffix) {
851            log.debug("recordProducer of {} in {}", eventID, nodeID);
852
853            // update if the model has been cleared
854            if (memos.size() <= 1) {
855                handleTableUpdate(-1, -1);
856            }
857
858            var nodeMemo = store.findNode(nodeID);
859            String name = "";
860            if (nodeMemo != null) {
861                var ident = nodeMemo.getSimpleNodeIdent();
862                    if (ident != null) {
863                        name = ident.getUserName();
864                        if (name.isEmpty()) {
865                            name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion();
866                        }
867                    }
868            }
869
870
871            // if this already exists, skip storing it
872            // if you can, find a matching memo with an empty consumer value
873            TripleMemo empty = null;    // an existing empty cell                       // TODO: switch to int index for handle update below
874            TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below
875            TripleMemo sameNodeID = null;// cell with matching consumer                 // TODO: switch to int index for handle update below
876            for (int i = 0; i < memos.size(); i++) {
877                var memo = memos.get(i);
878                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) {
879                    // if nodeID matches, already present; ignore
880                    if (nodeID.equals(memo.producer)) {
881                        // might be 2nd EventTablePane to process the data,
882                        // hence memos would already have been processed. To
883                        // handle that, need to fire a change to the table.
884                        // On the other hand, this rapidly erases the
885                        // popcorn display, so we disable it for that.
886                        if (!popcornModeActive) {
887                            handleTableUpdate(i, i);
888                        }
889                        return;
890                    }
891                    // if empty producer slot, remember it
892                    if (memo.producer == null) {
893                        empty = memo;
894                        // best empty has matching consumer
895                        if (nodeID.equals(memo.consumer)) bestEmpty = memo;
896                    }
897                    // if same consumer slot, remember it
898                    if (nodeID == memo.consumer) {
899                        sameNodeID = memo;
900                    }
901                }
902            }
903
904            // can we use the bestEmpty?
905            if (bestEmpty != null) {
906                // yes
907                log.trace("   use bestEmpty");
908                bestEmpty.producer = nodeID;
909                bestEmpty.producerName = name;
910                handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty
911                return;
912            }
913
914            // can we just insert into the empty?
915            if (empty != null && sameNodeID == null) {
916                // yes
917                log.trace("   reuse empty");
918                empty.producer = nodeID;
919                empty.producerName = name;
920                handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty
921                return;
922            }
923
924            // is there a sameNodeID to insert into?
925            if (sameNodeID != null) {
926                // yes
927                log.trace("   switch to sameID");
928                var fromSaveNodeID = sameNodeID.producer;
929                var fromSaveNodeIDName = sameNodeID.producerName;
930                sameNodeID.producer = nodeID;
931                sameNodeID.producerName = name;
932                // now leave behind old cell to make new one in next block
933                nodeID = fromSaveNodeID;
934                name = fromSaveNodeIDName;
935            }
936
937            // have to make a new one
938            var memo = new TripleMemo(
939                            eventID,
940                            rangeSuffix,
941                            nodeID,
942                            name,
943                            null,
944                            ""
945                        );
946            memos.add(memo);
947            handleTableUpdate(memos.size()-1, memos.size()-1);
948        }
949
950        /**
951         * Record an event-consumer pair
952         * @param eventID Observed event
953         * @param nodeID  Node that is known to consume the event
954         * @param rangeSuffix the range mask string or "" for single events
955         */
956        void recordConsumer(EventID eventID, NodeID nodeID, String rangeSuffix) {
957            log.debug("recordConsumer of {} in {}", eventID, nodeID);
958
959            // update if the model has been cleared
960            if (memos.size() <= 1) {
961                handleTableUpdate(-1, -1);
962            }
963
964            var nodeMemo = store.findNode(nodeID);
965            String name = "";
966            if (nodeMemo != null) {
967                var ident = nodeMemo.getSimpleNodeIdent();
968                    if (ident != null) {
969                        name = ident.getUserName();
970                        if (name.isEmpty()) {
971                            name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion();
972                        }
973                    }
974            }
975
976            // if this already exists, skip storing it
977            // if you can, find a matching memo with an empty consumer value
978            TripleMemo empty = null;    // an existing empty cell                       // TODO: switch to int index for handle update below
979            TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below
980            TripleMemo sameNodeID = null;// cell with matching consumer                 // TODO: switch to int index for handle update below
981            for (int i = 0; i < memos.size(); i++) {
982                var memo = memos.get(i);
983                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) {
984                    // if nodeID matches, already present; ignore
985                    if (nodeID.equals(memo.consumer)) {
986                        // might be 2nd EventTablePane to process the data,
987                        // hence memos would already have been processed. To
988                        // handle that, always fire a change to the table.
989                        log.trace("    nodeDI == memo.consumer");
990                        handleTableUpdate(i, i);
991                        return;
992                    }
993                    // if empty consumer slot, remember it
994                    if (memo.consumer == null) {
995                        empty = memo;
996                        // best empty has matching producer
997                        if (nodeID.equals(memo.producer)) bestEmpty = memo;
998                    }
999                    // if same producer slot, remember it
1000                    if (nodeID == memo.producer) {
1001                        sameNodeID = memo;
1002                    }
1003                }
1004            }
1005
1006            // can we use the best empty?
1007            if (bestEmpty != null) {
1008                // yes
1009                log.trace("   use bestEmpty");
1010                bestEmpty.consumer = nodeID;
1011                bestEmpty.consumerName = name;
1012                handleTableUpdate(-1, -1);  // should be rows for bestEmpty, bestEmpty
1013                return;
1014            }
1015
1016            // can we just insert into the empty?
1017            if (empty != null && sameNodeID == null) {
1018                // yes
1019                log.trace("   reuse empty");
1020                empty.consumer = nodeID;
1021                empty.consumerName = name;
1022                handleTableUpdate(-1, -1);  // should be rows for empty, empty
1023                return;
1024            }
1025
1026            // is there a sameNodeID to insert into?
1027            if (sameNodeID != null) {
1028                // yes
1029                log.trace("   switch to sameID");
1030                var fromSaveNodeID = sameNodeID.consumer;
1031                var fromSaveNodeIDName = sameNodeID.consumerName;
1032                sameNodeID.consumer = nodeID;
1033                sameNodeID.consumerName = name;
1034                // now leave behind old cell to make new one
1035                nodeID = fromSaveNodeID;
1036                name = fromSaveNodeIDName;
1037            }
1038
1039            // have to make a new one
1040            log.trace("    make a new one");
1041            var memo = new TripleMemo(
1042                            eventID,
1043                            rangeSuffix,
1044                            null,
1045                            "",
1046                            nodeID,
1047                            name
1048                        );
1049            memos.add(memo);
1050            handleTableUpdate(memos.size()-1, memos.size()-1);
1051         }
1052
1053        // This causes the display to jump around as it tried to keep
1054        // the selected cell visible.
1055        // TODO: A better approach might be to change
1056        // the cell background color via a custom cell renderer
1057        void highlightProducer(EventID eventID, NodeID nodeID) {
1058            if (!popcornModeActive) return;
1059            log.trace("highlightProducer {} {}", eventID, nodeID);
1060            for (int i = 0; i < memos.size(); i++) {
1061                var memo = memos.get(i);
1062                if (eventID.equals(memo.eventID)  && memo.rangeSuffix.equals("") && nodeID.equals(memo.producer)) {
1063                    try {
1064                        var viewRow = sorter.convertRowIndexToView(i);
1065                        log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow);
1066                        if (viewRow >= 0) {
1067                            table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false);
1068                        }
1069                    } catch (ArrayIndexOutOfBoundsException e) {
1070                        // can happen on first encounter of an event before table is updated
1071                        log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i);
1072                    }
1073                }
1074            }
1075        }
1076
1077        // highlights (selects) all the eventID cells with a particular event,
1078        // Most LAFs will move the first of these on-scroll-view.
1079        void highlightEvent(EventID eventID) {
1080            log.trace("highlightEvent {}", eventID);
1081            table.clearSelection(); // clear existing selections
1082            for (int i = 0; i < memos.size(); i++) {
1083                var memo = memos.get(i);
1084                if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") ) {
1085                    try {
1086                        var viewRow = sorter.convertRowIndexToView(i);
1087                        log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow);
1088                        if (viewRow >= 0) {
1089                            table.changeSelection(viewRow, COL_EVENTID, true, false);
1090                        }
1091                    } catch (ArrayIndexOutOfBoundsException e) {
1092                        // can happen on first encounter of an event before table is updated
1093                        log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i);
1094                    }
1095                }
1096            }
1097        }
1098
1099        boolean consumerPresent(EventID eventID) {
1100            for (var memo : memos) {
1101                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) {
1102                    if (memo.consumer!=null) return true;
1103                }
1104            }
1105            return false;
1106        }
1107
1108        boolean producerPresent(EventID eventID) {
1109            for (var memo : memos) {
1110                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) {
1111                    if (memo.producer!=null) return true;
1112                }
1113            }
1114            return false;
1115        }
1116
1117        static class TripleMemo {
1118            final EventID eventID;
1119            final String  rangeSuffix;
1120            // Event name is stored in an OlcbEventNameStore, see getValueAt()
1121            NodeID producer;
1122            String producerName;
1123            NodeID consumer;
1124            String consumerName;
1125
1126            TripleMemo(EventID eventID, String rangeSuffix, NodeID producer, String producerName,
1127                        NodeID consumer, String consumerName) {
1128                this.eventID = eventID;
1129                this.rangeSuffix = rangeSuffix;
1130                this.producer = producer;
1131                this.producerName = producerName;
1132                this.consumer = consumer;
1133                this.consumerName = consumerName;
1134            }
1135        }
1136    }
1137
1138    /**
1139     * Internal class to watch OpenLCB traffic
1140     */
1141
1142    static class Monitor extends MessageDecoder {
1143
1144        Monitor(EventTableDataModel model) {
1145            this.model = model;
1146        }
1147
1148        EventTableDataModel model;
1149
1150        /**
1151         * Handle "Producer/Consumer Event Report" message
1152         * @param msg       message to handle
1153         * @param sender    connection where it came from
1154         */
1155        @Override
1156        public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender){
1157            ThreadingUtil.runOnGUIEventually(()->{
1158                var nodeID = msg.getSourceNodeID();
1159                var eventID = msg.getEventID();
1160                model.recordProducer(eventID, nodeID, "");
1161                model.highlightProducer(eventID, nodeID);
1162            });
1163        }
1164
1165        /**
1166         * Handle "Consumer Identified" message
1167         * @param msg       message to handle
1168         * @param sender    connection where it came from
1169         */
1170        @Override
1171        public void handleConsumerIdentified(ConsumerIdentifiedMessage msg, Connection sender){
1172            ThreadingUtil.runOnGUIEventually(()->{
1173                var nodeID = msg.getSourceNodeID();
1174                var eventID = msg.getEventID();
1175                model.recordConsumer(eventID, nodeID, "");
1176            });
1177        }
1178
1179        /**
1180         * Handle "Producer Identified" message
1181         * @param msg       message to handle
1182         * @param sender    connection where it came from
1183         */
1184        @Override
1185        public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender){
1186            ThreadingUtil.runOnGUIEventually(()->{
1187                var nodeID = msg.getSourceNodeID();
1188                var eventID = msg.getEventID();
1189                model.recordProducer(eventID, nodeID, "");
1190            });
1191        }
1192
1193        @Override
1194        public void handleConsumerRangeIdentified(ConsumerRangeIdentifiedMessage msg, Connection sender){
1195            ThreadingUtil.runOnGUIEventually(()->{
1196                final var nodeID = msg.getSourceNodeID();
1197                final var eventID = msg.getEventID();
1198                
1199                final long rangeSuffix = eventID.rangeSuffix();
1200                // have to set low part of event ID to 0's as it might be 1's
1201                EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix));
1202                
1203                model.recordConsumer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString());
1204            });
1205        }
1206    
1207        @Override
1208        public void handleProducerRangeIdentified(ProducerRangeIdentifiedMessage msg, Connection sender){
1209            ThreadingUtil.runOnGUIEventually(()->{
1210                final var nodeID = msg.getSourceNodeID();
1211                final var eventID = msg.getEventID();
1212                
1213                final long rangeSuffix = eventID.rangeSuffix();
1214                // have to set low part of event ID to 0's as it might be 1's
1215                EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix));
1216                
1217                model.recordProducer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString());
1218            });
1219        }
1220
1221        /*
1222         * We no longer handle "Simple Node Ident Info Reply" messages because of
1223         * excessive redisplays.  Instead, we expect the MimicNodeStore to handle
1224         * these and provide the information when requested.
1225         */
1226    }
1227
1228    /**
1229     * Nested class to create one of these using old-style defaults
1230     */
1231    public static class Default extends jmri.jmrix.can.swing.CanNamedPaneAction {
1232
1233        public Default() {
1234            super("LCC Event Table",
1235                    new jmri.util.swing.sdi.JmriJFrameInterface(),
1236                    EventTablePane.class.getName(),
1237                    jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class));
1238        }
1239        
1240        public Default(String name, jmri.util.swing.WindowInterface iface) {
1241            super(name,
1242                    iface,
1243                    EventTablePane.class.getName(),
1244                    jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class));        
1245        }
1246
1247        public Default(String name, Icon icon, jmri.util.swing.WindowInterface iface) {
1248            super(name,
1249                    icon, iface,
1250                    EventTablePane.class.getName(),
1251                    jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class));        
1252        }
1253    }
1254    
1255    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTablePane.class);
1256}