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}