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