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;
005
006import java.awt.Component;
007import java.awt.Font;
008import java.lang.reflect.Method;
009import java.lang.reflect.Modifier;
010import java.util.ArrayList;
011import java.util.Arrays;
012import java.util.Collection;
013import java.util.Collections;
014import java.util.List;
015import java.util.Map;
016import java.util.TreeMap;
017import java.util.stream.Collectors;
018
019import javax.swing.JLabel;
020import javax.swing.JList;
021import javax.swing.JPanel;
022import javax.swing.ListCellRenderer;
023
024import org.openstreetmap.josm.data.osm.Tag;
025import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetItemGuiSupport;
026import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;
027import org.openstreetmap.josm.gui.tagging.presets.TaggingPresets;
028import org.openstreetmap.josm.gui.widgets.JosmListCellRenderer;
029import org.openstreetmap.josm.gui.widgets.OrientationAction;
030import org.openstreetmap.josm.tools.AlphanumComparator;
031import org.openstreetmap.josm.tools.GBC;
032import org.openstreetmap.josm.tools.Logging;
033
034/**
035 * Abstract superclass for combo box and multi-select list types.
036 */
037public abstract class ComboMultiSelect extends KeyedItem {
038
039    /**
040     * A list of entries.
041     * The list has to be separated by commas (for the {@link Combo} box) or by the specified delimiter (for the {@link MultiSelect}).
042     * If a value contains the delimiter, the delimiter may be escaped with a backslash.
043     * If a value contains a backslash, it must also be escaped with a backslash. */
044    public String values; // NOSONAR
045    /**
046     * To use instead of {@link #values} if the list of values has to be obtained with a Java method of this form:
047     * <p>{@code public static String[] getValues();}<p>
048     * The value must be: {@code full.package.name.ClassName#methodName}.
049     */
050    public String values_from; // NOSONAR
051    /** The context used for translating {@link #values} */
052    public String values_context; // NOSONAR
053    /** Disabled internationalisation for value to avoid mistakes, see #11696 */
054    public boolean values_no_i18n; // NOSONAR
055    /** Whether to sort the values, defaults to true. */
056    public boolean values_sort = true; // NOSONAR
057    /**
058     * A list of entries that is displayed to the user.
059     * Must be the same number and order of entries as {@link #values} and editable must be false or not specified.
060     * For the delimiter character and escaping, see the remarks at {@link #values}.
061     */
062    public String display_values; // NOSONAR
063    /** The localized version of {@link #display_values}. */
064    public String locale_display_values; // NOSONAR
065    /**
066     * A delimiter-separated list of texts to be displayed below each {@code display_value}.
067     * (Only if it is not possible to describe the entry in 2-3 words.)
068     * Instead of comma separated list instead using {@link #values}, {@link #display_values} and {@link #short_descriptions},
069     * the following form is also supported:<p>
070     * {@code <list_entry value="" display_value="" short_description="" icon="" icon_size="" />}
071     */
072    public String short_descriptions; // NOSONAR
073    /** The localized version of {@link #short_descriptions}. */
074    public String locale_short_descriptions; // NOSONAR
075    /** The default value for the item. If not specified, the current value of the key is chosen as default (if applicable).*/
076    public String default_; // NOSONAR
077    /**
078     * The character that separates values.
079     * In case of {@link Combo} the default is comma.
080     * In case of {@link MultiSelect} the default is semicolon and this will also be used to separate selected values in the tag.
081     */
082    public char delimiter = ';'; // NOSONAR
083    /** whether the last value is used as default.
084     * Using "force" (2) enforces this behaviour also for already tagged objects. Default is "false" (0).*/
085    public byte use_last_as_default; // NOSONAR
086    /** whether to use values for search via {@link TaggingPresetSelector} */
087    public boolean values_searchable; // NOSONAR
088
089    /**
090     * The standard entries in the combobox dropdown or multiselect list. These entries are defined
091     * in {@code defaultpresets.xml} (or in other custom preset files).
092     */
093    protected final List<PresetListEntry> presetListEntries = new ArrayList<>();
094    /** Helps avoid duplicate list entries */
095    protected final Map<String, PresetListEntry> seenValues = new TreeMap<>();
096    protected Usage usage;
097    /** Used to see if the user edited the value. */
098    protected String originalValue;
099
100    /**
101     * A list cell renderer that paints a short text in the current value pane and and a longer text
102     * in the dropdown list.
103     */
104    static class ComboMultiSelectListCellRenderer extends JosmListCellRenderer<PresetListEntry> {
105        int width;
106        private String key;
107
108        ComboMultiSelectListCellRenderer(Component component, ListCellRenderer<? super PresetListEntry> renderer, int width, String key) {
109            super(component, renderer);
110            this.key = key;
111            setWidth(width);
112        }
113
114        /**
115         * Sets the width to format the dropdown list to
116         *
117         * Note: This is not the width of the list, but the width to which we format any multi-line
118         * label in the list.  We cannot use the list's width because at the time the combobox
119         * measures its items, it is not guaranteed that the list is already sized, the combobox may
120         * not even be layed out yet.  Set this to {@code combobox.getWidth()}
121         *
122         * @param width the width
123         */
124        public void setWidth(int width) {
125            if (width <= 0)
126                width = 200;
127            this.width = width - 20;
128        }
129
130        @Override
131        public JLabel getListCellRendererComponent(
132            JList<? extends PresetListEntry> list, PresetListEntry value, int index, boolean isSelected, boolean cellHasFocus) {
133
134            JLabel l = (JLabel) renderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
135            l.setComponentOrientation(component.getComponentOrientation());
136            if (index != -1) {
137                // index -1 is set when measuring the size of the cell and when painting the
138                // editor-ersatz of a readonly combobox. fixes #6157
139                l.setText(value.getListDisplay(width));
140            }
141            if (value.getCount() > 0) {
142                l.setFont(l.getFont().deriveFont(Font.ITALIC + Font.BOLD));
143            }
144            l.setIcon(value.getIcon());
145            l.setToolTipText(value.getToolTipText(key));
146            return l;
147        }
148    }
149
150    /**
151     * allow escaped comma in comma separated list:
152     * "A\, B\, C,one\, two" --&gt; ["A, B, C", "one, two"]
153     * @param delimiter the delimiter, e.g. a comma. separates the entries and
154     *      must be escaped within one entry
155     * @param s the string
156     * @return splitted items
157     */
158    public static List<String> splitEscaped(char delimiter, String s) {
159        if (s == null)
160            return null; // NOSONAR
161
162        List<String> result = new ArrayList<>();
163        boolean backslash = false;
164        StringBuilder item = new StringBuilder();
165        for (int i = 0; i < s.length(); i++) {
166            char ch = s.charAt(i);
167            if (backslash) {
168                item.append(ch);
169                backslash = false;
170            } else if (ch == '\\') {
171                backslash = true;
172            } else if (ch == delimiter) {
173                result.add(item.toString());
174                item.setLength(0);
175            } else {
176                item.append(ch);
177            }
178        }
179        if (item.length() > 0) {
180            result.add(item.toString());
181        }
182        return result;
183    }
184
185    /**
186     * Returns the value selected in the combobox or a synthetic value if a multiselect.
187     *
188     * @return the value
189     */
190    protected abstract PresetListEntry getSelectedItem();
191
192    @Override
193    public Collection<String> getValues() {
194        initListEntries();
195        return presetListEntries.stream().map(x -> x.value).collect(Collectors.toSet());
196    }
197
198    /**
199     * Returns the values to display.
200     * @return the values to display
201     */
202    public Collection<String> getDisplayValues() {
203        initListEntries();
204        return presetListEntries.stream().map(PresetListEntry::getDisplayValue).collect(Collectors.toList());
205    }
206
207    /**
208     * Adds the label to the panel
209     *
210     * @param p the panel
211     * @return the label
212     */
213    protected JLabel addLabel(JPanel p) {
214        final JLabel label = new JLabel(tr("{0}:", locale_text));
215        addIcon(label);
216        label.setToolTipText(getKeyTooltipText());
217        label.setComponentPopupMenu(getPopupMenu());
218        label.applyComponentOrientation(OrientationAction.getDefaultComponentOrientation());
219        p.add(label, GBC.std().insets(0, 0, 10, 0));
220        return label;
221    }
222
223    protected void initListEntries() {
224        if (presetListEntries.isEmpty()) {
225            initListEntriesFromAttributes();
226        }
227    }
228
229    private List<String> getValuesFromCode(String valuesFrom) {
230        // get the values from a Java function
231        String[] classMethod = valuesFrom.split("#", -1);
232        if (classMethod.length == 2) {
233            try {
234                Method method = Class.forName(classMethod[0]).getMethod(classMethod[1]);
235                // Check method is public static String[] methodName()
236                int mod = method.getModifiers();
237                if (Modifier.isPublic(mod) && Modifier.isStatic(mod)
238                        && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) {
239                    return Arrays.asList((String[]) method.invoke(null));
240                } else {
241                    Logging.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text,
242                            "public static String[] methodName()"));
243                }
244            } catch (ReflectiveOperationException e) {
245                Logging.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text,
246                        e.getClass().getName(), e.getMessage()));
247                Logging.debug(e);
248            }
249        }
250        return null; // NOSONAR
251    }
252
253    /**
254     * Checks if list {@code a} is either null or the same length as list {@code b}.
255     *
256     * @param a The list to check
257     * @param b The other list
258     * @param name The name of the list for error reporting
259     * @return {@code a} if both lists have the same length or {@code null}
260     */
261    private List<String> checkListsSameLength(List<String> a, List<String> b, String name) {
262        if (a != null && a.size() != b.size()) {
263            Logging.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''{2}'' must be the same as in ''values''",
264                            key, text, name));
265            Logging.error(tr("Detailed information: {0} <> {1}", a, b));
266            return null; // NOSONAR
267        }
268        return a;
269    }
270
271    protected void initListEntriesFromAttributes() {
272        List<String> valueList = null;
273        List<String> displayList = null;
274        List<String> localeDisplayList = null;
275
276        if (values_from != null) {
277            valueList = getValuesFromCode(values_from);
278        }
279
280        if (valueList == null) {
281            // get from {@code values} attribute
282            valueList = splitEscaped(delimiter, values);
283        }
284        if (valueList == null) {
285            return;
286        }
287
288        if (!values_no_i18n) {
289            localeDisplayList = splitEscaped(delimiter, locale_display_values);
290            displayList = splitEscaped(delimiter, display_values);
291        }
292        List<String> localeShortDescriptionsList = splitEscaped(delimiter, locale_short_descriptions);
293        List<String> shortDescriptionsList = splitEscaped(delimiter, short_descriptions);
294
295        displayList = checkListsSameLength(displayList, valueList, "display_values");
296        localeDisplayList = checkListsSameLength(localeDisplayList, valueList, "locale_display_values");
297        shortDescriptionsList = checkListsSameLength(shortDescriptionsList, valueList, "short_descriptions");
298        localeShortDescriptionsList = checkListsSameLength(localeShortDescriptionsList, valueList, "locale_short_descriptions");
299
300        for (int i = 0; i < valueList.size(); i++) {
301            final PresetListEntry e = new PresetListEntry(valueList.get(i), this);
302            if (displayList != null)
303                e.display_value = displayList.get(i);
304            if (localeDisplayList != null)
305                e.locale_display_value = localeDisplayList.get(i);
306            if (shortDescriptionsList != null)
307                e.short_description = shortDescriptionsList.get(i);
308            if (localeShortDescriptionsList != null)
309                e.locale_short_description = localeShortDescriptionsList.get(i);
310            addListEntry(e);
311        }
312
313        if (values_sort && TaggingPresets.SORT_MENU.get()) {
314            Collections.sort(presetListEntries, (a, b) -> AlphanumComparator.getInstance().compare(a.getDisplayValue(), b.getDisplayValue()));
315        }
316    }
317
318    /**
319     * Returns the initial value to use for this preset.
320     * <p>
321     * The initial value is the value shown in the control when the preset dialog opens. For a
322     * discussion of all the options see the enclosed tickets.
323     *
324     * @param usage The key Usage
325     * @param support The support
326     * @return The initial value to use.
327     *
328     * @see "https://josm.openstreetmap.de/ticket/5564"
329     * @see "https://josm.openstreetmap.de/ticket/12733"
330     * @see "https://josm.openstreetmap.de/ticket/17324"
331     */
332    protected String getInitialValue(Usage usage, TaggingPresetItemGuiSupport support) {
333        String initialValue = null;
334        originalValue = "";
335
336        if (usage.hasUniqueValue()) {
337            // all selected primitives have the same not empty value for this key
338            initialValue = usage.getFirst();
339            originalValue = initialValue;
340        } else if (!usage.unused()) {
341            // at least one primitive has a value for this key (but not all have the same one)
342            initialValue = DIFFERENT;
343            originalValue = initialValue;
344        } else if (!usage.hadKeys() || isForceUseLastAsDefault() || PROP_FILL_DEFAULT.get()) {
345            // at this point no primitive had any value for this key
346            if (!support.isPresetInitiallyMatches() && isUseLastAsDefault() && LAST_VALUES.containsKey(key)) {
347                initialValue = LAST_VALUES.get(key);
348            } else {
349                initialValue = default_;
350            }
351        }
352        return initialValue != null ? initialValue : "";
353    }
354
355    @Override
356    public void addCommands(List<Tag> changedTags) {
357        String value = getSelectedItem().value;
358
359        // no change if same as before
360        if (value.equals(originalValue))
361            return;
362        changedTags.add(new Tag(key, value));
363
364        if (isUseLastAsDefault()) {
365            LAST_VALUES.put(key, value);
366        }
367    }
368
369    /**
370     * Sets whether the last value is used as default.
371     * @param v Using "force" (2) enforces this behaviour also for already tagged objects. Default is "false" (0).
372     */
373    public void setUse_last_as_default(String v) { // NOPMD
374        if ("force".equals(v)) {
375            use_last_as_default = 2;
376        } else if ("true".equals(v)) {
377            use_last_as_default = 1;
378        } else {
379            use_last_as_default = 0;
380        }
381    }
382
383    /**
384     * Returns true if the last entered value should be used as default.
385     * <p>
386     * Note: never used in {@code defaultpresets.xml}.
387     *
388     * @return true if the last entered value should be used as default.
389     */
390    protected boolean isUseLastAsDefault() {
391        return use_last_as_default > 0;
392    }
393
394    /**
395     * Returns true if the last entered value should be used as default also on primitives that
396     * already have tags.
397     * <p>
398     * Note: used for {@code addr:*} tags in {@code defaultpresets.xml}.
399     *
400     * @return true if see above
401     */
402    protected boolean isForceUseLastAsDefault() {
403        return use_last_as_default == 2;
404    }
405
406    /**
407     * Adds a preset list entry.
408     * @param e list entry to add
409     */
410    public void addListEntry(PresetListEntry e) {
411        presetListEntries.add(e);
412        // we need to fix the entries because the XML Parser
413        // {@link org.openstreetmap.josm.tools.XmlObjectParser.Parser#startElement} has used the
414        // default standard constructor for {@link PresetListEntry} if the list entry was defined
415        // using XML {@code <list_entry>}.
416        e.cms = this;
417    }
418
419    /**
420     * Adds a collection of preset list entries.
421     * @param e list entries to add
422     */
423    public void addListEntries(Collection<PresetListEntry> e) {
424        for (PresetListEntry i : e) {
425            addListEntry(i);
426        }
427    }
428
429    @Override
430    public MatchType getDefaultMatch() {
431        return MatchType.NONE;
432    }
433}