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