// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.gui.tagging.presets.items; import static org.openstreetmap.josm.tools.I18n.tr; import static org.openstreetmap.josm.tools.I18n.trc; import java.awt.Component; import java.awt.Dimension; import java.awt.Font; import java.io.File; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; import javax.swing.ImageIcon; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.ListCellRenderer; import javax.swing.ListModel; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.Tag; import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetReader; import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector; import org.openstreetmap.josm.tools.AlphanumComparator; import org.openstreetmap.josm.tools.GBC; import org.openstreetmap.josm.tools.Utils; /** * Abstract superclass for combo box and multi-select list types. */ public abstract class ComboMultiSelect extends KeyedItem { private static final Renderer RENDERER = new Renderer(); /** The localized version of {@link #text}. */ public String locale_text; // NOSONAR /** * A list of entries. * The list has to be separated by commas (for the {@link Combo} box) or by the specified delimiter (for the {@link MultiSelect}). * If a value contains the delimiter, the delimiter may be escaped with a backslash. * If a value contains a backslash, it must also be escaped with a backslash. */ public String values; // NOSONAR /** * To use instead of {@link #values} if the list of values has to be obtained with a Java method of this form: * <p>{@code public static String[] getValues();}<p> * The value must be: {@code full.package.name.ClassName#methodName}. */ public String values_from; // NOSONAR /** The context used for translating {@link #values} */ public String values_context; // NOSONAR /** Disabled internationalisation for value to avoid mistakes, see #11696 */ public boolean values_no_i18n; // NOSONAR /** Whether to sort the values, defaults to true. */ public boolean values_sort = true; // NOSONAR /** * A list of entries that is displayed to the user. * Must be the same number and order of entries as {@link #values} and editable must be false or not specified. * For the delimiter character and escaping, see the remarks at {@link #values}. */ public String display_values; // NOSONAR /** The localized version of {@link #display_values}. */ public String locale_display_values; // NOSONAR /** * A delimiter-separated list of texts to be displayed below each {@code display_value}. * (Only if it is not possible to describe the entry in 2-3 words.) * Instead of comma separated list instead using {@link #values}, {@link #display_values} and {@link #short_descriptions}, * the following form is also supported:<p> * {@code <list_entry value="" display_value="" short_description="" icon="" icon_size="" />} */ public String short_descriptions; // NOSONAR /** The localized version of {@link #short_descriptions}. */ public String locale_short_descriptions; // NOSONAR /** The default value for the item. If not specified, the current value of the key is chosen as default (if applicable).*/ public String default_; // NOSONAR /** * The character that separates values. * In case of {@link Combo} the default is comma. * In case of {@link MultiSelect} the default is semicolon and this will also be used to separate selected values in the tag. */ public String delimiter = ";"; // NOSONAR /** whether the last value is used as default. Using "force" enforces this behaviour also for already tagged objects. Default is "false".*/ public String use_last_as_default = "false"; // NOSONAR /** whether to use values for search via {@link TaggingPresetSelector} */ public String values_searchable = "false"; // NOSONAR protected JComponent component; protected final Map<String, PresetListEntry> lhm = new LinkedHashMap<>(); private boolean initialized; protected Usage usage; protected Object originalValue; private static final class Renderer implements ListCellRenderer<PresetListEntry> { private final JLabel lbl = new JLabel(); @Override public Component getListCellRendererComponent(JList<? extends PresetListEntry> list, PresetListEntry item, int index, boolean isSelected, boolean cellHasFocus) { // Only return cached size, item is not shown if (!list.isShowing() && item.prefferedWidth != -1 && item.prefferedHeight != -1) { if (index == -1) { lbl.setPreferredSize(new Dimension(item.prefferedWidth, 10)); } else { lbl.setPreferredSize(new Dimension(item.prefferedWidth, item.prefferedHeight)); } return lbl; } lbl.setPreferredSize(null); if (isSelected) { lbl.setBackground(list.getSelectionBackground()); lbl.setForeground(list.getSelectionForeground()); } else { lbl.setBackground(list.getBackground()); lbl.setForeground(list.getForeground()); } lbl.setOpaque(true); lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN)); lbl.setText("<html>" + item.getListDisplay() + "</html>"); lbl.setIcon(item.getIcon()); lbl.setEnabled(list.isEnabled()); // Cache size item.prefferedWidth = lbl.getPreferredSize().width; item.prefferedHeight = lbl.getPreferredSize().height; // We do not want the editor to have the maximum height of all // entries. Return a dummy with bogus height. if (index == -1) { lbl.setPreferredSize(new Dimension(lbl.getPreferredSize().width, 10)); } return lbl; } } /** * Class that allows list values to be assigned and retrieved as a comma-delimited * string (extracted from TaggingPreset) */ protected static class ConcatenatingJList extends JList<PresetListEntry> { private final String delimiter; protected ConcatenatingJList(String del, PresetListEntry... o) { super(o); delimiter = del; } public void setSelectedItem(Object o) { if (o == null) { clearSelection(); } else { String s = o.toString(); Set<String> parts = new TreeSet<>(Arrays.asList(s.split(delimiter))); ListModel<PresetListEntry> lm = getModel(); int[] intParts = new int[lm.getSize()]; int j = 0; for (int i = 0; i < lm.getSize(); i++) { final String value = lm.getElementAt(i).value; if (parts.contains(value)) { intParts[j++] = i; parts.remove(value); } } setSelectedIndices(Arrays.copyOf(intParts, j)); // check if we have actually managed to represent the full // value with our presets. if not, cop out; we will not offer // a selection list that threatens to ruin the value. setEnabled(parts.isEmpty()); } } public String getSelectedItem() { ListModel<PresetListEntry> lm = getModel(); int[] si = getSelectedIndices(); StringBuilder builder = new StringBuilder(); for (int i = 0; i < si.length; i++) { if (i > 0) { builder.append(delimiter); } builder.append(lm.getElementAt(si[i]).value); } return builder.toString(); } } /** * Preset list entry. */ public static class PresetListEntry implements Comparable<PresetListEntry> { /** Entry value */ public String value; // NOSONAR /** The context used for translating {@link #value} */ public String value_context; // NOSONAR /** Value displayed to the user */ public String display_value; // NOSONAR /** Text to be displayed below {@code display_value}. */ public String short_description; // NOSONAR /** The location of icon file to display */ public String icon; // NOSONAR /** The size of displayed icon. If not set, default is size from icon file */ public String icon_size; // NOSONAR /** The localized version of {@link #display_value}. */ public String locale_display_value; // NOSONAR /** The localized version of {@link #short_description}. */ public String locale_short_description; // NOSONAR private final File zipIcons = TaggingPresetReader.getZipIcons(); /** Cached width (currently only for Combo) to speed up preset dialog initialization */ public int prefferedWidth = -1; // NOSONAR /** Cached height (currently only for Combo) to speed up preset dialog initialization */ public int prefferedHeight = -1; // NOSONAR /** * Constructs a new {@code PresetListEntry}, uninitialized. */ public PresetListEntry() { // Public default constructor is needed } /** * Constructs a new {@code PresetListEntry}, initialized with a value. * @param value value */ public PresetListEntry(String value) { this.value = value; } /** * Returns HTML formatted contents. * @return HTML formatted contents */ public String getListDisplay() { if (value.equals(DIFFERENT)) return "<b>" + Utils.escapeReservedCharactersHTML(DIFFERENT) + "</b>"; String displayValue = Utils.escapeReservedCharactersHTML(getDisplayValue(true)); String shortDescription = getShortDescription(true); if (displayValue.isEmpty() && (shortDescription == null || shortDescription.isEmpty())) return " "; final StringBuilder res = new StringBuilder("<b>").append(displayValue).append("</b>"); if (shortDescription != null) { // wrap in table to restrict the text width res.append("<div style=\"width:300px; padding:0 0 5px 5px\">") .append(shortDescription) .append("</div>"); } return res.toString(); } /** * Returns the entry icon, if any. * @return the entry icon, or {@code null} */ public ImageIcon getIcon() { return icon == null ? null : loadImageIcon(icon, zipIcons, parseInteger(icon_size)); } /** * Returns the value to display. * @param translated whether the text must be translated * @return the value to display */ public String getDisplayValue(boolean translated) { return translated ? Utils.firstNonNull(locale_display_value, tr(display_value), trc(value_context, value)) : Utils.firstNonNull(display_value, value); } /** * Returns the short description to display. * @param translated whether the text must be translated * @return the short description to display */ public String getShortDescription(boolean translated) { return translated ? Utils.firstNonNull(locale_short_description, tr(short_description)) : short_description; } // toString is mainly used to initialize the Editor @Override public String toString() { if (DIFFERENT.equals(value)) return DIFFERENT; String displayValue = getDisplayValue(true); return displayValue != null ? displayValue.replaceAll("<.*>", "") : ""; // remove additional markup, e.g. <br> } @Override public int compareTo(PresetListEntry o) { return AlphanumComparator.getInstance().compare(this.getDisplayValue(true), o.getDisplayValue(true)); } } /** * allow escaped comma in comma separated list: * "A\, B\, C,one\, two" --> ["A, B, C", "one, two"] * @param delimiter the delimiter, e.g. a comma. separates the entries and * must be escaped within one entry * @param s the string * @return splitted items */ public static String[] splitEscaped(char delimiter, String s) { if (s == null) return new String[0]; List<String> result = new ArrayList<>(); boolean backslash = false; StringBuilder item = new StringBuilder(); for (int i = 0; i < s.length(); i++) { char ch = s.charAt(i); if (backslash) { item.append(ch); backslash = false; } else if (ch == '\\') { backslash = true; } else if (ch == delimiter) { result.add(item.toString()); item.setLength(0); } else { item.append(ch); } } if (item.length() > 0) { result.add(item.toString()); } return result.toArray(new String[result.size()]); } protected abstract Object getSelectedItem(); protected abstract void addToPanelAnchor(JPanel p, String def, boolean presetInitiallyMatches); protected char getDelChar() { return delimiter.isEmpty() ? ';' : delimiter.charAt(0); } @Override public Collection<String> getValues() { initListEntries(); return lhm.keySet(); } /** * Returns the values to display. * @return the values to display */ public Collection<String> getDisplayValues() { initListEntries(); return lhm.values().stream().map(x -> x.getDisplayValue(true)).collect(Collectors.toList()); } @Override public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel, boolean presetInitiallyMatches) { initListEntries(); // find out if our key is already used in the selection. usage = determineTextUsage(sel, key); if (!usage.hasUniqueValue() && !usage.unused()) { lhm.put(DIFFERENT, new PresetListEntry(DIFFERENT)); } final JLabel label = new JLabel(tr("{0}:", locale_text)); label.setToolTipText(getKeyTooltipText()); p.add(label, GBC.std().insets(0, 0, 10, 0)); addToPanelAnchor(p, default_, presetInitiallyMatches); label.setLabelFor(component); component.setToolTipText(getKeyTooltipText()); return true; } private void initListEntries() { if (initialized) { lhm.remove(DIFFERENT); // possibly added in #addToPanel return; } else if (lhm.isEmpty()) { initListEntriesFromAttributes(); } else { if (values != null) { Main.warn(tr("Warning in tagging preset \"{0}-{1}\": " + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.", key, text, "values", "list_entry")); } if (display_values != null || locale_display_values != null) { Main.warn(tr("Warning in tagging preset \"{0}-{1}\": " + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.", key, text, "display_values", "list_entry")); } if (short_descriptions != null || locale_short_descriptions != null) { Main.warn(tr("Warning in tagging preset \"{0}-{1}\": " + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.", key, text, "short_descriptions", "list_entry")); } for (PresetListEntry e : lhm.values()) { if (e.value_context == null) { e.value_context = values_context; } } } if (locale_text == null) { locale_text = getLocaleText(text, text_context, null); } initialized = true; } private void initListEntriesFromAttributes() { char delChar = getDelChar(); String[] valueArray = null; if (values_from != null) { String[] classMethod = values_from.split("#"); if (classMethod.length == 2) { try { Method method = Class.forName(classMethod[0]).getMethod(classMethod[1]); // Check method is public static String[] methodName() int mod = method.getModifiers(); if (Modifier.isPublic(mod) && Modifier.isStatic(mod) && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) { valueArray = (String[]) method.invoke(null); } else { Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text, "public static String[] methodName()")); } } catch (ReflectiveOperationException e) { Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text, e.getClass().getName(), e.getMessage())); Main.debug(e); } } } if (valueArray == null) { valueArray = splitEscaped(delChar, values); } String[] displayArray = valueArray; if (!values_no_i18n) { final String displ = Utils.firstNonNull(locale_display_values, display_values); displayArray = displ == null ? valueArray : splitEscaped(delChar, displ); } final String descr = Utils.firstNonNull(locale_short_descriptions, short_descriptions); String[] shortDescriptionsArray = descr == null ? null : splitEscaped(delChar, descr); if (displayArray.length != valueArray.length) { Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''display_values'' must be the same as in ''values''", key, text)); Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(displayArray), Arrays.toString(valueArray))); displayArray = valueArray; } if (shortDescriptionsArray != null && shortDescriptionsArray.length != valueArray.length) { Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''short_descriptions'' must be the same as in ''values''", key, text)); Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(shortDescriptionsArray), Arrays.toString(valueArray))); shortDescriptionsArray = null; } final List<PresetListEntry> entries = new ArrayList<>(valueArray.length); for (int i = 0; i < valueArray.length; i++) { final PresetListEntry e = new PresetListEntry(valueArray[i]); e.locale_display_value = locale_display_values != null || values_no_i18n ? displayArray[i] : trc(values_context, fixPresetString(displayArray[i])); if (shortDescriptionsArray != null) { e.locale_short_description = locale_short_descriptions != null ? shortDescriptionsArray[i] : tr(fixPresetString(shortDescriptionsArray[i])); } entries.add(e); } if (values_sort && Main.pref.getBoolean("taggingpreset.sortvalues", true)) { Collections.sort(entries); } for (PresetListEntry i : entries) { lhm.put(i.value, i); } } protected String getDisplayIfNull() { return null; } @Override public void addCommands(List<Tag> changedTags) { Object obj = getSelectedItem(); String display = obj == null ? getDisplayIfNull() : obj.toString(); String value = null; if (display != null) { for (Entry<String, PresetListEntry> entry : lhm.entrySet()) { String k = entry.getValue().toString(); if (k.equals(display)) { value = entry.getKey(); break; } } if (value == null) { value = display; } } else { value = ""; } value = Tag.removeWhiteSpaces(value); // no change if same as before if (originalValue == null) { if (value.isEmpty()) return; } else if (value.equals(originalValue.toString())) return; if (!"false".equals(use_last_as_default)) { LAST_VALUES.put(key, value); } changedTags.add(new Tag(key, value)); } /** * Adds a preset list entry. * @param e list entry to add */ public void addListEntry(PresetListEntry e) { lhm.put(e.value, e); } /** * Adds a collection of preset list entries. * @param e list entries to add */ public void addListEntries(Collection<PresetListEntry> e) { for (PresetListEntry i : e) { addListEntry(i); } } protected ListCellRenderer<PresetListEntry> getListCellRenderer() { return RENDERER; } @Override public MatchType getDefaultMatch() { return MatchType.NONE; } }