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 * @return splitted items 261 */ 262 public static String[] splitEscaped(char delimiter, String s) { 263 if (s == null) 264 return new String[0]; 265 List<String> result = new ArrayList<>(); 266 boolean backslash = false; 267 StringBuilder item = new StringBuilder(); 268 for (int i = 0; i < s.length(); i++) { 269 char ch = s.charAt(i); 270 if (backslash) { 271 item.append(ch); 272 backslash = false; 273 } else if (ch == '\\') { 274 backslash = true; 275 } else if (ch == delimiter) { 276 result.add(item.toString()); 277 item.setLength(0); 278 } else { 279 item.append(ch); 280 } 281 } 282 if (item.length() > 0) { 283 result.add(item.toString()); 284 } 285 return result.toArray(new String[result.size()]); 286 } 287 288 protected abstract Object getSelectedItem(); 289 290 protected abstract void addToPanelAnchor(JPanel p, String def, boolean presetInitiallyMatches); 291 292 protected char getDelChar() { 293 return delimiter.isEmpty() ? ';' : delimiter.charAt(0); 294 } 295 296 @Override 297 public Collection<String> getValues() { 298 initListEntries(); 299 return lhm.keySet(); 300 } 301 302 public Collection<String> getDisplayValues() { 303 initListEntries(); 304 return Utils.transform(lhm.values(), new Utils.Function<PresetListEntry, String>() { 305 @Override 306 public String apply(PresetListEntry x) { 307 return x.getDisplayValue(true); 308 } 309 }); 310 } 311 312 @Override 313 public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel, boolean presetInitiallyMatches) { 314 315 initListEntries(); 316 317 // find out if our key is already used in the selection. 318 usage = determineTextUsage(sel, key); 319 if (!usage.hasUniqueValue() && !usage.unused()) { 320 lhm.put(DIFFERENT, new PresetListEntry(DIFFERENT)); 321 } 322 323 p.add(new JLabel(tr("{0}:", locale_text)), GBC.std().insets(0, 0, 10, 0)); 324 addToPanelAnchor(p, default_, presetInitiallyMatches); 325 326 return true; 327 328 } 329 330 private void initListEntries() { 331 if (initialized) { 332 lhm.remove(DIFFERENT); // possibly added in #addToPanel 333 return; 334 } else if (lhm.isEmpty()) { 335 initListEntriesFromAttributes(); 336 } else { 337 if (values != null) { 338 Main.warn(tr("Warning in tagging preset \"{0}-{1}\": " 339 + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.", 340 key, text, "values", "list_entry")); 341 } 342 if (display_values != null || locale_display_values != null) { 343 Main.warn(tr("Warning in tagging preset \"{0}-{1}\": " 344 + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.", 345 key, text, "display_values", "list_entry")); 346 } 347 if (short_descriptions != null || locale_short_descriptions != null) { 348 Main.warn(tr("Warning in tagging preset \"{0}-{1}\": " 349 + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.", 350 key, text, "short_descriptions", "list_entry")); 351 } 352 for (PresetListEntry e : lhm.values()) { 353 if (e.value_context == null) { 354 e.value_context = values_context; 355 } 356 } 357 } 358 if (locale_text == null) { 359 locale_text = getLocaleText(text, text_context, null); 360 } 361 initialized = true; 362 } 363 364 private void initListEntriesFromAttributes() { 365 char delChar = getDelChar(); 366 367 String[] value_array = null; 368 369 if (values_from != null) { 370 String[] class_method = values_from.split("#"); 371 if (class_method != null && class_method.length == 2) { 372 try { 373 Method method = Class.forName(class_method[0]).getMethod(class_method[1]); 374 // Check method is public static String[] methodName() 375 int mod = method.getModifiers(); 376 if (Modifier.isPublic(mod) && Modifier.isStatic(mod) 377 && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) { 378 value_array = (String[]) method.invoke(null); 379 } else { 380 Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text, 381 "public static String[] methodName()")); 382 } 383 } catch (Exception e) { 384 Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text, 385 e.getClass().getName(), e.getMessage())); 386 } 387 } 388 } 389 390 if (value_array == null) { 391 value_array = splitEscaped(delChar, values); 392 } 393 394 String[] display_array = value_array; 395 if (!values_no_i18n) { 396 final String displ = Utils.firstNonNull(locale_display_values, display_values); 397 display_array = displ == null ? value_array : splitEscaped(delChar, displ); 398 } 399 400 final String descr = Utils.firstNonNull(locale_short_descriptions, short_descriptions); 401 String[] short_descriptions_array = descr == null ? null : splitEscaped(delChar, descr); 402 403 if (display_array.length != value_array.length) { 404 Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''display_values'' must be the same as in ''values''", 405 key, text)); 406 Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(display_array), Arrays.toString(value_array))); 407 display_array = value_array; 408 } 409 410 if (short_descriptions_array != null && short_descriptions_array.length != value_array.length) { 411 Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''short_descriptions'' must be the same as in ''values''", 412 key, text)); 413 Main.error(tr("Detailed information: {0} <> {1}", Arrays.toString(short_descriptions_array), Arrays.toString(value_array))); 414 short_descriptions_array = null; 415 } 416 417 final List<PresetListEntry> entries = new ArrayList<>(value_array.length); 418 for (int i = 0; i < value_array.length; i++) { 419 final PresetListEntry e = new PresetListEntry(value_array[i]); 420 e.locale_display_value = locale_display_values != null || values_no_i18n 421 ? display_array[i] 422 : trc(values_context, fixPresetString(display_array[i])); 423 if (short_descriptions_array != null) { 424 e.locale_short_description = locale_short_descriptions != null 425 ? short_descriptions_array[i] 426 : tr(fixPresetString(short_descriptions_array[i])); 427 } 428 429 entries.add(e); 430 } 431 432 if (Main.pref.getBoolean("taggingpreset.sortvalues", true) && values_sort) { 433 Collections.sort(entries); 434 } 435 436 for (PresetListEntry i : entries) { 437 lhm.put(i.value, i); 438 } 439 } 440 441 protected String getDisplayIfNull() { 442 return null; 443 } 444 445 @Override 446 public void addCommands(List<Tag> changedTags) { 447 Object obj = getSelectedItem(); 448 String display = (obj == null) ? null : obj.toString(); 449 String value = null; 450 if (display == null) { 451 display = getDisplayIfNull(); 452 } 453 454 if (display != null) { 455 for (Entry<String, PresetListEntry> entry : lhm.entrySet()) { 456 String k = entry.getValue().toString(); 457 if (k != null && k.equals(display)) { 458 value = entry.getKey(); 459 break; 460 } 461 } 462 if (value == null) { 463 value = display; 464 } 465 } else { 466 value = ""; 467 } 468 value = Tag.removeWhiteSpaces(value); 469 470 // no change if same as before 471 if (originalValue == null) { 472 if (value.isEmpty()) 473 return; 474 } else if (value.equals(originalValue.toString())) 475 return; 476 477 if (!"false".equals(use_last_as_default)) { 478 LAST_VALUES.put(key, value); 479 } 480 changedTags.add(new Tag(key, value)); 481 } 482 483 public void addListEntry(PresetListEntry e) { 484 lhm.put(e.value, e); 485 } 486 487 public void addListEntries(Collection<PresetListEntry> e) { 488 for (PresetListEntry i : e) { 489 addListEntry(i); 490 } 491 } 492 493 protected ListCellRenderer<PresetListEntry> getListCellRenderer() { 494 return RENDERER; 495 } 496 497 @Override 498 public MatchType getDefaultMatch() { 499 return MatchType.NONE; 500 } 501}