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