001package jmri.jmrit.roster.swing;
002
003import com.fasterxml.jackson.databind.util.StdDateFormat;
004
005import java.awt.Component;
006import java.awt.Rectangle;
007import java.awt.event.ActionEvent;
008import java.awt.event.ActionListener;
009import java.awt.event.MouseEvent;
010import java.text.DateFormat;
011import java.text.SimpleDateFormat;
012import java.text.ParseException;
013import java.util.ArrayList;
014import java.util.Arrays;
015import java.util.Date;
016import java.util.Enumeration;
017import java.util.List;
018
019import javax.swing.BoxLayout;
020import javax.swing.DefaultCellEditor;
021import javax.swing.JCheckBoxMenuItem;
022import javax.swing.JPopupMenu;
023import javax.swing.JScrollPane;
024import javax.swing.JTable;
025import javax.swing.JTextField;
026import javax.swing.ListSelectionModel;
027import javax.swing.RowSorter;
028import javax.swing.SortOrder;
029import javax.swing.border.Border;
030import javax.swing.event.ListSelectionEvent;
031import javax.swing.event.ListSelectionListener;
032import javax.swing.event.RowSorterEvent;
033import javax.swing.table.DefaultTableCellRenderer;
034import javax.swing.table.TableCellRenderer;
035import javax.swing.table.TableColumn;
036import javax.swing.table.TableRowSorter;
037
038import jmri.InstanceManager;
039import jmri.jmrit.roster.Roster;
040import jmri.jmrit.roster.RosterEntry;
041import jmri.jmrit.roster.RosterEntrySelector;
042import jmri.jmrit.roster.rostergroup.RosterGroupSelector;
043import jmri.util.gui.GuiLafPreferencesManager;
044import jmri.util.swing.JmriPanel;
045import jmri.util.swing.JmriMouseAdapter;
046import jmri.util.swing.JmriMouseEvent;
047import jmri.util.swing.JmriMouseListener;
048import jmri.util.swing.MultiLineCellRenderer;
049import jmri.util.swing.XTableColumnModel;
050
051/**
052 * Provide a table of roster entries as a JmriJPanel.
053 *
054 * @author Bob Jacobsen Copyright (C) 2003, 2010
055 * @author Randall Wood Copyright (C) 2013
056 */
057public class RosterTable extends JmriPanel implements RosterEntrySelector, RosterGroupSelector {
058
059    private RosterTableModel dataModel;
060    private TableRowSorter<RosterTableModel> sorter;
061    private JTable dataTable;
062    private JScrollPane dataScroll;
063    private final XTableColumnModel columnModel = new XTableColumnModel();
064    private RosterGroupSelector rosterGroupSource = null;
065    protected transient ListSelectionListener tableSelectionListener;
066    private RosterEntry[] selectedRosterEntries = null;
067    private RosterEntry[] sortedRosterEntries = null;
068    private RosterEntry re = null;
069
070    public RosterTable() {
071        this(false);
072    }
073
074    public RosterTable(boolean editable) {
075        // set to single selection
076        this(editable, ListSelectionModel.SINGLE_SELECTION);
077    }
078
079    public RosterTable(boolean editable, int selectionMode) {
080        super();
081        dataModel = new RosterTableModel(editable);
082        sorter = new TableRowSorter<>(dataModel);
083        sorter.addRowSorterListener(rowSorterEvent -> {
084            if (rowSorterEvent.getType() ==  RowSorterEvent.Type.SORTED) {
085                // clear sorted cache
086                sortedRosterEntries = null;
087            }
088        });
089        dataTable = new JTable(dataModel) {
090            // only use MultiLineRenderer in COMMENTS column
091            @Override
092            public TableCellRenderer getCellRenderer(int row, int column) {
093                var modelColumn = convertColumnIndexToModel(column);
094                if (modelColumn == RosterTableModel.COMMENT) {
095                    return new MultiLineCellRenderer();
096                }
097                return super.getCellRenderer(row, column);
098            }
099        };
100        dataModel.setAssociatedTable(dataTable);  // used for resizing
101        dataModel.setAssociatedSorter(sorter);
102        dataTable.setRowSorter(sorter);
103        dataScroll = new JScrollPane(dataTable);
104        dataTable.setRowHeight(InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize() + 4);
105
106        sorter.setComparator(RosterTableModel.IDCOL, new jmri.util.AlphanumComparator());
107
108        // set initial sort
109        List<RowSorter.SortKey> sortKeys = new ArrayList<>();
110        sortKeys.add(new RowSorter.SortKey(RosterTableModel.ADDRESSCOL, SortOrder.ASCENDING));
111        sorter.setSortKeys(sortKeys);
112
113        // allow reordering of the columns
114        dataTable.getTableHeader().setReorderingAllowed(true);
115
116        // have to shut off autoResizeMode to get horizontal scroll to work (JavaSwing p 541)
117        dataTable.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
118
119        dataTable.setColumnModel(columnModel);
120        dataTable.createDefaultColumnsFromModel();
121        dataTable.setAutoCreateColumnsFromModel(false);
122
123        // format the last updated date time, last operated date time.
124        dataTable.setDefaultRenderer(Date.class, new DateTimeCellRenderer());
125
126        // Start with two columns not visible
127        columnModel.setColumnVisible(columnModel.getColumnByModelIndex(RosterTableModel.DECODERMFGCOL), false);
128        columnModel.setColumnVisible(columnModel.getColumnByModelIndex(RosterTableModel.DECODERFAMILYCOL), false);
129
130        TableColumn tc = columnModel.getColumnByModelIndex(RosterTableModel.PROTOCOL);
131        columnModel.setColumnVisible(tc, false);
132
133        // if the total time operated column exists, set it to DurationRenderer
134        var columns = columnModel.getColumns();
135        while (columns.hasMoreElements()) {
136            TableColumn column = columns.nextElement();
137            if ( Bundle.getMessage(RosterEntry.ATTRIBUTE_OPERATING_DURATION)
138                .equals( column.getHeaderValue().toString())) {
139                column.setCellRenderer( new DurationRenderer() );
140                column.setCellEditor(new DurationCellEditor());
141            }
142        }
143
144        // resize columns as requested
145        resetColumnWidths();
146
147        // general GUI config
148        setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
149
150        // install items in GUI
151        add(dataScroll);
152
153        // set Viewport preferred size from size of table
154        java.awt.Dimension dataTableSize = dataTable.getPreferredSize();
155        // width is right, but if table is empty, it's not high
156        // enough to reserve much space.
157        dataTableSize.height = Math.max(dataTableSize.height, 400);
158        dataTableSize.width = Math.max(dataTableSize.width, 400);
159        dataScroll.getViewport().setPreferredSize(dataTableSize);
160
161        dataTable.setSelectionMode(selectionMode);
162        JmriMouseListener mouseHeaderListener = new TableHeaderListener();
163        dataTable.getTableHeader().addMouseListener(JmriMouseListener.adapt(mouseHeaderListener));
164
165        dataTable.setDefaultEditor(Object.class, new RosterCellEditor());
166        dataTable.setDefaultEditor(Date.class, new DateTimeCellEditor());
167
168        tableSelectionListener = (ListSelectionEvent e) -> {
169            if (!e.getValueIsAdjusting()) {
170                selectedRosterEntries = null; // clear cached list of selections
171                if (dataTable.getSelectedRowCount() == 1) {
172                    re = Roster.getDefault().getEntryForId(dataModel.getValueAt(sorter
173                        .convertRowIndexToModel(dataTable.getSelectedRow()), RosterTableModel.IDCOL).toString());
174                } else if (dataTable.getSelectedRowCount() > 1) {
175                    re = null;
176                } // leave last selected item visible if no selection
177            } else if (e.getFirstIndex() == -1) {
178                // A reorder of the table may have occurred so ensure the selected item is still in view
179                moveTableViewToSelected();
180            }
181        };
182        dataTable.getSelectionModel().addListSelectionListener(tableSelectionListener);
183    }
184
185    public JTable getTable() {
186        return dataTable;
187    }
188
189    public RosterTableModel getModel() {
190        return dataModel;
191    }
192
193    public final void resetColumnWidths() {
194        Enumeration<TableColumn> en = columnModel.getColumns(false);
195        while (en.hasMoreElements()) {
196            TableColumn tc = en.nextElement();
197            int width = dataModel.getPreferredWidth(tc.getModelIndex());
198            tc.setPreferredWidth(width);
199        }
200        dataTable.sizeColumnsToFit(-1);
201    }
202
203    @Override
204    public void dispose() {
205        this.setRosterGroupSource(null);
206        if (dataModel != null) {
207            dataModel.dispose();
208        }
209        dataModel = null;
210        dataTable.getSelectionModel().removeListSelectionListener(tableSelectionListener);
211        dataTable = null;
212        super.dispose();
213    }
214
215    public void setRosterGroup(String rosterGroup) {
216        this.dataModel.setRosterGroup(rosterGroup);
217    }
218
219    public String getRosterGroup() {
220        return this.dataModel.getRosterGroup();
221    }
222
223    /**
224     * @return the rosterGroupSource
225     */
226    public RosterGroupSelector getRosterGroupSource() {
227        return this.rosterGroupSource;
228    }
229
230    /**
231     * @param rosterGroupSource the rosterGroupSource to set
232     */
233    public void setRosterGroupSource(RosterGroupSelector rosterGroupSource) {
234        if (this.rosterGroupSource != null) {
235            this.rosterGroupSource.removePropertyChangeListener(SELECTED_ROSTER_GROUP, dataModel);
236        }
237        this.rosterGroupSource = rosterGroupSource;
238        if (this.rosterGroupSource != null) {
239            this.rosterGroupSource.addPropertyChangeListener(SELECTED_ROSTER_GROUP, dataModel);
240        }
241    }
242
243    protected void showTableHeaderPopup(JmriMouseEvent e) {
244        JPopupMenu popupMenu = new JPopupMenu();
245        for (int i = 0; i < columnModel.getColumnCount(false); i++) {
246            TableColumn tc = columnModel.getColumnByModelIndex(i);
247            JCheckBoxMenuItem menuItem = new JCheckBoxMenuItem(dataTable.getModel()
248                .getColumnName(i), columnModel.isColumnVisible(tc));
249            menuItem.addActionListener(new HeaderActionListener(tc));
250            popupMenu.add(menuItem);
251
252        }
253        popupMenu.show(e.getComponent(), e.getX(), e.getY());
254    }
255
256    protected void moveTableViewToSelected() {
257        if (re == null) {
258            return;
259        }
260        //Remove the listener as this change will re-activate it and we end up in a loop!
261        dataTable.getSelectionModel().removeListSelectionListener(tableSelectionListener);
262        dataTable.clearSelection();
263        int entires = dataTable.getRowCount();
264        for (int i = 0; i < entires; i++) {
265            if (dataModel.getValueAt(sorter.convertRowIndexToModel(i), RosterTableModel.IDCOL).equals(re.getId())) {
266                dataTable.addRowSelectionInterval(i, i);
267                dataTable.scrollRectToVisible(new Rectangle(dataTable.getCellRect(i, 0, true)));
268            }
269        }
270        dataTable.getSelectionModel().addListSelectionListener(tableSelectionListener);
271    }
272
273    @Override
274    public String getSelectedRosterGroup() {
275        return dataModel.getRosterGroup();
276    }
277
278    // cache selectedRosterEntries so that multiple calls to this
279    // between selection changes will not require the creation of a new array
280    @Override
281    public RosterEntry[] getSelectedRosterEntries() {
282        if (selectedRosterEntries == null) {
283            int[] rows = dataTable.getSelectedRows();
284            selectedRosterEntries = new RosterEntry[rows.length];
285            for (int idx = 0; idx < rows.length; idx++) {
286                selectedRosterEntries[idx] = Roster.getDefault().getEntryForId(
287                    dataModel.getValueAt(sorter.convertRowIndexToModel(rows[idx]), RosterTableModel.IDCOL).toString());
288            }
289        }
290        return Arrays.copyOf(selectedRosterEntries, selectedRosterEntries.length);
291    }
292
293    // cache getSortedRosterEntries so that multiple calls to this
294    // between selection changes will not require the creation of a new array
295    public RosterEntry[] getSortedRosterEntries() {
296        if (sortedRosterEntries == null) {
297            sortedRosterEntries = new RosterEntry[sorter.getModelRowCount()];
298            for (int idx = 0; idx < sorter.getModelRowCount(); idx++) {
299                sortedRosterEntries[idx] = Roster.getDefault().getEntryForId(
300                    dataModel.getValueAt(sorter.convertRowIndexToModel(idx), RosterTableModel.IDCOL).toString());
301            }
302        }
303        return Arrays.copyOf(sortedRosterEntries, sortedRosterEntries.length);
304    }
305
306    public void setEditable(boolean editable) {
307        this.dataModel.editable = editable;
308    }
309
310    public boolean getEditable() {
311        return this.dataModel.editable;
312    }
313
314    public void setSelectionMode(int selectionMode) {
315        dataTable.setSelectionMode(selectionMode);
316    }
317
318    public int getSelectionMode() {
319        return dataTable.getSelectionModel().getSelectionMode();
320    }
321
322    public boolean setSelection(RosterEntry... selection) {
323        //Remove the listener as this change will re-activate it and we end up in a loop!
324        dataTable.getSelectionModel().removeListSelectionListener(tableSelectionListener);
325        dataTable.clearSelection();
326        boolean foundIt = false;
327        if (selection != null) {
328            for (RosterEntry entry : selection) {
329                re = entry;
330                int entries = dataTable.getRowCount();
331                for (int i = 0; i < entries; i++) {
332                                    
333                    // skip over entry being deleted from the group
334                    if (dataModel.getValueAt(sorter.convertRowIndexToModel(i), 
335                                                                RosterTableModel.IDCOL) == null) {
336                        continue;
337                    }
338
339                    if (dataModel.getValueAt(sorter.convertRowIndexToModel(i), 
340                                            RosterTableModel.IDCOL)
341                                    .equals(re.getId())) {
342                        dataTable.addRowSelectionInterval(i, i);
343                        foundIt = true;
344                    }
345                }
346            }
347            if (selection.length > 1 || !foundIt) {
348                re = null;
349            } else {
350                this.moveTableViewToSelected();
351            }
352        } else {
353            re = null;
354        }
355        dataTable.getSelectionModel().addListSelectionListener(tableSelectionListener);
356        return foundIt;
357    }
358
359    private class HeaderActionListener implements ActionListener {
360
361        TableColumn tc;
362
363        HeaderActionListener(TableColumn tc) {
364            this.tc = tc;
365        }
366
367        @Override
368        public void actionPerformed(ActionEvent e) {
369            JCheckBoxMenuItem check = (JCheckBoxMenuItem) e.getSource();
370            //Do not allow the last column to be hidden
371            if (!check.isSelected() && columnModel.getColumnCount(true) == 1) {
372                return;
373            }
374            columnModel.setColumnVisible(tc, check.isSelected());
375        }
376    }
377
378    private class TableHeaderListener extends JmriMouseAdapter {
379
380        @Override
381        public void mousePressed(JmriMouseEvent e) {
382            if (e.isPopupTrigger()) {
383                showTableHeaderPopup(e);
384            }
385        }
386
387        @Override
388        public void mouseReleased(JmriMouseEvent e) {
389            if (e.isPopupTrigger()) {
390                showTableHeaderPopup(e);
391            }
392        }
393
394        @Override
395        public void mouseClicked(JmriMouseEvent e) {
396            if (e.isPopupTrigger()) {
397                showTableHeaderPopup(e);
398            }
399        }
400    }
401
402    public class RosterCellEditor extends DefaultCellEditor {
403
404        public RosterCellEditor() {
405            super(new JTextField() {
406
407                @Override
408                public void setBorder(Border border) {
409                    //No border required
410                }
411            });
412        }
413
414        //This allows the cell to be edited using a single click if the row was previously selected, this allows a double on an unselected row to launch the programmer
415        @Override
416        public boolean isCellEditable(java.util.EventObject e) {
417            if (re == null) {
418                //No previous roster entry selected so will take this as a select so no return false to prevent editing
419                return false;
420            }
421
422            if (e instanceof MouseEvent) {
423                MouseEvent me = (MouseEvent) e;
424                //If the click count is not equal to 1 then return false.
425                if (me.getClickCount() != 1) {
426                    return false;
427                }
428            }
429            return re.getId().equals(dataModel.getValueAt(sorter.convertRowIndexToModel(dataTable.getSelectedRow()), RosterTableModel.IDCOL));
430        }
431    }
432
433    private static class DurationRenderer extends DefaultTableCellRenderer {
434
435        @Override
436        public void setValue(Object value) {
437            try {
438                int duration = Integer.parseInt(value.toString());
439                if ( duration != 0 ) {
440                    super.setValue(jmri.util.DateUtil.userDurationFromSeconds(duration));
441                    super.setToolTipText(Bundle.getMessage("DurationViewTip"));
442                    return;
443                }
444            }
445            catch (NumberFormatException e) {
446                log.debug("could not format duration ( String integer of total seconds ) in {}", value, e);
447            }
448            super.setValue(null);
449        }
450    }
451
452    private static class DateTimeCellRenderer extends DefaultTableCellRenderer {
453        @Override
454        protected void setValue(Object value) {
455            if ( value instanceof Date) {
456                super.setValue(DateFormat.getDateTimeInstance().format((Date) value));
457            } else {
458                super.setValue(value);
459            }
460        }
461    }
462
463    private class DateTimeCellEditor extends RosterCellEditor {
464
465        DateTimeCellEditor() {
466            super();
467        }
468
469        private static final String EDITOR_DATE_FORMAT =  "yyyy-MM-dd HH:mm";
470        private Date startDate = new Date();
471
472        @Override
473        public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int col) {
474            if (!(value instanceof Date) ) {
475                value = new Date(); // field pre-populated if currently empty to show entry format
476            }
477            startDate = (Date)value;
478            String formatted = new SimpleDateFormat(EDITOR_DATE_FORMAT).format((Date)value);
479            ((JTextField)editorComponent).setText(formatted);
480            editorComponent.setToolTipText("e.g. 2022-12-25 12:34");
481            return editorComponent;
482        }
483
484        @Override
485        public Object getCellEditorValue() {
486            String o = (String)super.getCellEditorValue();
487            if ( o.isBlank() ) { // user cancels the date / time
488                return null;
489            }
490            SimpleDateFormat fm = new SimpleDateFormat(EDITOR_DATE_FORMAT);
491            try {
492                // get Date in local time before passing to StdDateFormat
493                startDate = fm.parse(o.trim());
494            } catch (ParseException e) {
495            } // return value unchanged in case of user mis-type
496            return new StdDateFormat().format(startDate);
497        }
498
499    }
500
501    private class DurationCellEditor extends RosterCellEditor {
502
503        @Override
504        public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int col) {
505            editorComponent.setToolTipText(Bundle.getMessage("DurationEditTip"));
506            return editorComponent;
507        }
508
509        @Override
510        public Object getCellEditorValue() {
511            return String.valueOf(super.getCellEditorValue());
512        }
513
514    }
515
516    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RosterTable.class);
517
518}