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     * @return splitted items
261     */
262    public static String[] splitEscaped(char delimiter, String s) {
263        if (s == null)
264            return new String[0];
265        List<String> result = new ArrayList<>();
266        boolean backslash = false;
267        StringBuilder item = new StringBuilder();
268        for (int i = 0; i < s.length(); i++) {
269            char ch = s.charAt(i);
270            if (backslash) {
271                item.append(ch);
272                backslash = false;
273            } else if (ch == '\\') {
274                backslash = true;
275            } else if (ch == delimiter) {
276                result.add(item.toString());
277                item.setLength(0);
278            } else {
279                item.append(ch);
280            }
281        }
282        if (item.length() > 0) {
283            result.add(item.toString());
284        }
285        return result.toArray(new String[result.size()]);
286    }
287
288    protected abstract Object getSelectedItem();
289
290    protected abstract void addToPanelAnchor(JPanel p, String def, boolean presetInitiallyMatches);
291
292    protected char getDelChar() {
293        return delimiter.isEmpty() ? ';' : delimiter.charAt(0);
294    }
295
296    @Override
297    public Collection<String> getValues() {
298        initListEntries();
299        return lhm.keySet();
300    }
301
302    public Collection<String> getDisplayValues() {
303        initListEntries();
304        return Utils.transform(lhm.values(), new Utils.Function<PresetListEntry, String>() {
305            @Override
306            public String apply(PresetListEntry x) {
307                return x.getDisplayValue(true);
308            }
309        });
310    }
311
312    @Override
313    public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel, boolean presetInitiallyMatches) {
314
315        initListEntries();
316
317        // find out if our key is already used in the selection.
318        usage = determineTextUsage(sel, key);
319        if (!usage.hasUniqueValue() && !usage.unused()) {
320            lhm.put(DIFFERENT, new PresetListEntry(DIFFERENT));
321        }
322
323        p.add(new JLabel(tr("{0}:", locale_text)), GBC.std().insets(0, 0, 10, 0));
324        addToPanelAnchor(p, default_, presetInitiallyMatches);
325
326        return true;
327
328    }
329
330    private void initListEntries() {
331        if (initialized) {
332            lhm.remove(DIFFERENT); // possibly added in #addToPanel
333            return;
334        } else if (lhm.isEmpty()) {
335            initListEntriesFromAttributes();
336        } else {
337            if (values != null) {
338                Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
339                        + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
340                        key, text, "values", "list_entry"));
341            }
342            if (display_values != null || locale_display_values != null) {
343                Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
344                        + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
345                        key, text, "display_values", "list_entry"));
346            }
347            if (short_descriptions != null || locale_short_descriptions != null) {
348                Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
349                        + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
350                        key, text, "short_descriptions", "list_entry"));
351            }
352            for (PresetListEntry e : lhm.values()) {
353                if (e.value_context == null) {
354                    e.value_context = values_context;
355                }
356            }
357        }
358        if (locale_text == null) {
359            locale_text = getLocaleText(text, text_context, null);
360        }
361        initialized = true;
362    }
363
364    private void initListEntriesFromAttributes() {
365        char delChar = getDelChar();
366
367        String[] value_array = null;
368
369        if (values_from != null) {
370            String[] class_method = values_from.split("#");
371            if (class_method != null && class_method.length == 2) {
372                try {
373                    Method method = Class.forName(class_method[0]).getMethod(class_method[1]);
374                    // Check method is public static String[] methodName()
375                    int mod = method.getModifiers();
376                    if (Modifier.isPublic(mod) && Modifier.isStatic(mod)
377                            && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) {
378                        value_array = (String[]) method.invoke(null);
379                    } else {
380                        Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text,
381                                "public static String[] methodName()"));
382                    }
383                } catch (Exception e) {
384                    Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text,
385                            e.getClass().getName(), e.getMessage()));
386                }
387            }
388        }
389
390        if (value_array == null) {
391            value_array = splitEscaped(delChar, values);
392        }
393
394        String[] display_array = value_array;
395        if (!values_no_i18n) {
396            final String displ = Utils.firstNonNull(locale_display_values, display_values);
397            display_array = displ == null ? value_array : splitEscaped(delChar, displ);
398        }
399
400        final String descr = Utils.firstNonNull(locale_short_descriptions, short_descriptions);
401        String[] short_descriptions_array = descr == null ? null : splitEscaped(delChar, descr);
402
403        if (display_array.length != value_array.length) {
404            Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''display_values'' must be the same as in ''values''",
405                            key, text));
406            Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(display_array), Arrays.toString(value_array)));
407            display_array = value_array;
408        }
409
410        if (short_descriptions_array != null && short_descriptions_array.length != value_array.length) {
411            Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''short_descriptions'' must be the same as in ''values''",
412                            key, text));
413            Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(short_descriptions_array), Arrays.toString(value_array)));
414            short_descriptions_array = null;
415        }
416
417        final List<PresetListEntry> entries = new ArrayList<>(value_array.length);
418        for (int i = 0; i < value_array.length; i++) {
419            final PresetListEntry e = new PresetListEntry(value_array[i]);
420            e.locale_display_value = locale_display_values != null || values_no_i18n
421                    ? display_array[i]
422                    : trc(values_context, fixPresetString(display_array[i]));
423            if (short_descriptions_array != null) {
424                e.locale_short_description = locale_short_descriptions != null
425                        ? short_descriptions_array[i]
426                        : tr(fixPresetString(short_descriptions_array[i]));
427            }
428
429            entries.add(e);
430        }
431
432        if (Main.pref.getBoolean("taggingpreset.sortvalues", true) && values_sort) {
433            Collections.sort(entries);
434        }
435
436        for (PresetListEntry i : entries) {
437            lhm.put(i.value, i);
438        }
439    }
440
441    protected String getDisplayIfNull() {
442        return null;
443    }
444
445    @Override
446    public void addCommands(List<Tag> changedTags) {
447        Object obj = getSelectedItem();
448        String display = (obj == null) ? null : obj.toString();
449        String value = null;
450        if (display == null) {
451            display = getDisplayIfNull();
452        }
453
454        if (display != null) {
455            for (Entry<String, PresetListEntry> entry : lhm.entrySet()) {
456                String k = entry.getValue().toString();
457                if (k != null && k.equals(display)) {
458                    value = entry.getKey();
459                    break;
460                }
461            }
462            if (value == null) {
463                value = display;
464            }
465        } else {
466            value = "";
467        }
468        value = Tag.removeWhiteSpaces(value);
469
470        // no change if same as before
471        if (originalValue == null) {
472            if (value.isEmpty())
473                return;
474        } else if (value.equals(originalValue.toString()))
475            return;
476
477        if (!"false".equals(use_last_as_default)) {
478            LAST_VALUES.put(key, value);
479        }
480        changedTags.add(new Tag(key, value));
481    }
482
483    public void addListEntry(PresetListEntry e) {
484        lhm.put(e.value, e);
485    }
486
487    public void addListEntries(Collection<PresetListEntry> e) {
488        for (PresetListEntry i : e) {
489            addListEntry(i);
490        }
491    }
492
493    protected ListCellRenderer<PresetListEntry> getListCellRenderer() {
494        return RENDERER;
495    }
496
497    @Override
498    public MatchType getDefaultMatch() {
499        return MatchType.NONE;
500    }
501}