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}