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 319 // skip over entry being deleted from the group 320 if (dataModel.getValueAt(sorter.convertRowIndexToModel(i), 321 RosterTableModel.IDCOL) == null) { 322 continue; 323 } 324 325 if (dataModel.getValueAt(sorter.convertRowIndexToModel(i), 326 RosterTableModel.IDCOL) 327 .equals(re.getId())) { 328 dataTable.addRowSelectionInterval(i, i); 329 foundIt = true; 330 } 331 } 332 } 333 if (selection.length > 1 || !foundIt) { 334 re = null; 335 } else { 336 this.moveTableViewToSelected(); 337 } 338 } else { 339 re = null; 340 } 341 dataTable.getSelectionModel().addListSelectionListener(tableSelectionListener); 342 return foundIt; 343 } 344 345 private class HeaderActionListener implements ActionListener { 346 347 TableColumn tc; 348 349 HeaderActionListener(TableColumn tc) { 350 this.tc = tc; 351 } 352 353 @Override 354 public void actionPerformed(ActionEvent e) { 355 JCheckBoxMenuItem check = (JCheckBoxMenuItem) e.getSource(); 356 //Do not allow the last column to be hidden 357 if (!check.isSelected() && columnModel.getColumnCount(true) == 1) { 358 return; 359 } 360 columnModel.setColumnVisible(tc, check.isSelected()); 361 } 362 } 363 364 private class TableHeaderListener extends JmriMouseAdapter { 365 366 @Override 367 public void mousePressed(JmriMouseEvent e) { 368 if (e.isPopupTrigger()) { 369 showTableHeaderPopup(e); 370 } 371 } 372 373 @Override 374 public void mouseReleased(JmriMouseEvent e) { 375 if (e.isPopupTrigger()) { 376 showTableHeaderPopup(e); 377 } 378 } 379 380 @Override 381 public void mouseClicked(JmriMouseEvent e) { 382 if (e.isPopupTrigger()) { 383 showTableHeaderPopup(e); 384 } 385 } 386 } 387 388 public class RosterCellEditor extends DefaultCellEditor { 389 390 public RosterCellEditor() { 391 super(new JTextField() { 392 393 @Override 394 public void setBorder(Border border) { 395 //No border required 396 } 397 }); 398 } 399 400 //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 401 @Override 402 public boolean isCellEditable(java.util.EventObject e) { 403 if (re == null) { 404 //No previous roster entry selected so will take this as a select so no return false to prevent editing 405 return false; 406 } 407 408 if (e instanceof MouseEvent) { 409 MouseEvent me = (MouseEvent) e; 410 //If the click count is not equal to 1 then return false. 411 if (me.getClickCount() != 1) { 412 return false; 413 } 414 } 415 return re.getId().equals(dataModel.getValueAt(sorter.convertRowIndexToModel(dataTable.getSelectedRow()), RosterTableModel.IDCOL)); 416 } 417 } 418 419 private static class DurationRenderer extends DefaultTableCellRenderer { 420 421 @Override 422 public void setValue(Object value) { 423 try { 424 int duration = Integer.parseInt(value.toString()); 425 if ( duration != 0 ) { 426 super.setValue(jmri.util.DateUtil.userDurationFromSeconds(duration)); 427 super.setToolTipText(Bundle.getMessage("DurationViewTip")); 428 return; 429 } 430 } 431 catch (NumberFormatException e) { 432 log.debug("could not format duration ( String integer of total seconds ) in {}", value, e); 433 } 434 super.setValue(null); 435 } 436 } 437 438 private static class DateTimeCellRenderer extends DefaultTableCellRenderer { 439 @Override 440 protected void setValue(Object value) { 441 if ( value instanceof Date) { 442 super.setValue(DateFormat.getDateTimeInstance().format((Date) value)); 443 } else { 444 super.setValue(value); 445 } 446 } 447 } 448 449 private class DateTimeCellEditor extends RosterCellEditor { 450 451 DateTimeCellEditor() { 452 super(); 453 } 454 455 private static final String EDITOR_DATE_FORMAT = "yyyy-MM-dd HH:mm"; 456 private Date startDate = new Date(); 457 458 @Override 459 public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int col) { 460 if (!(value instanceof Date) ) { 461 value = new Date(); // field pre-populated if currently empty to show entry format 462 } 463 startDate = (Date)value; 464 String formatted = new SimpleDateFormat(EDITOR_DATE_FORMAT).format((Date)value); 465 ((JTextField)editorComponent).setText(formatted); 466 editorComponent.setToolTipText("e.g. 2022-12-25 12:34"); 467 return editorComponent; 468 } 469 470 @Override 471 public Object getCellEditorValue() { 472 String o = (String)super.getCellEditorValue(); 473 if ( o.isBlank() ) { // user cancels the date / time 474 return null; 475 } 476 SimpleDateFormat fm = new SimpleDateFormat(EDITOR_DATE_FORMAT); 477 try { 478 // get Date in local time before passing to StdDateFormat 479 startDate = fm.parse(o.trim()); 480 } catch (ParseException e) { 481 } // return value unchanged in case of user mis-type 482 return new StdDateFormat().format(startDate); 483 } 484 485 } 486 487 private class DurationCellEditor extends RosterCellEditor { 488 489 @Override 490 public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int col) { 491 editorComponent.setToolTipText(Bundle.getMessage("DurationEditTip")); 492 return editorComponent; 493 } 494 495 @Override 496 public Object getCellEditorValue() { 497 return String.valueOf(super.getCellEditorValue()); 498 } 499 500 } 501 502 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RosterTable.class); 503 504}