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