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" --> ["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}