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