/
NamedBeanComboBox.java
510 lines (457 loc) · 18.3 KB
/
NamedBeanComboBox.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
package jmri.swing;
import java.awt.Component;
import java.beans.PropertyChangeListener;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.Vector;
import javax.swing.ComboBoxModel;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.ListCellRenderer;
import javax.swing.UIManager;
import javax.swing.text.JTextComponent;
import com.alexandriasoftware.swing.JInputValidatorPreferences;
import com.alexandriasoftware.swing.NonVerifyingValidator;
import com.alexandriasoftware.swing.Validation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jmri.Manager;
import jmri.NamedBean;
import jmri.ProvidingManager;
import jmri.util.NamedBeanComparator;
import jmri.util.NamedBeanUserNameComparator;
import jmri.util.ThreadingPropertyChangeListener;
/**
* A {@link javax.swing.JComboBox} for {@link jmri.NamedBean}s.
* <p>
* Validation of user input to select a NamedBean is limited to setting the
* selection to a NamedBean matching the typed input, and is always enabled
* unless {@link #setEditable(boolean)} is called against this JComboBox with
* {@code false}. API hooks exist for more complex validation, although they
* currently do nothing.
* <p>
* <strong>Note:</strong> It is recommended that implementations that exclude
* some NamedBeans from the combo box call {@link #setToolTipText(String)} to
* provide a context specific reason for excluding those items. The default tool
* tip reads (example for Turnouts) "Turnouts not shown cannot be used in this
* context.", but a better tool tip (example for Signal Heads when creating a
* Signal Mast) may be "Signal Heads not shown are assigned to another Signal
* Mast."
*
* @param <B> the supported type of NamedBean
*/
public class NamedBeanComboBox<B extends NamedBean> extends JComboBox<B> {
private final Manager<B> manager;
private DisplayOptions displayOptions;
private boolean allowNull = false;
private boolean validatingInput = false;
private String beanInUse = "NamedBeanComboBoxBeanInUse";
private String noMatchingBean = "NamedBeanComboBoxNoMatchingBean";
private final Set<B> excludedItems = new HashSet<>();
private final PropertyChangeListener managerListener = ThreadingPropertyChangeListener.guiListener(evt -> sort());
private final static Logger log = LoggerFactory.getLogger(NamedBeanComboBox.class);
/**
* Create a ComboBox without a selection using the
* {@link DisplayOptions#DISPLAYNAME} to sort NamedBeans.
*
* @param manager the Manager backing the ComboBox
*/
public NamedBeanComboBox(Manager<B> manager) {
this(manager, null);
}
/**
* Create a ComboBox with an existing selection using the
* {@link DisplayOptions#DISPLAYNAME} to sort NamedBeans.
*
* @param manager the Manager backing the ComboBox
* @param selection the NamedBean that is selected or null to specify no
* selection
*/
public NamedBeanComboBox(Manager<B> manager, B selection) {
this(manager, selection, DisplayOptions.DISPLAYNAME);
}
/**
* Create a ComboBox with an existing selection using the specified display
* order to sort NamedBeans.
*
* @param manager the Manager backing the ComboBox
* @param selection the NamedBean that is selected or null to specify no
* selection
* @param displayOrder the sorting scheme for NamedBeans
*/
public NamedBeanComboBox(Manager<B> manager, B selection, DisplayOptions displayOrder) {
this.manager = manager;
setToolTipText(Bundle.getMessage("NamedBeanComboBoxDefaultToolTipText", this.manager.getBeanTypeHandled(true)));
setDisplayOrder(displayOrder);
setEditable(false);
NamedBeanRenderer namedBeanRenderer = new NamedBeanRenderer();
setRenderer(namedBeanRenderer);
setKeySelectionManager(namedBeanRenderer);
Component ec = getEditor().getEditorComponent();
if (ec instanceof JComponent) {
JComponent jc = (JComponent) ec;
jc.setInputVerifier(new NonVerifyingValidator(jc, true) {
@Override
protected Validation getValidation(JComponent component, JInputValidatorPreferences preferences) {
if (component instanceof JTextComponent) {
JTextComponent jtc = (JTextComponent) component;
String text = jtc.getText();
if (text != null && !text.isEmpty()) {
B bean = manager.getNamedBean(text);
if (bean != null) {
setSelectedItem(bean); // won't change if bean is not in model
if (!bean.equals(getSelectedItem())) {
jtc.setText(text);
if (validatingInput) {
return new Validation(Validation.Type.WARNING, Bundle.getMessage(beanInUse,
manager.getBeanTypeHandled(), bean.getFullyFormattedDisplayName()));
}
}
} else {
if (validatingInput) {
return new Validation(Validation.Type.DANGER,
Bundle.getMessage(noMatchingBean, manager.getBeanTypeHandled(), text));
}
}
}
}
return new Validation(Validation.Type.NONE, ""); // NOI18N
}
});
}
this.manager.addPropertyChangeListener("beans", managerListener);
this.manager.addPropertyChangeListener("DisplayListName", managerListener);
sort();
setSelectedItem(selection);
}
public Manager<B> getManager() {
return manager;
}
public DisplayOptions getDisplayOrder() {
return displayOptions;
}
public final void setDisplayOrder(DisplayOptions displayOrder) {
if (displayOptions != displayOrder) {
displayOptions = displayOrder;
sort();
}
}
/**
* Is this JComboBox validating typed input?
*
* @return true if validating input; false otherwise
*/
public boolean isValidatingInput() {
return validatingInput;
}
/**
* Set if this JComboBox validates typed input.
*
* @param validatingInput true to validate; false to prevent validation
*/
public void setValidatingInput(boolean validatingInput) {
this.validatingInput = validatingInput;
}
/**
* Is this JComboBox allowing a null object to be selected?
*
* @return true if allowing a null selection; false otherwise
*/
public boolean isAllowNull() {
return allowNull;
}
/**
* Set if this JComboBox allows a null object to be selected. If so, the
* null object is placed first in the displayed list of NamedBeans.
*
* @param allowNull true if allowing a null selection; false otherwise
*/
public void setAllowNull(boolean allowNull) {
this.allowNull = allowNull;
}
/**
* {@inheritDoc}
*
* @return the selected item as the supported type of NamedBean or null if
* there is no selection, or {@link #isAllowNull()} is true and the
* null object is selected
*/
@Override
public B getSelectedItem() {
return getItemAt(getSelectedIndex());
}
@Override
public void setEditable(boolean editable) {
if (editable && !(manager instanceof ProvidingManager)) {
log.error("Unable to set editable to true because not backed by editable manager");
}
super.setEditable(editable);
}
/**
* Get the display name of the selected item.
*
* @return the display name of the selected item or null if the selected
* item is null or there is no selection
*/
public String getSelectedItemDisplayName() {
B item = getSelectedItem();
return item != null ? item.getDisplayName() : null;
}
/**
* Get the system name of the selected item.
*
* @return the system name of the selected item or null if the selected item
* is null or there is no selection
*/
public String getSelectedItemSystemName() {
B item = getSelectedItem();
return item != null ? item.getSystemName() : null;
}
/**
* Get the user name of the selected item.
*
* @return the user name of the selected item or null if the selected item
* is null or there is no selection
*/
public String getSelectedItemUserName() {
B item = getSelectedItem();
return item != null ? item.getUserName() : null;
}
/**
* Set the selected item by either its user name or system name.
*
* @param name the name of the item to select
* @throws IllegalArgumentException if {@link #isAllowNull()} is false and
* no bean exists by name or name is
* null
*/
public void setSelectedItemByName(String name) {
B item = null;
if (name != null) {
item = manager.getNamedBean(name);
}
if (item == null && !allowNull) {
throw new IllegalArgumentException();
}
setSelectedItem(item);
}
public void dispose() {
manager.removePropertyChangeListener(managerListener);
}
private void sort() {
B selectedItem = getSelectedItem();
Comparator<B> comparator = new NamedBeanComparator<>();
if (displayOptions == DisplayOptions.USERNAME || displayOptions == DisplayOptions.USERNAMESYSTEMNAME) {
comparator = new NamedBeanUserNameComparator<>();
}
TreeSet<B> set = new TreeSet<>(comparator);
set.addAll(manager.getNamedBeanSet());
set.removeAll(excludedItems);
Vector<B> vector = new Vector<>(set);
if (allowNull) {
vector.insertElementAt(null, 0);
}
setModel(new DefaultComboBoxModel<>(vector));
setSelectedItem(selectedItem); // retain selection
}
/**
* Set the translation key to be used when a typed in bean name matches a
* named bean has been included in a call to
* {@link #setExcludedItems(java.util.Set)} and {@link #isValidatingInput()}
* is {@code true}.
*
* @param beanInUseKey a translatable bundle key where {@code {0}} is the
* result of
* {@link jmri.Manager#getBeanTypeHandled()} and {1}
* is the result of
* {@link jmri.NamedBean#getFullyFormattedDisplayName()}
* for the matching bean
*/
public void setNoMatchingToolTipBeanInUse(String beanInUseKey) {
beanInUse = beanInUseKey;
}
/**
* Set the translation key to be used when a typed in bean name does not
* match a named bean and {@link #isValidatingInput()} is {@code true}.
*
* @param noMatchingBeanKey a translatable bundle key where {@code {0}} is
* the result of
* {@link jmri.Manager#getBeanTypeHandled()}
* and {1} is the typed input
*/
public void setNoMatchingToolTipNoMatchingBean(String noMatchingBeanKey) {
noMatchingBean = noMatchingBeanKey;
}
public Set<B> getExcludedItems() {
return excludedItems;
}
/**
* Collection of named beans managed by the manager for this combo box that
* should not be included in the combo box. This may be, for example, a list
* of SignalHeads already in use, and therefor not available to be added to
* a SignalMast.
*
* @param excludedItems items to be excluded from this combo box
*/
public void setExcludedItems(Set<B> excludedItems) {
this.excludedItems.clear();
this.excludedItems.addAll(excludedItems);
sort();
}
public enum DisplayOptions {
/**
* Format the entries in the combo box using the display name.
*/
DISPLAYNAME(1),
/**
* Format the entries in the combo box using the username. If the
* username value is blank for a bean then the system name is used.
*/
USERNAME(2),
/**
* Format the entries in the combo box using the system name.
*/
SYSTEMNAME(3),
/**
* Format the entries in the combo box with the username followed by the
* system name.
*/
USERNAMESYSTEMNAME(4),
/**
* Format the entries in the combo box with the system name followed by
* the username.
*/
SYSTEMNAMEUSERNAME(5);
//
// following code maps enumsto int and int to enum
//
private final int value;
private static final Map<Integer, DisplayOptions> enumMap;
private DisplayOptions(int value) {
this.value = value;
}
//Build an immutable map of String name to enum pairs.
static {
Map<Integer, DisplayOptions> map = new HashMap<>();
for (DisplayOptions instance : DisplayOptions.values()) {
map.put(instance.getValue(), instance);
}
enumMap = Collections.unmodifiableMap(map);
}
public static DisplayOptions valueOf(int inDisplayOptionInt) {
return enumMap.get(inDisplayOptionInt);
}
public int getValue() {
return value;
}
}
private class NamedBeanRenderer implements ListCellRenderer<B>, JComboBox.KeySelectionManager {
protected DefaultListCellRenderer renderer = new DefaultListCellRenderer();
private final long timeFactor;
private long lastTime;
private long time;
private String prefix = "";
public NamedBeanRenderer() {
Long l = (Long) UIManager.get("ComboBox.timeFactor");
timeFactor = l != null ? l : 1000;
}
@Override
public Component getListCellRendererComponent(JList<? extends B> list, B value, int index, boolean isSelected,
boolean cellHasFocus) {
JLabel label = (JLabel) renderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
if (value != null) {
switch (displayOptions) {
case SYSTEMNAMEUSERNAME:
label.setText(value.getFullyFormattedDisplayName(false));
break;
case DISPLAYNAME:
label.setText(value.getDisplayName());
break;
case USERNAME:
String userName = value.getUserName();
label.setText((userName != null && !userName.isEmpty()) ? userName : value.getSystemName());
break;
case USERNAMESYSTEMNAME:
label.setText(value.getFullyFormattedDisplayName());
break;
case SYSTEMNAME:
default:
label.setText(value.getSystemName());
}
}
return label;
}
/**
* {@inheritDoc}
*/
@Override
@SuppressWarnings("unchecked") // unchecked cast due to API constraints
public int selectionForKey(char key, ComboBoxModel model) {
time = System.currentTimeMillis();
// Get the index of the currently selected item
int size = model.getSize();
int startIndex = -1;
B selectedItem = (B) model.getSelectedItem();
if (selectedItem != null) {
for (int i = 0; i < size; i++) {
if (selectedItem == model.getElementAt(i)) {
startIndex = i;
break;
}
}
}
// Determine the "prefix" to be used when searching the model. The
// prefix can be a single letter or multiple letters depending on how
// fast the user has been typing and on which letter has been typed.
if (time - lastTime < timeFactor) {
if ((prefix.length() == 1) && (key == prefix.charAt(0))) {
// Subsequent same key presses move the keyboard focus to the next
// object that starts with the same letter.
startIndex++;
} else {
prefix += key;
}
} else {
startIndex++;
prefix = "" + key;
}
lastTime = time;
// Search from the current selection and wrap when no match is found
if (startIndex < 0 || startIndex >= size) {
startIndex = 0;
}
int index = getNextMatch(prefix, startIndex, size, model);
if (index < 0) {
// wrap
index = getNextMatch(prefix, 0, startIndex, model);
}
return index;
}
/**
* Find the index of the item in the model that starts with the prefix.
*/
@SuppressWarnings("unchecked") // unchecked cast due to API constraints
private int getNextMatch(String prefix, int start, int end, ComboBoxModel model) {
for (int i = start; i < end; i++) {
B item = (B) model.getElementAt(i);
if (item != null) {
String userName = item.getUserName();
if (item.getSystemName().toLowerCase().startsWith(prefix) ||
(userName != null && userName.toLowerCase().startsWith(prefix))) {
return i;
}
}
}
return -1;
}
}
}