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