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}