001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.tagging.presets.items;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trc;
006
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.Font;
010import java.io.File;
011import java.lang.reflect.Method;
012import java.lang.reflect.Modifier;
013import java.util.ArrayList;
014import java.util.Arrays;
015import java.util.Collection;
016import java.util.Collections;
017import java.util.LinkedHashMap;
018import java.util.List;
019import java.util.Map;
020import java.util.Map.Entry;
021import java.util.Set;
022import java.util.TreeSet;
023import java.util.stream.Collectors;
024
025import javax.swing.ImageIcon;
026import javax.swing.JComponent;
027import javax.swing.JLabel;
028import javax.swing.JList;
029import javax.swing.JPanel;
030import javax.swing.ListCellRenderer;
031import javax.swing.ListModel;
032
033import org.openstreetmap.josm.Main;
034import org.openstreetmap.josm.data.osm.OsmPrimitive;
035import org.openstreetmap.josm.data.osm.Tag;
036import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetReader;
037import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;
038import org.openstreetmap.josm.tools.AlphanumComparator;
039import org.openstreetmap.josm.tools.GBC;
040import org.openstreetmap.josm.tools.Utils;
041
042/**
043 * Abstract superclass for combo box and multi-select list types.
044 */
045public abstract class ComboMultiSelect extends KeyedItem {
046
047    private static final Renderer RENDERER = new Renderer();
048
049    /** The localized version of {@link #text}. */
050    public String locale_text; // NOSONAR
051    /**
052     * A list of entries.
053     * The list has to be separated by commas (for the {@link Combo} box) or by the specified delimiter (for the {@link MultiSelect}).
054     * If a value contains the delimiter, the delimiter may be escaped with a backslash.
055     * If a value contains a backslash, it must also be escaped with a backslash. */
056    public String values; // NOSONAR
057    /**
058     * To use instead of {@link #values} if the list of values has to be obtained with a Java method of this form:
059     * <p>{@code public static String[] getValues();}<p>
060     * The value must be: {@code full.package.name.ClassName#methodName}.
061     */
062    public String values_from; // NOSONAR
063    /** The context used for translating {@link #values} */
064    public String values_context; // NOSONAR
065    /** Disabled internationalisation for value to avoid mistakes, see #11696 */
066    public boolean values_no_i18n; // NOSONAR
067    /** Whether to sort the values, defaults to true. */
068    public boolean values_sort = true; // NOSONAR
069    /**
070     * A list of entries that is displayed to the user.
071     * Must be the same number and order of entries as {@link #values} and editable must be false or not specified.
072     * For the delimiter character and escaping, see the remarks at {@link #values}.
073     */
074    public String display_values; // NOSONAR
075    /** The localized version of {@link #display_values}. */
076    public String locale_display_values; // NOSONAR
077    /**
078     * A delimiter-separated list of texts to be displayed below each {@code display_value}.
079     * (Only if it is not possible to describe the entry in 2-3 words.)
080     * Instead of comma separated list instead using {@link #values}, {@link #display_values} and {@link #short_descriptions},
081     * the following form is also supported:<p>
082     * {@code <list_entry value="" display_value="" short_description="" icon="" icon_size="" />}
083     */
084    public String short_descriptions; // NOSONAR
085    /** The localized version of {@link #short_descriptions}. */
086    public String locale_short_descriptions; // NOSONAR
087    /** The default value for the item. If not specified, the current value of the key is chosen as default (if applicable).*/
088    public String default_; // NOSONAR
089    /**
090     * The character that separates values.
091     * In case of {@link Combo} the default is comma.
092     * In case of {@link MultiSelect} the default is semicolon and this will also be used to separate selected values in the tag.
093     */
094    public String delimiter = ";"; // NOSONAR
095    /** whether the last value is used as default. Using "force" enforces this behaviour also for already tagged objects. Default is "false".*/
096    public String use_last_as_default = "false"; // NOSONAR
097    /** whether to use values for search via {@link TaggingPresetSelector} */
098    public String values_searchable = "false"; // NOSONAR
099
100    protected JComponent component;
101    protected final Map<String, PresetListEntry> lhm = new LinkedHashMap<>();
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            // Only return cached size, item is not shown
115            if (!list.isShowing() && item.prefferedWidth != -1 && item.prefferedHeight != -1) {
116                if (index == -1) {
117                    lbl.setPreferredSize(new Dimension(item.prefferedWidth, 10));
118                } else {
119                    lbl.setPreferredSize(new Dimension(item.prefferedWidth, item.prefferedHeight));
120                }
121                return lbl;
122            }
123
124            lbl.setPreferredSize(null);
125
126            if (isSelected) {
127                lbl.setBackground(list.getSelectionBackground());
128                lbl.setForeground(list.getSelectionForeground());
129            } else {
130                lbl.setBackground(list.getBackground());
131                lbl.setForeground(list.getForeground());
132            }
133
134            lbl.setOpaque(true);
135            lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN));
136            lbl.setText("<html>" + item.getListDisplay() + "</html>");
137            lbl.setIcon(item.getIcon());
138            lbl.setEnabled(list.isEnabled());
139
140            // Cache size
141            item.prefferedWidth = lbl.getPreferredSize().width;
142            item.prefferedHeight = lbl.getPreferredSize().height;
143
144            // We do not want the editor to have the maximum height of all
145            // entries. Return a dummy with bogus height.
146            if (index == -1) {
147                lbl.setPreferredSize(new Dimension(lbl.getPreferredSize().width, 10));
148            }
149            return lbl;
150        }
151    }
152
153    /**
154     * Class that allows list values to be assigned and retrieved as a comma-delimited
155     * string (extracted from TaggingPreset)
156     */
157    protected static class ConcatenatingJList extends JList<PresetListEntry> {
158        private final String delimiter;
159
160        protected ConcatenatingJList(String del, PresetListEntry ... o) {
161            super(o);
162            delimiter = del;
163        }
164
165        public void setSelectedItem(Object o) {
166            if (o == null) {
167                clearSelection();
168            } else {
169                String s = o.toString();
170                Set<String> parts = new TreeSet<>(Arrays.asList(s.split(delimiter)));
171                ListModel<PresetListEntry> lm = getModel();
172                int[] intParts = new int[lm.getSize()];
173                int j = 0;
174                for (int i = 0; i < lm.getSize(); i++) {
175                    final String value = lm.getElementAt(i).value;
176                    if (parts.contains(value)) {
177                        intParts[j++] = i;
178                        parts.remove(value);
179                    }
180                }
181                setSelectedIndices(Arrays.copyOf(intParts, j));
182                // check if we have actually managed to represent the full
183                // value with our presets. if not, cop out; we will not offer
184                // a selection list that threatens to ruin the value.
185                setEnabled(parts.isEmpty());
186            }
187        }
188
189        public String getSelectedItem() {
190            ListModel<PresetListEntry> lm = getModel();
191            int[] si = getSelectedIndices();
192            StringBuilder builder = new StringBuilder();
193            for (int i = 0; i < si.length; i++) {
194                if (i > 0) {
195                    builder.append(delimiter);
196                }
197                builder.append(lm.getElementAt(si[i]).value);
198            }
199            return builder.toString();
200        }
201    }
202
203    /**
204     * Preset list entry.
205     */
206    public static class PresetListEntry implements Comparable<PresetListEntry> {
207        /** Entry value */
208        public String value; // NOSONAR
209        /** The context used for translating {@link #value} */
210        public String value_context; // NOSONAR
211        /** Value displayed to the user */
212        public String display_value; // NOSONAR
213        /** Text to be displayed below {@code display_value}. */
214        public String short_description; // NOSONAR
215        /** The location of icon file to display */
216        public String icon; // NOSONAR
217        /** The size of displayed icon. If not set, default is size from icon file */
218        public String icon_size; // NOSONAR
219        /** The localized version of {@link #display_value}. */
220        public String locale_display_value; // NOSONAR
221        /** The localized version of {@link #short_description}. */
222        public String locale_short_description; // NOSONAR
223        private final File zipIcons = TaggingPresetReader.getZipIcons();
224
225        /** Cached width (currently only for Combo) to speed up preset dialog initialization */
226        public int prefferedWidth = -1; // NOSONAR
227        /** Cached height (currently only for Combo) to speed up preset dialog initialization */
228        public int prefferedHeight = -1; // NOSONAR
229
230        /**
231         * Constructs a new {@code PresetListEntry}, uninitialized.
232         */
233        public PresetListEntry() {
234            // Public default constructor is needed
235        }
236
237        /**
238         * Constructs a new {@code PresetListEntry}, initialized with a value.
239         * @param value value
240         */
241        public PresetListEntry(String value) {
242            this.value = value;
243        }
244
245        /**
246         * Returns HTML formatted contents.
247         * @return HTML formatted contents
248         */
249        public String getListDisplay() {
250            if (value.equals(DIFFERENT))
251                return "<b>" + Utils.escapeReservedCharactersHTML(DIFFERENT) + "</b>";
252
253            String displayValue = Utils.escapeReservedCharactersHTML(getDisplayValue(true));
254            String shortDescription = getShortDescription(true);
255
256            if (displayValue.isEmpty() && (shortDescription == null || shortDescription.isEmpty()))
257                return "&nbsp;";
258
259            final StringBuilder res = new StringBuilder("<b>").append(displayValue).append("</b>");
260            if (shortDescription != null) {
261                // wrap in table to restrict the text width
262                res.append("<div style=\"width:300px; padding:0 0 5px 5px\">")
263                   .append(shortDescription)
264                   .append("</div>");
265            }
266            return res.toString();
267        }
268
269        /**
270         * Returns the entry icon, if any.
271         * @return the entry icon, or {@code null}
272         */
273        public ImageIcon getIcon() {
274            return icon == null ? null : loadImageIcon(icon, zipIcons, parseInteger(icon_size));
275        }
276
277        /**
278         * Returns the value to display.
279         * @param translated whether the text must be translated
280         * @return the value to display
281         */
282        public String getDisplayValue(boolean translated) {
283            return translated
284                    ? Utils.firstNonNull(locale_display_value, tr(display_value), trc(value_context, value))
285                            : Utils.firstNonNull(display_value, value);
286        }
287
288        /**
289         * Returns the short description to display.
290         * @param translated whether the text must be translated
291         * @return the short description to display
292         */
293        public String getShortDescription(boolean translated) {
294            return translated
295                    ? Utils.firstNonNull(locale_short_description, tr(short_description))
296                            : short_description;
297        }
298
299        // toString is mainly used to initialize the Editor
300        @Override
301        public String toString() {
302            if (DIFFERENT.equals(value))
303                return DIFFERENT;
304            String displayValue = getDisplayValue(true);
305            return displayValue != null ? displayValue.replaceAll("<.*>", "") : ""; // remove additional markup, e.g. <br>
306        }
307
308        @Override
309        public int compareTo(PresetListEntry o) {
310            return AlphanumComparator.getInstance().compare(this.getDisplayValue(true), o.getDisplayValue(true));
311        }
312    }
313
314    /**
315     * allow escaped comma in comma separated list:
316     * "A\, B\, C,one\, two" --&gt; ["A, B, C", "one, two"]
317     * @param delimiter the delimiter, e.g. a comma. separates the entries and
318     *      must be escaped within one entry
319     * @param s the string
320     * @return splitted items
321     */
322    public static String[] splitEscaped(char delimiter, String s) {
323        if (s == null)
324            return new String[0];
325        List<String> result = new ArrayList<>();
326        boolean backslash = false;
327        StringBuilder item = new StringBuilder();
328        for (int i = 0; i < s.length(); i++) {
329            char ch = s.charAt(i);
330            if (backslash) {
331                item.append(ch);
332                backslash = false;
333            } else if (ch == '\\') {
334                backslash = true;
335            } else if (ch == delimiter) {
336                result.add(item.toString());
337                item.setLength(0);
338            } else {
339                item.append(ch);
340            }
341        }
342        if (item.length() > 0) {
343            result.add(item.toString());
344        }
345        return result.toArray(new String[result.size()]);
346    }
347
348    protected abstract Object getSelectedItem();
349
350    protected abstract void addToPanelAnchor(JPanel p, String def, boolean presetInitiallyMatches);
351
352    protected char getDelChar() {
353        return delimiter.isEmpty() ? ';' : delimiter.charAt(0);
354    }
355
356    @Override
357    public Collection<String> getValues() {
358        initListEntries();
359        return lhm.keySet();
360    }
361
362    /**
363     * Returns the values to display.
364     * @return the values to display
365     */
366    public Collection<String> getDisplayValues() {
367        initListEntries();
368        return lhm.values().stream().map(x -> x.getDisplayValue(true)).collect(Collectors.toList());
369    }
370
371    @Override
372    public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel, boolean presetInitiallyMatches) {
373        initListEntries();
374
375        // find out if our key is already used in the selection.
376        usage = determineTextUsage(sel, key);
377        if (!usage.hasUniqueValue() && !usage.unused()) {
378            lhm.put(DIFFERENT, new PresetListEntry(DIFFERENT));
379        }
380
381        final JLabel label = new JLabel(tr("{0}:", locale_text));
382        label.setToolTipText(getKeyTooltipText());
383        p.add(label, GBC.std().insets(0, 0, 10, 0));
384        addToPanelAnchor(p, default_, presetInitiallyMatches);
385        label.setLabelFor(component);
386        component.setToolTipText(getKeyTooltipText());
387
388        return true;
389    }
390
391    private void initListEntries() {
392        if (initialized) {
393            lhm.remove(DIFFERENT); // possibly added in #addToPanel
394            return;
395        } else if (lhm.isEmpty()) {
396            initListEntriesFromAttributes();
397        } else {
398            if (values != null) {
399                Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
400                        + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
401                        key, text, "values", "list_entry"));
402            }
403            if (display_values != null || locale_display_values != null) {
404                Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
405                        + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
406                        key, text, "display_values", "list_entry"));
407            }
408            if (short_descriptions != null || locale_short_descriptions != null) {
409                Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
410                        + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
411                        key, text, "short_descriptions", "list_entry"));
412            }
413            for (PresetListEntry e : lhm.values()) {
414                if (e.value_context == null) {
415                    e.value_context = values_context;
416                }
417            }
418        }
419        if (locale_text == null) {
420            locale_text = getLocaleText(text, text_context, null);
421        }
422        initialized = true;
423    }
424
425    private void initListEntriesFromAttributes() {
426        char delChar = getDelChar();
427
428        String[] valueArray = null;
429
430        if (values_from != null) {
431            String[] classMethod = values_from.split("#");
432            if (classMethod.length == 2) {
433                try {
434                    Method method = Class.forName(classMethod[0]).getMethod(classMethod[1]);
435                    // Check method is public static String[] methodName()
436                    int mod = method.getModifiers();
437                    if (Modifier.isPublic(mod) && Modifier.isStatic(mod)
438                            && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) {
439                        valueArray = (String[]) method.invoke(null);
440                    } else {
441                        Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text,
442                                "public static String[] methodName()"));
443                    }
444                } catch (ReflectiveOperationException e) {
445                    Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text,
446                            e.getClass().getName(), e.getMessage()));
447                    Main.debug(e);
448                }
449            }
450        }
451
452        if (valueArray == null) {
453            valueArray = splitEscaped(delChar, values);
454        }
455
456        String[] displayArray = valueArray;
457        if (!values_no_i18n) {
458            final String displ = Utils.firstNonNull(locale_display_values, display_values);
459            displayArray = displ == null ? valueArray : splitEscaped(delChar, displ);
460        }
461
462        final String descr = Utils.firstNonNull(locale_short_descriptions, short_descriptions);
463        String[] shortDescriptionsArray = descr == null ? null : splitEscaped(delChar, descr);
464
465        if (displayArray.length != valueArray.length) {
466            Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''display_values'' must be the same as in ''values''",
467                            key, text));
468            Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(displayArray), Arrays.toString(valueArray)));
469            displayArray = valueArray;
470        }
471
472        if (shortDescriptionsArray != null && shortDescriptionsArray.length != valueArray.length) {
473            Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''short_descriptions'' must be the same as in ''values''",
474                            key, text));
475            Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(shortDescriptionsArray), Arrays.toString(valueArray)));
476            shortDescriptionsArray = null;
477        }
478
479        final List<PresetListEntry> entries = new ArrayList<>(valueArray.length);
480        for (int i = 0; i < valueArray.length; i++) {
481            final PresetListEntry e = new PresetListEntry(valueArray[i]);
482            e.locale_display_value = locale_display_values != null || values_no_i18n
483                    ? displayArray[i]
484                    : trc(values_context, fixPresetString(displayArray[i]));
485            if (shortDescriptionsArray != null) {
486                e.locale_short_description = locale_short_descriptions != null
487                        ? shortDescriptionsArray[i]
488                        : tr(fixPresetString(shortDescriptionsArray[i]));
489            }
490
491            entries.add(e);
492        }
493
494        if (Main.pref.getBoolean("taggingpreset.sortvalues", true) && values_sort) {
495            Collections.sort(entries);
496        }
497
498        for (PresetListEntry i : entries) {
499            lhm.put(i.value, i);
500        }
501    }
502
503    protected String getDisplayIfNull() {
504        return null;
505    }
506
507    @Override
508    public void addCommands(List<Tag> changedTags) {
509        Object obj = getSelectedItem();
510        String display = (obj == null) ? null : obj.toString();
511        String value = null;
512        if (display == null) {
513            display = getDisplayIfNull();
514        }
515
516        if (display != null) {
517            for (Entry<String, PresetListEntry> entry : lhm.entrySet()) {
518                String k = entry.getValue().toString();
519                if (k.equals(display)) {
520                    value = entry.getKey();
521                    break;
522                }
523            }
524            if (value == null) {
525                value = display;
526            }
527        } else {
528            value = "";
529        }
530        value = Tag.removeWhiteSpaces(value);
531
532        // no change if same as before
533        if (originalValue == null) {
534            if (value.isEmpty())
535                return;
536        } else if (value.equals(originalValue.toString()))
537            return;
538
539        if (!"false".equals(use_last_as_default)) {
540            LAST_VALUES.put(key, value);
541        }
542        changedTags.add(new Tag(key, value));
543    }
544
545    /**
546     * Adds a preset list entry.
547     * @param e list entry to add
548     */
549    public void addListEntry(PresetListEntry e) {
550        lhm.put(e.value, e);
551    }
552
553    /**
554     * Adds a collection of preset list entries.
555     * @param e list entries to add
556     */
557    public void addListEntries(Collection<PresetListEntry> e) {
558        for (PresetListEntry i : e) {
559            addListEntry(i);
560        }
561    }
562
563    protected ListCellRenderer<PresetListEntry> getListCellRenderer() {
564        return RENDERER;
565    }
566
567    @Override
568    public MatchType getDefaultMatch() {
569        return MatchType.NONE;
570    }
571}