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