source: josm/trunk/src/org/openstreetmap/josm/gui/tagging/presets/items/ComboMultiSelect.java@ 16688

Last change on this file since 16688 was 16688, checked in by simon04, 4 years ago

see #16031 - Extract ComboMultiSelect.getItemToSelect

File size: 25.0 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.tagging.presets.items;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trc;
6
7import java.awt.Component;
8import java.awt.Dimension;
9import java.awt.Font;
10import java.lang.reflect.Method;
11import java.lang.reflect.Modifier;
12import java.util.ArrayList;
13import java.util.Arrays;
14import java.util.Collection;
15import java.util.Collections;
16import java.util.Comparator;
17import java.util.List;
18import java.util.Objects;
19import java.util.Set;
20import java.util.TreeSet;
21import java.util.concurrent.CopyOnWriteArraySet;
22import java.util.stream.Collectors;
23import java.util.stream.IntStream;
24
25import javax.swing.ImageIcon;
26import javax.swing.JComponent;
27import javax.swing.JLabel;
28import javax.swing.JList;
29import javax.swing.JPanel;
30import javax.swing.ListCellRenderer;
31import javax.swing.ListModel;
32
33import org.openstreetmap.josm.data.osm.OsmPrimitive;
34import org.openstreetmap.josm.data.osm.Tag;
35import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetReader;
36import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;
37import org.openstreetmap.josm.spi.preferences.Config;
38import org.openstreetmap.josm.tools.AlphanumComparator;
39import org.openstreetmap.josm.tools.GBC;
40import org.openstreetmap.josm.tools.Logging;
41import org.openstreetmap.josm.tools.Utils;
42
43/**
44 * Abstract superclass for combo box and multi-select list types.
45 */
46public abstract class ComboMultiSelect extends KeyedItem {
47
48 private static final Renderer RENDERER = new Renderer();
49
50 /**
51 * A list of entries.
52 * The list has to be separated by commas (for the {@link Combo} box) or by the specified delimiter (for the {@link MultiSelect}).
53 * If a value contains the delimiter, the delimiter may be escaped with a backslash.
54 * If a value contains a backslash, it must also be escaped with a backslash. */
55 public String values; // NOSONAR
56 /**
57 * To use instead of {@link #values} if the list of values has to be obtained with a Java method of this form:
58 * <p>{@code public static String[] getValues();}<p>
59 * The value must be: {@code full.package.name.ClassName#methodName}.
60 */
61 public String values_from; // NOSONAR
62 /** The context used for translating {@link #values} */
63 public String values_context; // NOSONAR
64 /** Disabled internationalisation for value to avoid mistakes, see #11696 */
65 public boolean values_no_i18n; // NOSONAR
66 /** Whether to sort the values, defaults to true. */
67 public boolean values_sort = true; // NOSONAR
68 /**
69 * A list of entries that is displayed to the user.
70 * Must be the same number and order of entries as {@link #values} and editable must be false or not specified.
71 * For the delimiter character and escaping, see the remarks at {@link #values}.
72 */
73 public String display_values; // NOSONAR
74 /** The localized version of {@link #display_values}. */
75 public String locale_display_values; // NOSONAR
76 /**
77 * A delimiter-separated list of texts to be displayed below each {@code display_value}.
78 * (Only if it is not possible to describe the entry in 2-3 words.)
79 * Instead of comma separated list instead using {@link #values}, {@link #display_values} and {@link #short_descriptions},
80 * the following form is also supported:<p>
81 * {@code <list_entry value="" display_value="" short_description="" icon="" icon_size="" />}
82 */
83 public String short_descriptions; // NOSONAR
84 /** The localized version of {@link #short_descriptions}. */
85 public String locale_short_descriptions; // NOSONAR
86 /** The default value for the item. If not specified, the current value of the key is chosen as default (if applicable).*/
87 public String default_; // NOSONAR
88 /**
89 * The character that separates values.
90 * In case of {@link Combo} the default is comma.
91 * In case of {@link MultiSelect} the default is semicolon and this will also be used to separate selected values in the tag.
92 */
93 public char delimiter = ';'; // NOSONAR
94 /** whether the last value is used as default.
95 * Using "force" (2) enforces this behaviour also for already tagged objects. Default is "false" (0).*/
96 public byte use_last_as_default; // NOSONAR
97 /** whether to use values for search via {@link TaggingPresetSelector} */
98 public boolean values_searchable; // NOSONAR
99
100 protected JComponent component;
101 protected final Set<PresetListEntry> presetListEntries = new CopyOnWriteArraySet<>();
102 private boolean initialized;
103 protected Usage usage;
104 protected Object originalValue;
105
106 private static final class Renderer implements ListCellRenderer<PresetListEntry> {
107
108 private final JLabel lbl = new JLabel();
109
110 @Override
111 public Component getListCellRendererComponent(JList<? extends PresetListEntry> list, PresetListEntry item, int index,
112 boolean isSelected, boolean cellHasFocus) {
113
114 if (list == null || item == null) {
115 return lbl;
116 }
117
118 if (index == -1) {
119 // Take the longest element for the preferred width (#19321)
120 // We do not want the editor to have the maximum height of all entries. Return a dummy with bogus height.
121 IntStream.range(0, list.getModel().getSize())
122 .mapToObj(i -> getListCellRendererComponent(list, list.getModel().getElementAt(i), i, isSelected, cellHasFocus))
123 .map(Component::getPreferredSize)
124 .max(Comparator.comparingInt(dim -> dim.width))
125 .ifPresent(dim -> lbl.setPreferredSize(new Dimension(dim.width, 10)));
126 return lbl;
127 }
128
129 // Only return cached size, item is not shown
130 if (!list.isShowing() && item.preferredWidth != -1 && item.preferredHeight != -1) {
131 lbl.setPreferredSize(new Dimension(item.preferredWidth, item.preferredHeight));
132 return lbl;
133 }
134
135 lbl.setPreferredSize(null);
136
137 if (isSelected) {
138 lbl.setBackground(list.getSelectionBackground());
139 lbl.setForeground(list.getSelectionForeground());
140 } else {
141 lbl.setBackground(list.getBackground());
142 lbl.setForeground(list.getForeground());
143 }
144
145 lbl.setOpaque(true);
146 lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN));
147 lbl.setText("<html>" + item.getListDisplay() + "</html>");
148 lbl.setIcon(item.getIcon());
149 lbl.setEnabled(list.isEnabled());
150
151 // Cache size
152 item.preferredWidth = (short) lbl.getPreferredSize().width;
153 item.preferredHeight = (short) lbl.getPreferredSize().height;
154
155 return lbl;
156 }
157 }
158
159 /**
160 * Class that allows list values to be assigned and retrieved as a comma-delimited
161 * string (extracted from TaggingPreset)
162 */
163 protected static class ConcatenatingJList extends JList<PresetListEntry> {
164 private final char delimiter;
165
166 protected ConcatenatingJList(char del, PresetListEntry... o) {
167 super(o);
168 delimiter = del;
169 }
170
171 public void setSelectedItem(Object o) {
172 if (o == null) {
173 clearSelection();
174 } else {
175 String s = o.toString();
176 Set<String> parts = new TreeSet<>(Arrays.asList(s.split(String.valueOf(delimiter), -1)));
177 ListModel<PresetListEntry> lm = getModel();
178 int[] intParts = new int[lm.getSize()];
179 int j = 0;
180 for (int i = 0; i < lm.getSize(); i++) {
181 final String value = lm.getElementAt(i).value;
182 if (parts.contains(value)) {
183 intParts[j++] = i;
184 parts.remove(value);
185 }
186 }
187 setSelectedIndices(Arrays.copyOf(intParts, j));
188 // check if we have actually managed to represent the full
189 // value with our presets. if not, cop out; we will not offer
190 // a selection list that threatens to ruin the value.
191 setEnabled(parts.isEmpty());
192 }
193 }
194
195 public String getSelectedItem() {
196 ListModel<PresetListEntry> lm = getModel();
197 int[] si = getSelectedIndices();
198 StringBuilder builder = new StringBuilder();
199 for (int i = 0; i < si.length; i++) {
200 if (i > 0) {
201 builder.append(delimiter);
202 }
203 builder.append(lm.getElementAt(si[i]).value);
204 }
205 return builder.toString();
206 }
207 }
208
209 /**
210 * Preset list entry.
211 */
212 public static class PresetListEntry implements Comparable<PresetListEntry> {
213 /** Entry value */
214 public String value; // NOSONAR
215 /** The context used for translating {@link #value} */
216 public String value_context; // NOSONAR
217 /** Value displayed to the user */
218 public String display_value; // NOSONAR
219 /** Text to be displayed below {@code display_value}. */
220 public String short_description; // NOSONAR
221 /** The location of icon file to display */
222 public String icon; // NOSONAR
223 /** The size of displayed icon. If not set, default is size from icon file */
224 public short icon_size; // NOSONAR
225 /** The localized version of {@link #display_value}. */
226 public String locale_display_value; // NOSONAR
227 /** The localized version of {@link #short_description}. */
228 public String locale_short_description; // NOSONAR
229
230 /** Cached width (currently only for Combo) to speed up preset dialog initialization */
231 public short preferredWidth = -1; // NOSONAR
232 /** Cached height (currently only for Combo) to speed up preset dialog initialization */
233 public short preferredHeight = -1; // NOSONAR
234
235 /**
236 * Constructs a new {@code PresetListEntry}, uninitialized.
237 */
238 public PresetListEntry() {
239 // Public default constructor is needed
240 }
241
242 /**
243 * Constructs a new {@code PresetListEntry}, initialized with a value.
244 * @param value value
245 */
246 public PresetListEntry(String value) {
247 this.value = value;
248 }
249
250 /**
251 * Returns HTML formatted contents.
252 * @return HTML formatted contents
253 */
254 public String getListDisplay() {
255 if (value.equals(DIFFERENT))
256 return "<b>" + Utils.escapeReservedCharactersHTML(DIFFERENT) + "</b>";
257
258 String displayValue = Utils.escapeReservedCharactersHTML(getDisplayValue());
259 String shortDescription = getShortDescription(true);
260
261 if (displayValue.isEmpty() && (shortDescription == null || shortDescription.isEmpty()))
262 return "&nbsp;";
263
264 final StringBuilder res = new StringBuilder("<b>").append(displayValue).append("</b>");
265 if (shortDescription != null) {
266 // wrap in table to restrict the text width
267 res.append("<div style=\"width:300px; padding:0 0 5px 5px\">")
268 .append(shortDescription)
269 .append("</div>");
270 }
271 return res.toString();
272 }
273
274 /**
275 * Returns the entry icon, if any.
276 * @return the entry icon, or {@code null}
277 */
278 public ImageIcon getIcon() {
279 return icon == null ? null : loadImageIcon(icon, TaggingPresetReader.getZipIcons(), (int) icon_size);
280 }
281
282 /**
283 * Returns the value to display.
284 * @return the value to display
285 */
286 public String getDisplayValue() {
287 return Utils.firstNonNull(locale_display_value, tr(display_value), trc(value_context, value));
288 }
289
290 /**
291 * Returns the short description to display.
292 * @param translated whether the text must be translated
293 * @return the short description to display
294 */
295 public String getShortDescription(boolean translated) {
296 return translated
297 ? Utils.firstNonNull(locale_short_description, tr(short_description))
298 : short_description;
299 }
300
301 // toString is mainly used to initialize the Editor
302 @Override
303 public String toString() {
304 if (DIFFERENT.equals(value))
305 return DIFFERENT;
306 String displayValue = getDisplayValue();
307 return displayValue != null ? displayValue.replaceAll("<.*>", "") : ""; // remove additional markup, e.g. <br>
308 }
309
310 @Override
311 public boolean equals(Object o) {
312 if (this == o) return true;
313 if (o == null || getClass() != o.getClass()) return false;
314 PresetListEntry that = (PresetListEntry) o;
315 return Objects.equals(value, that.value);
316 }
317
318 @Override
319 public int hashCode() {
320 return Objects.hash(value);
321 }
322
323 @Override
324 public int compareTo(PresetListEntry o) {
325 return AlphanumComparator.getInstance().compare(this.getDisplayValue(), o.getDisplayValue());
326 }
327 }
328
329 /**
330 * allow escaped comma in comma separated list:
331 * "A\, B\, C,one\, two" --&gt; ["A, B, C", "one, two"]
332 * @param delimiter the delimiter, e.g. a comma. separates the entries and
333 * must be escaped within one entry
334 * @param s the string
335 * @return splitted items
336 */
337 public static String[] splitEscaped(char delimiter, String s) {
338 if (s == null)
339 return new String[0];
340 List<String> result = new ArrayList<>();
341 boolean backslash = false;
342 StringBuilder item = new StringBuilder();
343 for (int i = 0; i < s.length(); i++) {
344 char ch = s.charAt(i);
345 if (backslash) {
346 item.append(ch);
347 backslash = false;
348 } else if (ch == '\\') {
349 backslash = true;
350 } else if (ch == delimiter) {
351 result.add(item.toString());
352 item.setLength(0);
353 } else {
354 item.append(ch);
355 }
356 }
357 if (item.length() > 0) {
358 result.add(item.toString());
359 }
360 return result.toArray(new String[0]);
361 }
362
363 protected abstract Object getSelectedItem();
364
365 protected abstract void addToPanelAnchor(JPanel p, String def, boolean presetInitiallyMatches);
366
367 @Override
368 public Collection<String> getValues() {
369 initListEntries(false);
370 return presetListEntries.stream().map(x -> x.value).collect(Collectors.toSet());
371 }
372
373 /**
374 * Returns the values to display.
375 * @return the values to display
376 */
377 public Collection<String> getDisplayValues() {
378 initListEntries(false);
379 return presetListEntries.stream().map(PresetListEntry::getDisplayValue).collect(Collectors.toList());
380 }
381
382 @Override
383 public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel, boolean presetInitiallyMatches) {
384 initListEntries(true);
385
386 // find out if our key is already used in the selection.
387 usage = determineTextUsage(sel, key);
388 if (!usage.hasUniqueValue() && !usage.unused()) {
389 presetListEntries.add(new PresetListEntry(DIFFERENT));
390 }
391
392 final JLabel label = new JLabel(tr("{0}:", locale_text));
393 label.setToolTipText(getKeyTooltipText());
394 label.setComponentPopupMenu(getPopupMenu());
395 p.add(label, GBC.std().insets(0, 0, 10, 0));
396 addToPanelAnchor(p, default_, presetInitiallyMatches);
397 label.setLabelFor(component);
398 component.setToolTipText(getKeyTooltipText());
399
400 return true;
401 }
402
403 private void initListEntries(boolean cleanup) {
404 if (initialized) {
405 if (cleanup) { // do not cleanup for #getDisplayValues used in Combo#addToPanelAnchor
406 presetListEntries.remove(new PresetListEntry(DIFFERENT)); // possibly added in #addToPanel
407 }
408 return;
409 } else if (presetListEntries.isEmpty()) {
410 initListEntriesFromAttributes();
411 } else {
412 if (values != null) {
413 Logging.warn(tr("Warning in tagging preset \"{0}-{1}\": "
414 + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
415 key, text, "values", "list_entry"));
416 }
417 if (display_values != null || locale_display_values != null) {
418 Logging.warn(tr("Warning in tagging preset \"{0}-{1}\": "
419 + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
420 key, text, "display_values", "list_entry"));
421 }
422 if (short_descriptions != null || locale_short_descriptions != null) {
423 Logging.warn(tr("Warning in tagging preset \"{0}-{1}\": "
424 + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
425 key, text, "short_descriptions", "list_entry"));
426 }
427 for (PresetListEntry e : presetListEntries) {
428 if (e.value_context == null) {
429 e.value_context = values_context;
430 }
431 }
432 }
433 initializeLocaleText(null);
434 initialized = true;
435 }
436
437 private void initListEntriesFromAttributes() {
438
439 String[] valueArray = null;
440
441 if (values_from != null) {
442 String[] classMethod = values_from.split("#", -1);
443 if (classMethod.length == 2) {
444 try {
445 Method method = Class.forName(classMethod[0]).getMethod(classMethod[1]);
446 // Check method is public static String[] methodName()
447 int mod = method.getModifiers();
448 if (Modifier.isPublic(mod) && Modifier.isStatic(mod)
449 && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) {
450 valueArray = (String[]) method.invoke(null);
451 } else {
452 Logging.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text,
453 "public static String[] methodName()"));
454 }
455 } catch (ReflectiveOperationException e) {
456 Logging.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text,
457 e.getClass().getName(), e.getMessage()));
458 Logging.debug(e);
459 }
460 }
461 }
462
463 if (valueArray == null) {
464 valueArray = splitEscaped(delimiter, values);
465 values = null;
466 }
467
468 String[] displayArray = valueArray;
469 if (!values_no_i18n) {
470 final String displ = Utils.firstNonNull(locale_display_values, display_values);
471 displayArray = displ == null ? valueArray : splitEscaped(delimiter, displ);
472 }
473
474 final String descr = Utils.firstNonNull(locale_short_descriptions, short_descriptions);
475 String[] shortDescriptionsArray = descr == null ? null : splitEscaped(delimiter, descr);
476
477 if (displayArray.length != valueArray.length) {
478 Logging.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''display_values'' must be the same as in ''values''",
479 key, text));
480 Logging.error(tr("Detailed information: {0} <> {1}", Arrays.toString(displayArray), Arrays.toString(valueArray)));
481 displayArray = valueArray;
482 }
483
484 if (shortDescriptionsArray != null && shortDescriptionsArray.length != valueArray.length) {
485 Logging.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''short_descriptions'' must be the same as in ''values''",
486 key, text));
487 Logging.error(tr("Detailed information: {0} <> {1}", Arrays.toString(shortDescriptionsArray), Arrays.toString(valueArray)));
488 shortDescriptionsArray = null;
489 }
490
491 final List<PresetListEntry> entries = new ArrayList<>(valueArray.length);
492 for (int i = 0; i < valueArray.length; i++) {
493 final PresetListEntry e = new PresetListEntry(valueArray[i]);
494 final String value = locale_display_values != null || values_no_i18n
495 ? displayArray[i]
496 : trc(values_context, fixPresetString(displayArray[i]));
497 e.locale_display_value = value == null ? null : value.intern();
498 if (shortDescriptionsArray != null) {
499 final String description = locale_short_descriptions != null
500 ? shortDescriptionsArray[i]
501 : tr(fixPresetString(shortDescriptionsArray[i]));
502 e.locale_short_description = description == null ? null : description.intern();
503 }
504
505 entries.add(e);
506 }
507
508 if (values_sort && Config.getPref().getBoolean("taggingpreset.sortvalues", true)) {
509 Collections.sort(entries);
510 }
511
512 addListEntries(entries);
513 }
514
515 protected String getDisplayIfNull() {
516 return null;
517 }
518
519 protected Object getItemToSelect(String def, boolean presetInitiallyMatches) {
520 final Object itemToSelect;
521 if (usage.hasUniqueValue()) {
522 // all items have the same value (and there were no unset items)
523 originalValue = getListEntry(usage.getFirst());
524 itemToSelect = originalValue;
525 } else if (def != null && usage.unused()) {
526 // default is set and all items were unset
527 if (!usage.hadKeys() || PROP_FILL_DEFAULT.get() || isForceUseLastAsDefault()) {
528 // selected osm primitives are untagged or filling default feature is enabled
529 itemToSelect = getListEntry(def).getDisplayValue();
530 } else {
531 // selected osm primitives are tagged and filling default feature is disabled
532 itemToSelect = "";
533 }
534 originalValue = getListEntry(DIFFERENT);
535 } else if (usage.unused()) {
536 // all items were unset (and so is default)
537 originalValue = getListEntry("");
538 if (!presetInitiallyMatches && isUseLastAsDefault() && LAST_VALUES.containsKey(key)) {
539 itemToSelect = getListEntry(LAST_VALUES.get(key));
540 } else {
541 itemToSelect = originalValue;
542 }
543 } else {
544 originalValue = getListEntry(DIFFERENT);
545 itemToSelect = originalValue;
546 }
547 return itemToSelect;
548 }
549
550 protected String getSelectedValue() {
551 Object obj = getSelectedItem();
552 String display = obj == null ? getDisplayIfNull() : obj.toString();
553
554 if (display == null) {
555 return "";
556 }
557 return presetListEntries.stream()
558 .filter(entry -> Objects.equals(entry.toString(), display))
559 .findFirst()
560 .map(entry -> entry.value)
561 .map(Utils::removeWhiteSpaces)
562 .orElse(display);
563 }
564
565 @Override
566 public void addCommands(List<Tag> changedTags) {
567 String value = getSelectedValue();
568
569 // no change if same as before
570 if (originalValue == null) {
571 if (value.isEmpty())
572 return;
573 } else if (value.equals(originalValue.toString()))
574 return;
575
576 if (isUseLastAsDefault()) {
577 LAST_VALUES.put(key, value);
578 }
579 changedTags.add(new Tag(key, value));
580 }
581
582 /**
583 * Sets whether the last value is used as default.
584 * @param v Using "force" (2) enforces this behaviour also for already tagged objects. Default is "false" (0).
585 */
586 public void setUse_last_as_default(String v) { // NOPMD
587 if ("force".equals(v)) {
588 use_last_as_default = 2;
589 } else if ("true".equals(v)) {
590 use_last_as_default = 1;
591 } else {
592 use_last_as_default = 0;
593 }
594 }
595
596 protected boolean isUseLastAsDefault() {
597 return use_last_as_default > 0;
598 }
599
600 protected boolean isForceUseLastAsDefault() {
601 return use_last_as_default == 2;
602 }
603
604 /**
605 * Adds a preset list entry.
606 * @param e list entry to add
607 */
608 public void addListEntry(PresetListEntry e) {
609 presetListEntries.add(e);
610 }
611
612 /**
613 * Adds a collection of preset list entries.
614 * @param e list entries to add
615 */
616 public void addListEntries(Collection<PresetListEntry> e) {
617 for (PresetListEntry i : e) {
618 addListEntry(i);
619 }
620 }
621
622 protected PresetListEntry getListEntry(String value) {
623 return presetListEntries.stream().filter(e -> Objects.equals(e.value, value)).findFirst().orElse(null);
624 }
625
626 protected ListCellRenderer<PresetListEntry> getListCellRenderer() {
627 return RENDERER;
628 }
629
630 @Override
631 public MatchType getDefaultMatch() {
632 return MatchType.NONE;
633 }
634}
Note: See TracBrowser for help on using the repository browser.