001package jmri.jmrit.roster.swing; 002 003import com.fasterxml.jackson.databind.util.StdDateFormat; 004 005import java.beans.PropertyChangeEvent; 006import java.beans.PropertyChangeListener; 007import java.text.ParseException; 008import java.util.*; 009 010import javax.annotation.CheckForNull; 011import javax.swing.Icon; 012import javax.swing.ImageIcon; 013import javax.swing.JLabel; 014import javax.swing.JTable; 015import javax.swing.RowSorter; 016import javax.swing.table.DefaultTableModel; 017 018import jmri.*; 019import jmri.jmrit.decoderdefn.DecoderIndexFile; 020import jmri.jmrit.roster.Roster; 021import jmri.jmrit.roster.RosterEntry; 022import jmri.jmrit.roster.RosterIconFactory; 023import jmri.jmrit.roster.rostergroup.RosterGroup; 024import jmri.jmrit.roster.rostergroup.RosterGroupSelector; 025import jmri.util.swing.ResizableRowDataModel; 026import jmri.util.gui.GuiLafPreferencesManager; 027 028/** 029 * Table data model for display of Roster variable values. 030 * <p> 031 * Any desired ordering, etc, is handled outside this class. 032 * <p> 033 * The initial implementation doesn't automatically update when roster entries 034 * change, doesn't allow updating of the entries, and only shows some of the 035 * fields. But it's a start.... 036 * 037 * @author Bob Jacobsen Copyright (C) 2009, 2010 038 * @since 2.7.5 039 */ 040public class RosterTableModel extends DefaultTableModel implements PropertyChangeListener, ResizableRowDataModel { 041 042 public static final int IDCOL = 0; 043 static final int ADDRESSCOL = 1; 044 static final int ICONCOL = 2; 045 static final int DECODERMFGCOL = 3; 046 static final int DECODERFAMILYCOL = 4; 047 static final int DECODERMODELCOL = 5; 048 static final int ROADNAMECOL = 6; 049 static final int ROADNUMBERCOL = 7; 050 static final int MFGCOL = 8; 051 static final int MODELCOL = 9; 052 static final int OWNERCOL = 10; 053 static final int DATEUPDATECOL = 11; 054 public static final int PROTOCOL = 12; 055 static final int COMMENT = 13; 056 public static final int NUMCOL = COMMENT + 1; 057 private String rosterGroup = null; 058 boolean editable = false; 059 060 static final PermissionManager permissionManager = InstanceManager.getDefault(PermissionManager.class); 061 062 public RosterTableModel() { 063 this(false); 064 } 065 066 public RosterTableModel(boolean editable) { 067 this.editable = editable; 068 Roster.getDefault().addPropertyChangeListener(RosterTableModel.this); 069 setRosterGroup(null); // add prop change listeners to roster entries 070 } 071 072 /** 073 * Create a table model for a Roster group. 074 * 075 * @param group the roster group to show; if null, behaves the same as 076 * {@link #RosterTableModel()} 077 */ 078 public RosterTableModel(@CheckForNull RosterGroup group) { 079 this(false); 080 if (group != null) { 081 this.setRosterGroup(group.getName()); 082 } 083 } 084 085 JTable associatedTable; 086 public void setAssociatedTable(JTable associatedTable) { 087 this.associatedTable = associatedTable; 088 } 089 090 RowSorter<RosterTableModel> associatedSorter; 091 public void setAssociatedSorter(RowSorter<RosterTableModel> associatedSorter) { 092 this.associatedSorter = associatedSorter; 093 } 094 095 096 @Override 097 public void propertyChange(PropertyChangeEvent e) { 098 if (e.getPropertyName().equals(Roster.ADD)) { 099 setRosterGroup(getRosterGroup()); // add prop change listener to new entry 100 fireTableDataChanged(); 101 } else if (e.getPropertyName().equals(Roster.REMOVE)) { 102 fireTableDataChanged(); 103 } else if (e.getPropertyName().equals(Roster.SAVED)) { 104 //TODO This really needs to do something like find the index of the roster entry here 105 if (e.getSource() instanceof RosterEntry) { 106 int row = Roster.getDefault().getGroupIndex(rosterGroup, (RosterEntry) e.getSource()); 107 fireTableRowsUpdated(row, row); 108 } else { 109 fireTableDataChanged(); 110 } 111 } else if (e.getPropertyName().equals(RosterGroupSelector.SELECTED_ROSTER_GROUP)) { 112 setRosterGroup((e.getNewValue() != null) ? e.getNewValue().toString() : null); 113 } else if (e.getPropertyName().startsWith("attribute") && e.getSource() instanceof RosterEntry) { // NOI18N 114 int row = Roster.getDefault().getGroupIndex(rosterGroup, (RosterEntry) e.getSource()); 115 fireTableRowsUpdated(row, row); 116 } else if (e.getPropertyName().equals(Roster.ROSTER_GROUP_ADDED) && e.getNewValue().equals(rosterGroup)) { 117 fireTableDataChanged(); 118 } 119 } 120 121 @Override 122 public int getRowCount() { 123 return Roster.getDefault().numGroupEntries(rosterGroup); 124 } 125 126 @Override 127 public int getColumnCount() { 128 return NUMCOL + getModelAttributeKeyColumnNames().length; 129 } 130 131 @Override 132 public String getColumnName(int col) { 133 switch (col) { 134 case IDCOL: 135 return Bundle.getMessage("FieldID"); 136 case ADDRESSCOL: 137 return Bundle.getMessage("FieldDCCAddress"); 138 case DECODERMFGCOL: 139 return Bundle.getMessage("FieldDecoderMfg"); 140 case DECODERFAMILYCOL: 141 return Bundle.getMessage("FieldDecoderFamily"); 142 case DECODERMODELCOL: 143 return Bundle.getMessage("FieldDecoderModel"); 144 case MODELCOL: 145 return Bundle.getMessage("FieldModel"); 146 case ROADNAMECOL: 147 return Bundle.getMessage("FieldRoadName"); 148 case ROADNUMBERCOL: 149 return Bundle.getMessage("FieldRoadNumber"); 150 case MFGCOL: 151 return Bundle.getMessage("FieldManufacturer"); 152 case ICONCOL: 153 return Bundle.getMessage("FieldIcon"); 154 case OWNERCOL: 155 return Bundle.getMessage("FieldOwner"); 156 case DATEUPDATECOL: 157 return Bundle.getMessage("FieldDateUpdated"); 158 case PROTOCOL: 159 return Bundle.getMessage("FieldProtocol"); 160 case COMMENT: 161 return Bundle.getMessage("FieldComment"); 162 default: 163 return getColumnNameAttribute(col); 164 } 165 } 166 167 private String getColumnNameAttribute(int col) { 168 if ( col < getColumnCount() ) { 169 String attributeKey = getAttributeKey(col); 170 try { 171 return Bundle.getMessage(attributeKey); 172 } catch (java.util.MissingResourceException ex){} 173 174 String[] r = attributeKey.split("(?=\\p{Lu})"); // NOI18N 175 StringBuilder sb = new StringBuilder(); 176 sb.append(r[0].trim()); 177 for (int j = 1; j < r.length; j++) { 178 sb.append(" "); 179 sb.append(r[j].trim()); 180 } 181 return sb.toString(); 182 } 183 return "<UNKNOWN>"; // NOI18N 184 } 185 186 @Override 187 public Class<?> getColumnClass(int col) { 188 switch (col) { 189 case ADDRESSCOL: 190 return Integer.class; 191 case ICONCOL: 192 return ImageIcon.class; 193 case DATEUPDATECOL: 194 return Date.class; 195 default: 196 return getColumnClassAttribute(col); 197 } 198 } 199 200 private Class<?> getColumnClassAttribute(int col){ 201 if (RosterEntry.ATTRIBUTE_LAST_OPERATED.equals( getAttributeKey(col))) { 202 return Date.class; 203 } 204 if (RosterEntry.ATTRIBUTE_OPERATING_DURATION.equals( getAttributeKey(col))) { 205 return Integer.class; 206 } 207 return String.class; 208 } 209 210 /** 211 * {@inheritDoc} 212 * <p> 213 * Note that the table can be set to be non-editable when constructed, in 214 * which case this always returns false. 215 * 216 * @return true if cell is editable in roster entry model and table allows 217 * editing 218 */ 219 @Override 220 public boolean isCellEditable(int row, int col) { 221 if (col == ADDRESSCOL) { 222 return false; 223 } 224 if (col == PROTOCOL) { 225 return false; 226 } 227 if (col == DECODERMFGCOL) { 228 return false; 229 } 230 if (col == DECODERFAMILYCOL) { 231 return false; 232 } 233 if (col == DECODERMODELCOL) { 234 return false; 235 } 236 if (col == ICONCOL) { 237 return false; 238 } 239 if (col == DATEUPDATECOL) { 240 return false; 241 } 242 if (editable) { 243 // permission to edit optional columns? 244 if ( col >= NUMCOL && col < getColumnCount() ) { 245 if (! permissionManager.hasAtLeastPermission(PermissionsProgrammer.PERMISSION_ROSTER_ADDED_COLUMNS, 246 BooleanPermission.BooleanValue.TRUE)) { 247 return false; 248 } 249 } 250 RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row); 251 if (re != null) { 252 return (!re.isOpen()); 253 } 254 } 255 return editable; 256 } 257 258 RosterIconFactory iconFactory = null; 259 260 ImageIcon getIcon(RosterEntry re) { 261 // defer image handling to RosterIconFactory 262 if (iconFactory == null) { 263 iconFactory = new RosterIconFactory(Math.max(19, new JLabel(getColumnName(0)).getPreferredSize().height)); 264 } 265 return iconFactory.getIcon(re); 266 } 267 268 /** 269 * {@inheritDoc} 270 * 271 * Provides an empty string for a column if the model returns null for that 272 * value. 273 */ 274 @Override 275 public Object getValueAt(int row, int col) { 276 // get roster entry for row 277 RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row); 278 if (re == null) { 279 log.debug("roster entry is null!"); 280 return null; 281 } 282 switch (col) { 283 case IDCOL: 284 return re.getId(); 285 case ADDRESSCOL: 286 return re.getDccLocoAddress().getNumber(); 287 case DECODERMFGCOL: 288 var index = InstanceManager.getDefault(DecoderIndexFile.class); 289 var matches = index.matchingDecoderList( 290 null, re.getDecoderFamily(), 291 null, null, null, 292 re.getDecoderModel() 293 ); 294 if (matches.size() == 0) return ""; 295 return matches.get(0).getMfg(); 296 case DECODERFAMILYCOL: 297 return re.getDecoderFamily(); 298 case DECODERMODELCOL: 299 return re.getDecoderModel(); 300 case MODELCOL: 301 return re.getModel(); 302 case ROADNAMECOL: 303 return re.getRoadName(); 304 case ROADNUMBERCOL: 305 return re.getRoadNumber(); 306 case MFGCOL: 307 return re.getMfg(); 308 case ICONCOL: 309 return getIcon(re); 310 case OWNERCOL: 311 return re.getOwner(); 312 case DATEUPDATECOL: 313 // will not display last update if not parsable as date 314 return re.getDateModified(); 315 case PROTOCOL: 316 return re.getProtocolAsString(); 317 case COMMENT: 318 // have to set height for extra lines 319 resizeRowToText(row, findMaxLines(row, re)); 320 return re.getComment(); 321 default: 322 break; 323 } 324 resizeRowToText(row, findMaxLines(row, re)); 325 return getValueAtAttribute(re, col); 326 } 327 328 int findMaxLines(int row, RosterEntry re) { 329 int lines = 1; 330 331 var columnModel = (jmri.util.swing.XTableColumnModel) associatedTable.getColumnModel(); 332 boolean visible = columnModel.isColumnVisible(columnModel.getColumnByModelIndex(RosterTableModel.COMMENT)); 333 if (visible) { 334 lines = Math.max(lines, countLinesIn(re.getComment())); 335 } 336 337 String[] auxAttributeNames = getModelAttributeKeyColumnNames(); 338 for (String attributeKey : auxAttributeNames) { 339 String value = re.getAttribute(attributeKey); 340 if (value != null) { 341 int index = getAttributeColumn(attributeKey); 342 visible = columnModel.isColumnVisible(columnModel.getColumnByModelIndex(index)); 343 344 int count = countLinesIn(value); 345 if (visible) { 346 lines = Math.max(lines, count); 347 } 348 } 349 } 350 return lines; 351 } 352 353 int countLinesIn(String text) { 354 String[] sections = text.split("\n"); 355 int lines = sections.length; 356 return lines; 357 } 358 359 @Override 360 public void resizeRowToText(int modelRow, int heightInLines) { 361 if (associatedSorter == null || associatedTable == null ) { 362 return; // because not initialized, can't act - useful for tests 363 } 364 365 if (heightInLines < 1) heightInLines = 1; // always show at least one line 366 367 var viewRow = associatedSorter.convertRowIndexToView(modelRow); 368 int height = heightInLines * (InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize() + 4); // same line height as in RosterTable 369 if (height != associatedTable.getRowHeight(viewRow)) { 370 associatedTable.setRowHeight(viewRow, height); 371 } 372 } 373 374 private Object getValueAtAttribute(RosterEntry re, int col){ 375 String attributeKey = getAttributeKey(col); 376 String value = re.getAttribute(attributeKey); // NOI18N 377 if (RosterEntry.ATTRIBUTE_LAST_OPERATED.equals( attributeKey)) { 378 if (value == null){ 379 return null; 380 } 381 try { 382 return new StdDateFormat().parse(value); 383 } catch (ParseException ex){ 384 return null; 385 } 386 } 387 if ( RosterEntry.ATTRIBUTE_OPERATING_DURATION.equals( attributeKey) ) { 388 try { 389 return Integer.valueOf(value); 390 } 391 catch (NumberFormatException e) { 392 log.debug("could not format duration ( String integer of total seconds ) in {}", value, e); 393 } 394 return 0; 395 } 396 return (value == null ? "" : value); 397 } 398 399 @Override 400 public void setValueAt(Object value, int row, int col) { 401 // get roster entry for row 402 RosterEntry re = Roster.getDefault().getGroupEntry(rosterGroup, row); 403 if (re == null) { 404 log.warn("roster entry is null!"); 405 return; 406 } 407 if (re.isOpen()) { 408 log.warn("Entry is already open"); 409 return; 410 } 411 if (Objects.equals(value, getValueAt(row, col))) { 412 return; 413 } 414 String valueToSet = (String) value; 415 switch (col) { 416 case IDCOL: 417 // don't allow setting to existing ID 418 if (Roster.getDefault().getEntryForId(valueToSet)!=null) { 419 jmri.util.swing.JmriJOptionPane.showMessageDialog(associatedTable, Bundle.getMessage("MessageIdAlreadyPresent")); 420 return; 421 } 422 // here unique 423 re.setId(valueToSet); 424 break; 425 case ROADNAMECOL: 426 re.setRoadName(valueToSet); 427 break; 428 case ROADNUMBERCOL: 429 re.setRoadNumber(valueToSet); 430 break; 431 case MFGCOL: 432 re.setMfg(valueToSet); 433 break; 434 case MODELCOL: 435 re.setModel(valueToSet); 436 break; 437 case OWNERCOL: 438 re.setOwner(valueToSet); 439 break; 440 case COMMENT: 441 re.setComment(valueToSet); 442 break; 443 default: 444 // permission to edit optional columns? 445 if (! permissionManager.ensureAtLeastPermission(PermissionsProgrammer.PERMISSION_ROSTER_ADDED_COLUMNS, 446 BooleanPermission.BooleanValue.TRUE)) { 447 return; 448 } 449 450 setValueAtAttribute(valueToSet, re, col); 451 break; 452 } 453 // need to mark as updated 454 re.changeDateUpdated(); 455 re.updateFile(); 456 } 457 458 private void setValueAtAttribute(String valueToSet, RosterEntry re, int col) { 459 String attributeKey = getAttributeKey(col); 460 if ((valueToSet == null) || valueToSet.isEmpty()) { 461 re.deleteAttribute(attributeKey); 462 } else { 463 re.putAttribute(attributeKey, valueToSet); 464 } 465 } 466 467 public int getPreferredWidth(int column) { 468 int retval = 20; // always take some width 469 retval = Math.max(retval, new JLabel(getColumnName(column)) 470 .getPreferredSize().width + 15); // leave room for sorter arrow 471 for (int row = 0; row < getRowCount(); row++) { 472 if (getColumnClass(column).equals(String.class)) { 473 retval = Math.max(retval, new JLabel(getValueAt(row, column).toString()).getPreferredSize().width); 474 } else if (getColumnClass(column).equals(Integer.class)) { 475 retval = Math.max(retval, new JLabel(getValueAt(row, column).toString()).getPreferredSize().width); 476 } else if (getColumnClass(column).equals(ImageIcon.class)) { 477 retval = Math.max(retval, new JLabel((Icon) getValueAt(row, column)).getPreferredSize().width); 478 } 479 } 480 return retval + 5; 481 } 482 483 public final void setRosterGroup(String rosterGroup) { 484 Roster.getDefault().getEntriesInGroup(this.rosterGroup).forEach( re -> 485 re.removePropertyChangeListener(this)); 486 this.rosterGroup = rosterGroup; 487 Roster.getDefault().getEntriesInGroup(rosterGroup).forEach( re -> 488 re.addPropertyChangeListener(this)); 489 fireTableDataChanged(); 490 } 491 492 public final String getRosterGroup() { 493 return this.rosterGroup; 494 } 495 496 // access via method to ensure not null 497 private String[] attributeKeys = null; 498 499 private String[] getModelAttributeKeyColumnNames() { 500 if ( attributeKeys == null ) { 501 Set<String> result = new TreeSet<>(); 502 for (String s : Roster.getDefault().getAllAttributeKeys()) { 503 if ( !s.contains("RosterGroup") 504 && !s.toLowerCase().startsWith("sys") 505 && !s.toUpperCase().startsWith("VSD")) { // NOI18N 506 result.add(s); 507 } 508 } 509 attributeKeys = result.toArray(String[]::new); 510 } 511 return attributeKeys; 512 } 513 514 private int getAttributeColumn(String attr) { 515 var names = getModelAttributeKeyColumnNames(); 516 for (int i = 0; i < names.length; i++) { 517 if (names[i].equals(attr)) { 518 return i + NUMCOL; 519 } 520 } 521 return 0; 522 } 523 524 private String getAttributeKey(int col) { 525 if ( col >= NUMCOL && col < getColumnCount() ) { 526 return getModelAttributeKeyColumnNames()[col - NUMCOL ]; 527 } 528 return ""; 529 } 530 531 // drop listeners 532 public void dispose() { 533 Roster.getDefault().removePropertyChangeListener(this); 534 Roster.getDefault().getEntriesInGroup(this.rosterGroup).forEach( re -> 535 re.removePropertyChangeListener(this) ); 536 } 537 538 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RosterTableModel.class); 539 540}