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 column at "+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            if (log.isTraceEnabled()) { // check logging level to avoid processing irrelevant traceback
830                log.trace("handleTableUpdated", jmri.util.LoggingUtil.shortenStacktrace(new Exception("traceback")));
831            }
832            
833            final int DELAY = 500;
834
835            if (!pending) {
836                jmri.util.ThreadingUtil.runOnGUIDelayed(() -> {
837                    pending = false;
838                    log.debug("handleTableUpdated fires table changed");
839                    fireTableDataChanged();
840                }, DELAY);
841                pending = true;
842            }
843
844        }
845        boolean pending = false;
846
847        /**
848         * Record an event-producer pair
849         * @param eventID Observed event
850         * @param nodeID  Node that is known to produce the event
851         * @param rangeSuffix the range mask string or "" for single events
852         * @param pcer true if this Producer was inferred from a PCER message, false if from a Producer Identified message
853         */
854        void recordProducer(EventID eventID, NodeID nodeID, String rangeSuffix, boolean pcer) {
855            log.debug("recordProducer of {} in {}", eventID, nodeID);
856
857            // update if the model has been cleared
858            if (memos.size() <= 1) {
859                handleTableUpdate(-1, -1);
860            }
861
862            var nodeMemo = store.findNode(nodeID);
863            String name = "";
864            if (nodeMemo != null) {
865                var ident = nodeMemo.getSimpleNodeIdent();
866                    if (ident != null) {
867                        name = ident.getUserName();
868                        if (name.isEmpty()) {
869                            name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion();
870                        }
871                    }
872            }
873
874
875            // if this already exists, skip storing it
876            // if you can, find a matching memo with an empty consumer value
877            TripleMemo empty = null;    // an existing empty cell                       // TODO: switch to int index for handle update below
878            TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below
879            TripleMemo sameNodeID = null;// cell with matching consumer                 // TODO: switch to int index for handle update below
880            for (int i = 0; i < memos.size(); i++) {
881                var memo = memos.get(i);
882                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) {
883                    // if nodeID matches, already present; ignore
884                    if (nodeID.equals(memo.producer)) {
885                        // The node ID is already registered (hence appearing in table)
886                        // for this producer.
887                        //
888                        // This might be the 2nd EventTablePane to process the data,
889                        // hence memos would already have been processed. To
890                        // handle that, need to fire a change to the table.
891                        //
892                        // On the other hand, this rapidly erases the
893                        // popcorn display, so we disable it for that.
894                        //
895                        // We also disable it if this call was from a PCER message,
896                        // as those are routine and should have been preceeded
897                        // by a Producer Identified. Leaving this in results in
898                        // excessive refreshes and e.g. frustrating loss of 
899                        // cell selections.
900                        //
901                        if (! (popcornModeActive | pcer) ) {
902                            handleTableUpdate(i, i);
903                        }
904                        return;
905                    }
906                    // if empty producer slot, remember it
907                    if (memo.producer == null) {
908                        empty = memo;
909                        // best empty has matching consumer
910                        if (nodeID.equals(memo.consumer)) bestEmpty = memo;
911                    }
912                    // if same consumer slot, remember it
913                    if (nodeID == memo.consumer) {
914                        sameNodeID = memo;
915                    }
916                }
917            }
918
919            // can we use the bestEmpty?
920            if (bestEmpty != null) {
921                // yes
922                log.trace("   use bestEmpty");
923                bestEmpty.producer = nodeID;
924                bestEmpty.producerName = name;
925                handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty
926                return;
927            }
928
929            // can we just insert into the empty?
930            if (empty != null && sameNodeID == null) {
931                // yes
932                log.trace("   reuse empty");
933                empty.producer = nodeID;
934                empty.producerName = name;
935                handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty
936                return;
937            }
938
939            // is there a sameNodeID to insert into?
940            if (sameNodeID != null) {
941                // yes
942                log.trace("   switch to sameID");
943                var fromSaveNodeID = sameNodeID.producer;
944                var fromSaveNodeIDName = sameNodeID.producerName;
945                sameNodeID.producer = nodeID;
946                sameNodeID.producerName = name;
947                // now leave behind old cell to make new one in next block
948                nodeID = fromSaveNodeID;
949                name = fromSaveNodeIDName;
950            }
951
952            // have to make a new one
953            var memo = new TripleMemo(
954                            eventID,
955                            rangeSuffix,
956                            nodeID,
957                            name,
958                            null,
959                            ""
960                        );
961            memos.add(memo);
962            handleTableUpdate(memos.size()-1, memos.size()-1);
963        }
964
965        /**
966         * Record an event-consumer pair
967         * @param eventID Observed event
968         * @param nodeID  Node that is known to consume the event
969         * @param rangeSuffix the range mask string or "" for single events
970         */
971        void recordConsumer(EventID eventID, NodeID nodeID, String rangeSuffix) {
972            log.debug("recordConsumer of {} in {}", eventID, nodeID);
973
974            // update if the model has been cleared
975            if (memos.size() <= 1) {
976                handleTableUpdate(-1, -1);
977            }
978
979            var nodeMemo = store.findNode(nodeID);
980            String name = "";
981            if (nodeMemo != null) {
982                var ident = nodeMemo.getSimpleNodeIdent();
983                    if (ident != null) {
984                        name = ident.getUserName();
985                        if (name.isEmpty()) {
986                            name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion();
987                        }
988                    }
989            }
990
991            // if this already exists, skip storing it
992            // if you can, find a matching memo with an empty consumer value
993            TripleMemo empty = null;    // an existing empty cell                       // TODO: switch to int index for handle update below
994            TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below
995            TripleMemo sameNodeID = null;// cell with matching consumer                 // TODO: switch to int index for handle update below
996            for (int i = 0; i < memos.size(); i++) {
997                var memo = memos.get(i);
998                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) {
999                    // if nodeID matches, already present; ignore
1000                    if (nodeID.equals(memo.consumer)) {
1001                        // might be 2nd EventTablePane to process the data,
1002                        // hence memos would already have been processed. To
1003                        // handle that, always fire a change to the table.
1004                        log.trace("    nodeDI == memo.consumer");
1005                        handleTableUpdate(i, i);
1006                        return;
1007                    }
1008                    // if empty consumer slot, remember it
1009                    if (memo.consumer == null) {
1010                        empty = memo;
1011                        // best empty has matching producer
1012                        if (nodeID.equals(memo.producer)) bestEmpty = memo;
1013                    }
1014                    // if same producer slot, remember it
1015                    if (nodeID == memo.producer) {
1016                        sameNodeID = memo;
1017                    }
1018                }
1019            }
1020
1021            // can we use the best empty?
1022            if (bestEmpty != null) {
1023                // yes
1024                log.trace("   use bestEmpty");
1025                bestEmpty.consumer = nodeID;
1026                bestEmpty.consumerName = name;
1027                handleTableUpdate(-1, -1);  // should be rows for bestEmpty, bestEmpty
1028                return;
1029            }
1030
1031            // can we just insert into the empty?
1032            if (empty != null && sameNodeID == null) {
1033                // yes
1034                log.trace("   reuse empty");
1035                empty.consumer = nodeID;
1036                empty.consumerName = name;
1037                handleTableUpdate(-1, -1);  // should be rows for empty, empty
1038                return;
1039            }
1040
1041            // is there a sameNodeID to insert into?
1042            if (sameNodeID != null) {
1043                // yes
1044                log.trace("   switch to sameID");
1045                var fromSaveNodeID = sameNodeID.consumer;
1046                var fromSaveNodeIDName = sameNodeID.consumerName;
1047                sameNodeID.consumer = nodeID;
1048                sameNodeID.consumerName = name;
1049                // now leave behind old cell to make new one
1050                nodeID = fromSaveNodeID;
1051                name = fromSaveNodeIDName;
1052            }
1053
1054            // have to make a new one
1055            log.trace("    make a new one");
1056            var memo = new TripleMemo(
1057                            eventID,
1058                            rangeSuffix,
1059                            null,
1060                            "",
1061                            nodeID,
1062                            name
1063                        );
1064            memos.add(memo);
1065            handleTableUpdate(memos.size()-1, memos.size()-1);
1066         }
1067
1068        // This causes the display to jump around as it tried to keep
1069        // the selected cell visible.
1070        // TODO: A better approach might be to change
1071        // the cell background color via a custom cell renderer
1072        void highlightProducer(EventID eventID, NodeID nodeID) {
1073            if (!popcornModeActive) return;
1074            log.trace("highlightProducer {} {}", eventID, nodeID);
1075            for (int i = 0; i < memos.size(); i++) {
1076                var memo = memos.get(i);
1077                if (eventID.equals(memo.eventID)  && memo.rangeSuffix.equals("") && nodeID.equals(memo.producer)) {
1078                    try {
1079                        var viewRow = sorter.convertRowIndexToView(i);
1080                        log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow);
1081                        if (viewRow >= 0) {
1082                            table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false);
1083                        }
1084                    } catch (ArrayIndexOutOfBoundsException e) {
1085                        // can happen on first encounter of an event before table is updated
1086                        log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i);
1087                    }
1088                }
1089            }
1090        }
1091
1092        // highlights (selects) all the eventID cells with a particular event,
1093        // Most LAFs will move the first of these on-scroll-view.
1094        void highlightEvent(EventID eventID) {
1095            log.trace("highlightEvent {}", eventID);
1096            table.clearSelection(); // clear existing selections
1097            for (int i = 0; i < memos.size(); i++) {
1098                var memo = memos.get(i);
1099                if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") ) {
1100                    try {
1101                        var viewRow = sorter.convertRowIndexToView(i);
1102                        log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow);
1103                        if (viewRow >= 0) {
1104                            table.changeSelection(viewRow, COL_EVENTID, true, false);
1105                        }
1106                    } catch (ArrayIndexOutOfBoundsException e) {
1107                        // can happen on first encounter of an event before table is updated
1108                        log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i);
1109                    }
1110                }
1111            }
1112        }
1113
1114        boolean consumerPresent(EventID eventID) {
1115            for (var memo : memos) {
1116                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) {
1117                    if (memo.consumer!=null) return true;
1118                }
1119            }
1120            return false;
1121        }
1122
1123        boolean producerPresent(EventID eventID) {
1124            for (var memo : memos) {
1125                if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) {
1126                    if (memo.producer!=null) return true;
1127                }
1128            }
1129            return false;
1130        }
1131
1132        static class TripleMemo {
1133            final EventID eventID;
1134            final String  rangeSuffix;
1135            // Event name is stored in an OlcbEventNameStore, see getValueAt()
1136            NodeID producer;
1137            String producerName;
1138            NodeID consumer;
1139            String consumerName;
1140
1141            TripleMemo(EventID eventID, String rangeSuffix, NodeID producer, String producerName,
1142                        NodeID consumer, String consumerName) {
1143                this.eventID = eventID;
1144                this.rangeSuffix = rangeSuffix;
1145                this.producer = producer;
1146                this.producerName = producerName;
1147                this.consumer = consumer;
1148                this.consumerName = consumerName;
1149            }
1150        }
1151    }
1152
1153    /**
1154     * Internal class to watch OpenLCB traffic
1155     */
1156
1157    static class Monitor extends MessageDecoder {
1158
1159        Monitor(EventTableDataModel model) {
1160            this.model = model;
1161        }
1162
1163        EventTableDataModel model;
1164
1165        /**
1166         * Handle "Producer/Consumer Event Report" message
1167         * @param msg       message to handle
1168         * @param sender    connection where it came from
1169         */
1170        @Override
1171        public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender){
1172            ThreadingUtil.runOnGUIEventually(()->{
1173                var nodeID = msg.getSourceNodeID();
1174                var eventID = msg.getEventID();
1175                model.recordProducer(eventID, nodeID, "", true);
1176                model.highlightProducer(eventID, nodeID);
1177            });
1178        }
1179
1180        /**
1181         * Handle "Consumer Identified" message
1182         * @param msg       message to handle
1183         * @param sender    connection where it came from
1184         */
1185        @Override
1186        public void handleConsumerIdentified(ConsumerIdentifiedMessage msg, Connection sender){
1187            ThreadingUtil.runOnGUIEventually(()->{
1188                var nodeID = msg.getSourceNodeID();
1189                var eventID = msg.getEventID();
1190                model.recordConsumer(eventID, nodeID, "");
1191            });
1192        }
1193
1194        /**
1195         * Handle "Producer Identified" message
1196         * @param msg       message to handle
1197         * @param sender    connection where it came from
1198         */
1199        @Override
1200        public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender){
1201            ThreadingUtil.runOnGUIEventually(()->{
1202                var nodeID = msg.getSourceNodeID();
1203                var eventID = msg.getEventID();
1204                model.recordProducer(eventID, nodeID, "", false);
1205            });
1206        }
1207
1208        @Override
1209        public void handleConsumerRangeIdentified(ConsumerRangeIdentifiedMessage msg, Connection sender){
1210            ThreadingUtil.runOnGUIEventually(()->{
1211                final var nodeID = msg.getSourceNodeID();
1212                final var eventID = msg.getEventID();
1213                
1214                final long rangeSuffix = eventID.rangeSuffix();
1215                // have to set low part of event ID to 0's as it might be 1's
1216                EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix));
1217                
1218                model.recordConsumer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString());
1219            });
1220        }
1221    
1222        @Override
1223        public void handleProducerRangeIdentified(ProducerRangeIdentifiedMessage msg, Connection sender){
1224            ThreadingUtil.runOnGUIEventually(()->{
1225                final var nodeID = msg.getSourceNodeID();
1226                final var eventID = msg.getEventID();
1227                
1228                final long rangeSuffix = eventID.rangeSuffix();
1229                // have to set low part of event ID to 0's as it might be 1's
1230                EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix));
1231                
1232                model.recordProducer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString(), false);
1233            });
1234        }
1235
1236        /*
1237         * We no longer handle "Simple Node Ident Info Reply" messages because of
1238         * excessive redisplays.  Instead, we expect the MimicNodeStore to handle
1239         * these and provide the information when requested.
1240         */
1241    }
1242
1243    /**
1244     * Nested class to create one of these using old-style defaults
1245     */
1246    public static class Default extends jmri.jmrix.can.swing.CanNamedPaneAction {
1247
1248        public Default() {
1249            super("LCC Event Table",
1250                    new jmri.util.swing.sdi.JmriJFrameInterface(),
1251                    EventTablePane.class.getName(),
1252                    jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class));
1253        }
1254        
1255        public Default(String name, jmri.util.swing.WindowInterface iface) {
1256            super(name,
1257                    iface,
1258                    EventTablePane.class.getName(),
1259                    jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class));        
1260        }
1261
1262        public Default(String name, Icon icon, jmri.util.swing.WindowInterface iface) {
1263            super(name,
1264                    icon, iface,
1265                    EventTablePane.class.getName(),
1266                    jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class));        
1267        }
1268    }
1269    
1270    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTablePane.class);
1271}