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