001package jmri.swing; 002 003import java.awt.Component; 004import java.beans.PropertyChangeListener; 005import java.util.Comparator; 006import java.util.HashSet; 007import java.util.Set; 008import java.util.TreeSet; 009import java.util.Vector; 010 011import javax.swing.ComboBoxModel; 012import javax.swing.DefaultComboBoxModel; 013import javax.swing.JComboBox; 014import javax.swing.JComponent; 015import javax.swing.JLabel; 016import javax.swing.JList; 017import javax.swing.ListCellRenderer; 018import javax.swing.UIManager; 019import javax.swing.text.JTextComponent; 020 021import com.alexandriasoftware.swing.JInputValidatorPreferences; 022import com.alexandriasoftware.swing.JInputValidator; 023import com.alexandriasoftware.swing.Validation; 024import java.awt.event.ActionListener; 025import javax.swing.ComboBoxEditor; 026 027import org.slf4j.Logger; 028import org.slf4j.LoggerFactory; 029 030import jmri.Manager; 031import jmri.NamedBean; 032import jmri.ProvidingManager; 033import jmri.NamedBean.DisplayOptions; 034import jmri.beans.SwingPropertyChangeListener; 035import jmri.util.NamedBeanComparator; 036import jmri.util.NamedBeanUserNameComparator; 037 038/** 039 * A {@link javax.swing.JComboBox} for {@link jmri.NamedBean}s. 040 * <p> 041 * When editable, this will create a new NamedBean if backed by a 042 * {@link jmri.ProvidingManager} if {@link #getSelectedItem()} is called and the 043 * current text is neither the system name nor user name of an existing 044 * NamedBean. This will also validate input when editable, showing an 045 * Information (blue I in circle) icon to indicate a name will be used to create 046 * a new Named Bean, an Error (red X in circle) icon to indicate a typed in name 047 * cannot be used (either because it would not be valid as a user name or system 048 * name or because the name of an existing NamedBean not usable in the current 049 * context has been entered, or no icon to indicate the name of an existing 050 * Named Bean has been entered. 051 * <p> 052 * When not editable, this will allow (but may not actively show) continual 053 * typing of a system name or a user name by a user to match a NamedBean even if 054 * only the system name or user name or both are displayed (e.g. if a list of 055 * turnouts is shown by user name only, a user may type in the system name of 056 * the turnout and the turnout will be selected correctly). If the typing speed 057 * is slower than the {@link javax.swing.UIManager}'s 058 * {@code ComboBox.timeFactor} setting, keyboard input acts like a normal 059 * JComboBox, with only the first character displayed matching the user input. 060 * <p> 061 * <strong>Note:</strong> It is recommended that implementations that exclude 062 * some NamedBeans from the combo box call {@link #setToolTipText(String)} to 063 * provide a context specific reason for excluding those items. The default tool 064 * tip reads (example for Turnouts) "Turnouts not shown cannot be used in this 065 * context.", but a better tool tip (example for Signal Heads when creating a 066 * Signal Mast) may be "Signal Heads not shown are assigned to another Signal 067 * Mast." 068 * <p> 069 * To change the tool tip text shown when an existing bean is not selected, this 070 * class should be subclassed and the methods 071 * {@link #getBeanInUseMessage(java.lang.String, java.lang.String)}, 072 * {@link #getInvalidNameFormatMessage(java.lang.String, java.lang.String, java.lang.String)}, 073 * {@link #getNoMatchingBeanMessage(java.lang.String, java.lang.String)}, and 074 * {@link #getWillCreateBeanMessage(java.lang.String, java.lang.String)} should 075 * be overridden. 076 * 077 * @param <B> the supported type of NamedBean 078 */ 079public class NamedBeanComboBox<B extends NamedBean> extends JComboBox<B> { 080 081 private final transient Manager<B> manager; 082 private DisplayOptions displayOptions; 083 private boolean allowNull = false; 084 private boolean providing = true; 085 private boolean validatingInput = true; 086 private final transient Set<B> excludedItems = new HashSet<>(); 087 private final transient PropertyChangeListener managerListener = 088 new SwingPropertyChangeListener(evt -> sort()); 089 private String userInput = null; 090 private static final Logger log = LoggerFactory.getLogger(NamedBeanComboBox.class); 091 092 /** 093 * Create a ComboBox without a selection using the 094 * {@link DisplayOptions#DISPLAYNAME} to sort NamedBeans. 095 * 096 * @param manager the Manager backing the ComboBox 097 */ 098 public NamedBeanComboBox(Manager<B> manager) { 099 this(manager, null); 100 } 101 102 /** 103 * Create a ComboBox with an existing selection using the 104 * {@link DisplayOptions#DISPLAYNAME} to sort NamedBeans. 105 * 106 * @param manager the Manager backing the ComboBox 107 * @param selection the NamedBean that is selected or null to specify no 108 * selection 109 */ 110 public NamedBeanComboBox(Manager<B> manager, B selection) { 111 this(manager, selection, DisplayOptions.DISPLAYNAME); 112 } 113 114 /** 115 * Create a ComboBox with an existing selection using the specified display 116 * order to sort NamedBeans. 117 * 118 * @param manager the Manager backing the ComboBox 119 * @param selection the NamedBean that is selected or null to specify no 120 * selection 121 * @param displayOrder the sorting scheme for NamedBeans 122 */ 123 public NamedBeanComboBox(Manager<B> manager, B selection, DisplayOptions displayOrder) { 124 // uses NamedBeanComboBox.this... to prevent overridden methods from being 125 // called in constructor 126 super(); 127 this.manager = manager; 128 super.setToolTipText( 129 Bundle.getMessage("NamedBeanComboBoxDefaultToolTipText", this.manager.getBeanTypeHandled(true))); 130 setDisplayOrder(displayOrder); 131 NamedBeanComboBox.this.setEditable(false); 132 NamedBeanRenderer namedBeanRenderer = new NamedBeanRenderer(getRenderer()); 133 setRenderer(namedBeanRenderer); 134 setKeySelectionManager(namedBeanRenderer); 135 NamedBeanEditor namedBeanEditor = new NamedBeanEditor(getEditor()); 136 setEditor(namedBeanEditor); 137 this.manager.addPropertyChangeListener("beans", managerListener); 138 this.manager.addPropertyChangeListener("DisplayListName", managerListener); 139 sort(); 140 NamedBeanComboBox.this.setSelectedItem(selection); 141 } 142 143 public Manager<B> getManager() { 144 return manager; 145 } 146 147 public DisplayOptions getDisplayOrder() { 148 return displayOptions; 149 } 150 151 public final void setDisplayOrder(DisplayOptions displayOrder) { 152 if (displayOptions != displayOrder) { 153 displayOptions = displayOrder; 154 sort(); 155 } 156 } 157 158 /** 159 * Is this JComboBox validating typed input? 160 * 161 * @return true if validating input; false otherwise 162 */ 163 public boolean isValidatingInput() { 164 return validatingInput; 165 } 166 167 /** 168 * Set if this JComboBox validates typed input. 169 * 170 * @param validatingInput true to validate; false to prevent validation 171 */ 172 public void setValidatingInput(boolean validatingInput) { 173 this.validatingInput = validatingInput; 174 } 175 176 /** 177 * Is this JComboBox allowing a null object to be selected? 178 * 179 * @return true if allowing a null selection; false otherwise 180 */ 181 public boolean isAllowNull() { 182 return allowNull; 183 } 184 185 /** 186 * Set if this JComboBox allows a null object to be selected. If so, the 187 * null object is placed first in the displayed list of NamedBeans. 188 * 189 * @param allowNull true if allowing a null selection; false otherwise 190 */ 191 public void setAllowNull(boolean allowNull) { 192 this.allowNull = allowNull; 193 if (allowNull && (getModel().getSize() > 0 && getItemAt(0) != null)) { 194 this.insertItemAt(null, 0); 195 } else if (!allowNull && (getModel().getSize() > 0 && this.getItemAt(0) == null)) { 196 this.removeItemAt(0); 197 } 198 } 199 200 /** 201 * {@inheritDoc} 202 * <p> 203 * To get the current selection <em>without</em> potentially creating a 204 * NamedBean call {@link #getItemAt(int)} with {@link #getSelectedIndex()} 205 * as the index instead (as in {@code getItemAt(getSelectedIndex())}). 206 * 207 * @return the selected item as the supported type of NamedBean, creating a 208 * new NamedBean as needed if {@link #isEditable()} and 209 * {@link #isProviding()} are true, or null if there is no 210 * selection, or {@link #isAllowNull()} is true and the null object 211 * is selected 212 */ 213 @Override 214 public B getSelectedItem() { 215 B item = getItemAt(getSelectedIndex()); 216 if (isEditable() && providing && item == null) { 217 Component ec = getEditor().getEditorComponent(); 218 if (ec instanceof JTextComponent && manager instanceof ProvidingManager) { 219 JTextComponent jtc = (JTextComponent) ec; 220 userInput = jtc.getText(); 221 if (userInput != null && 222 !userInput.isEmpty() && 223 ((manager.isValidSystemNameFormat(userInput)) || userInput.equals(NamedBean.normalizeUserName(userInput)))) { 224 ProvidingManager<B> pm = (ProvidingManager<B>) manager; 225 item = pm.provide(userInput); 226 setSelectedItem(item); 227 } 228 } 229 } 230 return item; 231 } 232 233 /** 234 * Check if new NamedBeans can be provided by a 235 * {@link jmri.ProvidingManager} when {@link #isEditable} returns 236 * {@code true}. 237 * 238 * @return {@code true} is allowing new NamedBeans to be provided; 239 * {@code false} otherwise 240 */ 241 public boolean isProviding() { 242 return providing; 243 } 244 245 /** 246 * Set if new NamedBeans can be provided by a {@link jmri.ProvidingManager} 247 * when {@link #isEditable()} returns {@code true}. 248 * 249 * @param providing {@code true} to allow new NamedBeans to be provided; 250 * {@code false} otherwise 251 */ 252 public void setProviding(boolean providing) { 253 this.providing = providing; 254 } 255 256 @Override 257 public void setEditable(boolean editable) { 258 if (editable && !(manager instanceof ProvidingManager)) { 259 log.error("Unable to set editable to true because not backed by editable manager"); 260 return; // refuse to allow editing if unable to accept user input 261 } 262 if (editable && !providing) { 263 log.error("Refusing to set editable if not allowing new NamedBeans to be created"); 264 return; // refuse to allow editing if not allowing user input to be 265 // accepted 266 } 267 super.setEditable(editable); 268 } 269 270 /** 271 * Get the display name of the selected item. 272 * 273 * @return the display name of the selected item or null if the selected 274 * item is null or there is no selection 275 */ 276 public String getSelectedItemDisplayName() { 277 B item = getSelectedItem(); 278 return item != null ? item.getDisplayName() : null; 279 } 280 281 /** 282 * Get the system name of the selected item. 283 * 284 * @return the system name of the selected item or null if the selected item 285 * is null or there is no selection 286 */ 287 public String getSelectedItemSystemName() { 288 B item = getSelectedItem(); 289 return item != null ? item.getSystemName() : null; 290 } 291 292 /** 293 * Get the user name of the selected item. 294 * 295 * @return the user name of the selected item or null if the selected item 296 * is null or there is no selection 297 */ 298 public String getSelectedItemUserName() { 299 B item = getSelectedItem(); 300 return item != null ? item.getUserName() : null; 301 } 302 303 /** 304 * {@inheritDoc} 305 */ 306 @Override 307 public void setSelectedItem(Object item) { 308 super.setSelectedItem(item); 309 if (getItemAt(getSelectedIndex()) != null) { 310 userInput = null; 311 } 312 } 313 314 /** 315 * Set the selected item by either its user name or system name. 316 * 317 * @param name the name of the item to select 318 * @throws IllegalArgumentException if {@link #isAllowNull()} is false and 319 * no bean exists by name or name is null 320 */ 321 public void setSelectedItemByName(String name) { 322 B item = null; 323 if (name != null) { 324 item = manager.getNamedBean(name); 325 } 326 if (item == null && !allowNull) { 327 throw new IllegalArgumentException(); 328 } 329 setSelectedItem(item); 330 } 331 332 public void dispose() { 333 manager.removePropertyChangeListener("beans", managerListener); 334 manager.removePropertyChangeListener("DisplayListName", managerListener); 335 } 336 337 private void sort() { 338 // use getItemAt instead of getSelectedItem to avoid 339 // possibility of creating a NamedBean in this method 340 B selectedItem = getItemAt(getSelectedIndex()); 341 Comparator<B> comparator = new NamedBeanComparator<>(); 342 if (displayOptions != DisplayOptions.SYSTEMNAME && displayOptions != DisplayOptions.QUOTED_SYSTEMNAME) { 343 comparator = new NamedBeanUserNameComparator<>(); 344 } 345 TreeSet<B> set = new TreeSet<>(comparator); 346 set.addAll(manager.getNamedBeanSet()); 347 set.removeAll(excludedItems); 348 Vector<B> vector = new Vector<>(set); 349 if (allowNull) { 350 vector.add(0, null); 351 } 352 setModel(new DefaultComboBoxModel<>(vector)); 353 // retain selection 354 if (selectedItem == null && userInput != null) { 355 setSelectedItemByName(userInput); 356 } else { 357 setSelectedItem(selectedItem); 358 } 359 } 360 361 /** 362 * Get the localized message to display in a tooltip when a typed in bean 363 * name matches a named bean has been included in a call to 364 * {@link #setExcludedItems(java.util.Set)} and {@link #isValidatingInput()} 365 * is {@code true}. 366 * 367 * @param beanType the type of bean as provided by 368 * {@link Manager#getBeanTypeHandled()} 369 * @param displayName the bean name as provided by 370 * {@link NamedBean#getDisplayName(jmri.NamedBean.DisplayOptions)} 371 * with the options in {@link #getDisplayOrder()} 372 * @return the localized message 373 */ 374 public String getBeanInUseMessage(String beanType, String displayName) { 375 return Bundle.getMessage("NamedBeanComboBoxBeanInUse", beanType, displayName); 376 } 377 378 /** 379 * Get the localized message to display in a tooltip when a typed in bean 380 * name is not a valid name format for creating a bean. 381 * 382 * @param beanType the type of bean as provided by 383 * {@link Manager#getBeanTypeHandled()} 384 * @param text the typed in name 385 * @param exception the localized message text from the exception thrown by 386 * {@link Manager#validateSystemNameFormat(java.lang.String, java.util.Locale)} 387 * @return the localized message 388 */ 389 public String getInvalidNameFormatMessage(String beanType, String text, String exception) { 390 return Bundle.getMessage("NamedBeanComboBoxInvalidNameFormat", beanType, text, exception); 391 } 392 393 /** 394 * Get the localized message to display when a typed in bean name does not 395 * match a named bean, {@link #isValidatingInput()} is {@code true} and 396 * {@link #isProviding()} is {@code false}. 397 * 398 * @param beanType the type of bean as provided by 399 * {@link Manager#getBeanTypeHandled()} 400 * @param text the typed in name 401 * @return the localized message 402 */ 403 public String getNoMatchingBeanMessage(String beanType, String text) { 404 return Bundle.getMessage("NamedBeanComboBoxNoMatchingBean", beanType, text); 405 } 406 407 /** 408 * Get the localized message to display when a typed in bean name does not 409 * match a named bean, {@link #isValidatingInput()} is {@code true} and 410 * {@link #isProviding()} is {@code true}. 411 * 412 * @param beanType the type of bean as provided by 413 * {@link Manager#getBeanTypeHandled()} 414 * @param text the typed in name 415 * @return the localized message 416 */ 417 public String getWillCreateBeanMessage(String beanType, String text) { 418 return Bundle.getMessage("NamedBeanComboBoxWillCreateBean", beanType, text); 419 } 420 421 public Set<B> getExcludedItems() { 422 return excludedItems; 423 } 424 425 /** 426 * Collection of named beans managed by the manager for this combo box that 427 * should not be included in the combo box. This may be, for example, a list 428 * of SignalHeads already in use, and therefor not available to be added to 429 * a SignalMast. 430 * 431 * @param excludedItems items to be excluded from this combo box 432 */ 433 public void setExcludedItems(Set<B> excludedItems) { 434 this.excludedItems.clear(); 435 this.excludedItems.addAll(excludedItems); 436 sort(); 437 } 438 439 private class NamedBeanEditor implements ComboBoxEditor { 440 441 private final ComboBoxEditor editor; 442 443 /** 444 * Create a NamedBeanEditor using another editor as its base. This 445 * allows the NamedBeanEditor to inherit any platform-specific behaviors 446 * that the default editor may implement. 447 * 448 * @param editor the underlying editor 449 */ 450 public NamedBeanEditor(ComboBoxEditor editor) { 451 this.editor = editor; 452 Component ec = editor.getEditorComponent(); 453 if (ec instanceof JComponent) { 454 JComponent jc = (JComponent) ec; 455 jc.setInputVerifier(new JInputValidator(jc, true, false) { 456 @Override 457 protected Validation getValidation(JComponent component, JInputValidatorPreferences preferences) { 458 if (component instanceof JTextComponent) { 459 JTextComponent jtc = (JTextComponent) component; 460 String text = jtc.getText(); 461 if (text != null && !text.isEmpty()) { 462 B bean = manager.getNamedBean(text); 463 if (bean != null) { 464 // selection won't change if bean is not in model 465 setSelectedItem(bean); 466 if (!bean.equals(getItemAt(getSelectedIndex()))) { 467 if (getSelectedIndex() != -1) { 468 jtc.setText(text); 469 if (validatingInput) { 470 return new Validation(Validation.Type.DANGER, 471 getBeanInUseMessage(manager.getBeanTypeHandled(), 472 bean.getDisplayName(DisplayOptions.QUOTED_DISPLAYNAME)), 473 preferences); 474 } 475 } 476 } 477 } else { 478 if (validatingInput) { 479 if (providing) { 480 try { 481 // ignore output, only interested in exceptions 482 manager.validateSystemNameFormat(text); 483 } catch (IllegalArgumentException ex) { 484 return new Validation(Validation.Type.DANGER, 485 getInvalidNameFormatMessage(manager.getBeanTypeHandled(), text, 486 ex.getLocalizedMessage()), 487 preferences); 488 } 489 return new Validation(Validation.Type.INFORMATION, 490 getWillCreateBeanMessage(manager.getBeanTypeHandled(), text), 491 preferences); 492 } else { 493 return new Validation(Validation.Type.WARNING, 494 getNoMatchingBeanMessage(manager.getBeanTypeHandled(), text), 495 preferences); 496 } 497 } 498 } 499 } 500 } 501 return getNoneValidation(); 502 } 503 }); 504 } 505 } 506 507 @Override 508 public Component getEditorComponent() { 509 return editor.getEditorComponent(); 510 } 511 512 @Override 513 public void setItem(Object anObject) { 514 Component c = getEditorComponent(); 515 if (c instanceof JTextComponent) { 516 JTextComponent jtc = (JTextComponent) c; 517 if (anObject instanceof NamedBean) { 518 NamedBean nb = (NamedBean) anObject; 519 jtc.setText(nb.getDisplayName(displayOptions)); 520 } else { 521 jtc.setText(""); 522 } 523 } else { 524 editor.setItem(anObject); 525 } 526 } 527 528 @Override 529 public Object getItem() { 530 return editor.getItem(); 531 } 532 533 @Override 534 public void selectAll() { 535 editor.selectAll(); 536 } 537 538 @Override 539 public void addActionListener(ActionListener l) { 540 editor.addActionListener(l); 541 } 542 543 @Override 544 public void removeActionListener(ActionListener l) { 545 editor.removeActionListener(l); 546 } 547 } 548 549 private class NamedBeanRenderer implements ListCellRenderer<B>, JComboBox.KeySelectionManager { 550 551 private final ListCellRenderer<? super B> renderer; 552 private final long timeFactor; 553 private long lastTime; 554 private String prefix = ""; 555 556 public NamedBeanRenderer(ListCellRenderer<? super B> renderer) { 557 this.renderer = renderer; 558 Long l = (Long) UIManager.get("ComboBox.timeFactor"); 559 timeFactor = l != null ? l : 1000; 560 } 561 562 @Override 563 public Component getListCellRendererComponent(JList<? extends B> list, B value, int index, boolean isSelected, 564 boolean cellHasFocus) { 565 JLabel label = (JLabel) renderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); 566 if (value != null) { 567 label.setText(value.getDisplayName(displayOptions)); 568 } 569 return label; 570 } 571 572 /** 573 * {@inheritDoc} 574 */ 575 @Override 576 @SuppressWarnings({"unchecked", "rawtypes"}) // unchecked cast due to API constraints 577 public int selectionForKey(char key, ComboBoxModel model) { 578 long time = System.currentTimeMillis(); 579 580 // Get the index of the currently selected item 581 int size = model.getSize(); 582 int startIndex = -1; 583 B selectedItem = (B) model.getSelectedItem(); 584 585 if (selectedItem != null) { 586 for (int i = 0; i < size; i++) { 587 if (selectedItem == model.getElementAt(i)) { 588 startIndex = i; 589 break; 590 } 591 } 592 } 593 594 // Determine the "prefix" to be used when searching the model. The 595 // prefix can be a single letter or multiple letters depending on 596 // how 597 // fast the user has been typing and on which letter has been typed. 598 if (time - lastTime < timeFactor) { 599 if ((prefix.length() == 1) && (key == prefix.charAt(0))) { 600 // Subsequent same key presses move the keyboard focus to 601 // the next object that starts with the same letter. 602 startIndex++; 603 } else { 604 prefix += key; 605 } 606 } else { 607 startIndex++; 608 prefix = "" + key; 609 } 610 611 lastTime = time; 612 613 // Search from the current selection and wrap when no match is found 614 if (startIndex < 0 || startIndex >= size) { 615 startIndex = 0; 616 } 617 618 int index = getNextMatch(prefix, startIndex, size, model); 619 620 if (index < 0) { 621 // wrap 622 index = getNextMatch(prefix, 0, startIndex, model); 623 } 624 625 return index; 626 } 627 628 /** 629 * Find the index of the item in the model that starts with the prefix. 630 */ 631 @SuppressWarnings({"unchecked", "rawtypes"}) // unchecked cast due to API constraints 632 private int getNextMatch(String prefix, int start, int end, ComboBoxModel model) { 633 for (int i = start; i < end; i++) { 634 B item = (B) model.getElementAt(i); 635 636 if (item != null) { 637 String userName = item.getUserName(); 638 639 if (item.getSystemName().toLowerCase().startsWith(prefix) || 640 (userName != null && userName.toLowerCase().startsWith(prefix))) { 641 return i; 642 } 643 } 644 } 645 return -1; 646 } 647 } 648 649}