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