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