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