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}