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