001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.tagging.presets; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.BorderLayout; 007import java.awt.Component; 008import java.awt.Dimension; 009import java.awt.event.ActionEvent; 010import java.util.ArrayList; 011import java.util.Collection; 012import java.util.Collections; 013import java.util.EnumSet; 014import java.util.HashSet; 015import java.util.Iterator; 016import java.util.List; 017import java.util.Locale; 018import java.util.Objects; 019import java.util.Set; 020import java.util.stream.Collectors; 021 022import javax.swing.AbstractAction; 023import javax.swing.Action; 024import javax.swing.BoxLayout; 025import javax.swing.DefaultListCellRenderer; 026import javax.swing.Icon; 027import javax.swing.JCheckBox; 028import javax.swing.JLabel; 029import javax.swing.JList; 030import javax.swing.JPanel; 031import javax.swing.JPopupMenu; 032import javax.swing.ListCellRenderer; 033import javax.swing.event.ListSelectionEvent; 034import javax.swing.event.ListSelectionListener; 035 036import org.openstreetmap.josm.data.osm.DataSelectionListener; 037import org.openstreetmap.josm.data.osm.DataSet; 038import org.openstreetmap.josm.data.osm.OsmDataManager; 039import org.openstreetmap.josm.data.osm.OsmPrimitive; 040import org.openstreetmap.josm.data.preferences.BooleanProperty; 041import org.openstreetmap.josm.gui.MainApplication; 042import org.openstreetmap.josm.gui.tagging.presets.items.ComboMultiSelect; 043import org.openstreetmap.josm.gui.tagging.presets.items.Key; 044import org.openstreetmap.josm.gui.tagging.presets.items.KeyedItem; 045import org.openstreetmap.josm.gui.tagging.presets.items.Roles; 046import org.openstreetmap.josm.gui.tagging.presets.items.Roles.Role; 047import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher; 048import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel; 049import org.openstreetmap.josm.tools.Destroyable; 050import org.openstreetmap.josm.tools.Utils; 051 052/** 053 * GUI component to select tagging preset: the list with filter and two checkboxes 054 * @since 6068 055 */ 056public class TaggingPresetSelector extends SearchTextResultListPanel<TaggingPreset> 057 implements DataSelectionListener, TaggingPresetListener, Destroyable { 058 059 private static final int CLASSIFICATION_IN_FAVORITES = 300; 060 private static final int CLASSIFICATION_NAME_MATCH = 300; 061 private static final int CLASSIFICATION_GROUP_MATCH = 200; 062 private static final int CLASSIFICATION_TAGS_MATCH = 100; 063 064 private static final BooleanProperty SEARCH_IN_TAGS = new BooleanProperty("taggingpreset.dialog.search-in-tags", true); 065 private static final BooleanProperty ONLY_APPLICABLE = new BooleanProperty("taggingpreset.dialog.only-applicable-to-selection", true); 066 067 private final JCheckBox ckOnlyApplicable; 068 private final JCheckBox ckSearchInTags; 069 private final Set<TaggingPresetType> typesInSelection = EnumSet.noneOf(TaggingPresetType.class); 070 private boolean typesInSelectionDirty = true; 071 private final transient PresetClassifications classifications = new PresetClassifications(); 072 073 private static class ResultListCellRenderer implements ListCellRenderer<TaggingPreset> { 074 private final DefaultListCellRenderer def = new DefaultListCellRenderer(); 075 @Override 076 public Component getListCellRendererComponent(JList<? extends TaggingPreset> list, TaggingPreset tp, int index, 077 boolean isSelected, boolean cellHasFocus) { 078 JLabel result = (JLabel) def.getListCellRendererComponent(list, tp, index, isSelected, cellHasFocus); 079 result.setText(tp.getName()); 080 result.setIcon((Icon) tp.getValue(Action.SMALL_ICON)); 081 return result; 082 } 083 } 084 085 /** 086 * Computes the match ration of a {@link TaggingPreset} wrt. a searchString. 087 */ 088 public static class PresetClassification implements Comparable<PresetClassification> { 089 public final TaggingPreset preset; 090 public int classification; 091 public int favoriteIndex; 092 private final Collection<String> groups; 093 private final Collection<String> names; 094 private final Collection<String> tags; 095 096 PresetClassification(TaggingPreset preset) { 097 this.preset = preset; 098 Set<String> groupSet = new HashSet<>(); 099 Set<String> nameSet = new HashSet<>(); 100 Set<String> tagSet = new HashSet<>(); 101 TaggingPreset group = preset.group; 102 while (group != null) { 103 addLocaleNames(groupSet, group); 104 group = group.group; 105 } 106 addLocaleNames(nameSet, preset); 107 for (TaggingPresetItem item: preset.data) { 108 if (item instanceof KeyedItem) { 109 tagSet.add(((KeyedItem) item).key); 110 if (item instanceof ComboMultiSelect) { 111 final ComboMultiSelect cms = (ComboMultiSelect) item; 112 if (cms.values_searchable) { 113 tagSet.addAll(cms.getDisplayValues()); 114 } 115 } 116 if (item instanceof Key && ((Key) item).value != null) { 117 tagSet.add(((Key) item).value); 118 } 119 } else if (item instanceof Roles) { 120 for (Role role : ((Roles) item).roles) { 121 tagSet.add(role.key); 122 } 123 } 124 } 125 this.groups = Utils.toUnmodifiableList(groupSet); 126 this.names = Utils.toUnmodifiableList(nameSet); 127 this.tags = Utils.toUnmodifiableList(tagSet); 128 } 129 130 private static void addLocaleNames(Collection<String> collection, TaggingPreset preset) { 131 String locName = preset.getLocaleName(); 132 if (locName != null) { 133 Collections.addAll(collection, locName.toLowerCase(Locale.ENGLISH).split("\\s", -1)); 134 } 135 } 136 137 private static String simplifyString(String s) { 138 return Utils.deAccent(s).toLowerCase(Locale.ENGLISH).replaceAll("\\p{Punct}", ""); 139 } 140 141 private static int isMatching(Collection<String> values, String... searchString) { 142 int sum = 0; 143 List<String> deaccentedValues = values.stream() 144 .map(PresetClassification::simplifyString).collect(Collectors.toList()); 145 for (String word: searchString) { 146 boolean found = false; 147 boolean foundFirst = false; 148 String deaccentedWord = simplifyString(word); 149 for (String value: deaccentedValues) { 150 int index = value.indexOf(deaccentedWord); 151 if (index == 0) { 152 foundFirst = true; 153 break; 154 } else if (index > 0) { 155 found = true; 156 } 157 } 158 if (foundFirst) { 159 sum += 2; 160 } else if (found) { 161 sum += 1; 162 } else 163 return 0; 164 } 165 return sum; 166 } 167 168 int isMatchingGroup(String... words) { 169 return isMatching(groups, words); 170 } 171 172 int isMatchingName(String... words) { 173 return isMatching(names, words); 174 } 175 176 int isMatchingTags(String... words) { 177 return isMatching(tags, words); 178 } 179 180 @Override 181 public int compareTo(PresetClassification o) { 182 int result = o.classification - classification; 183 if (result == 0) 184 return preset.getName().compareTo(o.preset.getName()); 185 else 186 return result; 187 } 188 189 @Override 190 public String toString() { 191 return Integer.toString(classification) + ' ' + preset; 192 } 193 } 194 195 /** 196 * Constructs a new {@code TaggingPresetSelector}. 197 * @param displayOnlyApplicable if {@code true} display "Show only applicable to selection" checkbox 198 * @param displaySearchInTags if {@code true} display "Search in tags" checkbox 199 */ 200 public TaggingPresetSelector(boolean displayOnlyApplicable, boolean displaySearchInTags) { 201 super(); 202 lsResult.setCellRenderer(new ResultListCellRenderer()); 203 classifications.loadPresets(TaggingPresets.getTaggingPresets()); 204 TaggingPresets.addListener(this); 205 206 JPanel pnChecks = new JPanel(); 207 pnChecks.setLayout(new BoxLayout(pnChecks, BoxLayout.Y_AXIS)); 208 209 if (displayOnlyApplicable) { 210 ckOnlyApplicable = new JCheckBox(); 211 ckOnlyApplicable.setText(tr("Show only applicable to selection")); 212 pnChecks.add(ckOnlyApplicable); 213 ckOnlyApplicable.addItemListener(e -> filterItems()); 214 } else { 215 ckOnlyApplicable = null; 216 } 217 218 if (displaySearchInTags) { 219 ckSearchInTags = new JCheckBox(); 220 ckSearchInTags.setText(tr("Search in tags")); 221 ckSearchInTags.setSelected(SEARCH_IN_TAGS.get()); 222 ckSearchInTags.addItemListener(e -> filterItems()); 223 pnChecks.add(ckSearchInTags); 224 } else { 225 ckSearchInTags = null; 226 } 227 228 add(pnChecks, BorderLayout.SOUTH); 229 230 setPreferredSize(new Dimension(400, 300)); 231 filterItems(); 232 JPopupMenu popupMenu = new JPopupMenu(); 233 popupMenu.add(new AbstractAction(tr("Add toolbar button")) { 234 @Override 235 public void actionPerformed(ActionEvent ae) { 236 final TaggingPreset preset = getSelectedPreset(); 237 if (preset != null) { 238 MainApplication.getToolbar().addCustomButton(preset.getToolbarString(), -1, false); 239 } 240 } 241 }); 242 lsResult.addMouseListener(new PopupMenuLauncher(popupMenu)); 243 } 244 245 /** 246 * Search expression can be in form: "group1/group2/name" where names can contain multiple words 247 */ 248 @Override 249 protected synchronized void filterItems() { 250 //TODO Save favorites to file 251 String text = edSearchText.getText().toLowerCase(Locale.ENGLISH); 252 boolean onlyApplicable = ckOnlyApplicable != null && ckOnlyApplicable.isSelected(); 253 boolean inTags = ckSearchInTags != null && ckSearchInTags.isSelected(); 254 255 DataSet ds = OsmDataManager.getInstance().getEditDataSet(); 256 Collection<OsmPrimitive> selected = (ds == null) ? Collections.<OsmPrimitive>emptyList() : ds.getSelected(); 257 final List<PresetClassification> result = classifications.getMatchingPresets( 258 text, onlyApplicable, inTags, getTypesInSelection(), selected); 259 260 final TaggingPreset oldPreset = getSelectedPreset(); 261 lsResultModel.setItems(Utils.transform(result, x -> x.preset)); 262 final TaggingPreset newPreset = getSelectedPreset(); 263 if (!Objects.equals(oldPreset, newPreset)) { 264 int[] indices = lsResult.getSelectedIndices(); 265 for (ListSelectionListener listener : listSelectionListeners) { 266 listener.valueChanged(new ListSelectionEvent(lsResult, lsResult.getSelectedIndex(), 267 indices.length > 0 ? indices[indices.length-1] : -1, false)); 268 } 269 } 270 } 271 272 /** 273 * A collection of {@link PresetClassification}s with the functionality of filtering wrt. searchString. 274 */ 275 public static class PresetClassifications implements Iterable<PresetClassification> { 276 277 private final List<PresetClassification> classifications = new ArrayList<>(); 278 279 public List<PresetClassification> getMatchingPresets(String searchText, boolean onlyApplicable, boolean inTags, 280 Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) { 281 final String[] groupWords; 282 final String[] nameWords; 283 284 if (searchText.contains("/")) { 285 groupWords = searchText.substring(0, searchText.lastIndexOf('/')).split("[\\s/]", -1); 286 nameWords = searchText.substring(searchText.indexOf('/') + 1).split("\\s", -1); 287 } else { 288 groupWords = null; 289 nameWords = searchText.split("\\s", -1); 290 } 291 292 return getMatchingPresets(groupWords, nameWords, onlyApplicable, inTags, presetTypes, selectedPrimitives); 293 } 294 295 public List<PresetClassification> getMatchingPresets(String[] groupWords, String[] nameWords, boolean onlyApplicable, 296 boolean inTags, Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) { 297 298 final List<PresetClassification> result = new ArrayList<>(); 299 for (PresetClassification presetClassification : classifications) { 300 TaggingPreset preset = presetClassification.preset; 301 presetClassification.classification = 0; 302 303 if (onlyApplicable) { 304 boolean suitable = preset.typeMatches(presetTypes); 305 306 if (!suitable && preset.types.contains(TaggingPresetType.RELATION) 307 && preset.roles != null && !preset.roles.roles.isEmpty()) { 308 suitable = preset.roles.roles.stream().anyMatch( 309 object -> object.memberExpression != null && selectedPrimitives.stream().anyMatch(object.memberExpression)); 310 // keep the preset to allow the creation of new relations 311 } 312 if (!suitable) { 313 continue; 314 } 315 } 316 317 if (groupWords != null && presetClassification.isMatchingGroup(groupWords) == 0) { 318 continue; 319 } 320 321 int matchName = presetClassification.isMatchingName(nameWords); 322 323 if (matchName == 0) { 324 if (groupWords == null) { 325 int groupMatch = presetClassification.isMatchingGroup(nameWords); 326 if (groupMatch > 0) { 327 presetClassification.classification = CLASSIFICATION_GROUP_MATCH + groupMatch; 328 } 329 } 330 if (presetClassification.classification == 0 && inTags) { 331 int tagsMatch = presetClassification.isMatchingTags(nameWords); 332 if (tagsMatch > 0) { 333 presetClassification.classification = CLASSIFICATION_TAGS_MATCH + tagsMatch; 334 } 335 } 336 } else { 337 presetClassification.classification = CLASSIFICATION_NAME_MATCH + matchName; 338 } 339 340 if (presetClassification.classification > 0) { 341 presetClassification.classification += presetClassification.favoriteIndex; 342 result.add(presetClassification); 343 } 344 } 345 346 Collections.sort(result); 347 return result; 348 } 349 350 /** 351 * Clears the selector. 352 */ 353 public void clear() { 354 classifications.clear(); 355 } 356 357 /** 358 * Loads a given collection of presets. 359 * @param presets presets collection 360 */ 361 public void loadPresets(Collection<TaggingPreset> presets) { 362 for (TaggingPreset preset : presets) { 363 if (preset instanceof TaggingPresetSeparator || preset instanceof TaggingPresetMenu) { 364 continue; 365 } 366 classifications.add(new PresetClassification(preset)); 367 } 368 } 369 370 @Override 371 public Iterator<PresetClassification> iterator() { 372 return classifications.iterator(); 373 } 374 } 375 376 private Set<TaggingPresetType> getTypesInSelection() { 377 if (typesInSelectionDirty) { 378 synchronized (typesInSelection) { 379 typesInSelectionDirty = false; 380 typesInSelection.clear(); 381 if (OsmDataManager.getInstance().getEditDataSet() == null) return typesInSelection; 382 for (OsmPrimitive primitive : OsmDataManager.getInstance().getEditDataSet().getSelected()) { 383 typesInSelection.add(TaggingPresetType.forPrimitive(primitive)); 384 } 385 } 386 } 387 return typesInSelection; 388 } 389 390 @Override 391 public void selectionChanged(SelectionChangeEvent event) { 392 typesInSelectionDirty = true; 393 } 394 395 @Override 396 public synchronized void init() { 397 if (ckOnlyApplicable != null) { 398 ckOnlyApplicable.setEnabled(!getTypesInSelection().isEmpty()); 399 ckOnlyApplicable.setSelected(!getTypesInSelection().isEmpty() && ONLY_APPLICABLE.get()); 400 } 401 super.init(); 402 } 403 404 /** 405 * Initializes the selector with a given collection of presets. 406 * @param presets presets collection 407 */ 408 public void init(Collection<TaggingPreset> presets) { 409 classifications.clear(); 410 classifications.loadPresets(presets); 411 init(); 412 } 413 414 /** 415 * Save checkbox values in preferences for future reuse 416 */ 417 public void savePreferences() { 418 if (ckSearchInTags != null) { 419 SEARCH_IN_TAGS.put(ckSearchInTags.isSelected()); 420 } 421 if (ckOnlyApplicable != null && ckOnlyApplicable.isEnabled()) { 422 ONLY_APPLICABLE.put(ckOnlyApplicable.isSelected()); 423 } 424 } 425 426 /** 427 * Determines, which preset is selected at the moment. 428 * @return selected preset (as action) 429 */ 430 public synchronized TaggingPreset getSelectedPreset() { 431 if (lsResultModel.isEmpty()) return null; 432 int idx = lsResult.getSelectedIndex(); 433 if (idx < 0 || idx >= lsResultModel.getSize()) { 434 idx = 0; 435 } 436 return lsResultModel.getElementAt(idx); 437 } 438 439 /** 440 * Determines, which preset is selected at the moment. Updates {@link PresetClassification#favoriteIndex}! 441 * @return selected preset (as action) 442 */ 443 public synchronized TaggingPreset getSelectedPresetAndUpdateClassification() { 444 final TaggingPreset preset = getSelectedPreset(); 445 for (PresetClassification pc: classifications) { 446 if (pc.preset == preset) { 447 pc.favoriteIndex = CLASSIFICATION_IN_FAVORITES; 448 } else if (pc.favoriteIndex > 0) { 449 pc.favoriteIndex--; 450 } 451 } 452 return preset; 453 } 454 455 /** 456 * Selects a given preset. 457 * @param p preset to select 458 */ 459 public synchronized void setSelectedPreset(TaggingPreset p) { 460 lsResult.setSelectedValue(p, true); 461 } 462 463 @Override 464 public void taggingPresetsModified() { 465 classifications.clear(); 466 classifications.loadPresets(TaggingPresets.getTaggingPresets()); 467 } 468 469 @Override 470 public void destroy() { 471 TaggingPresets.removeListener(this); 472 } 473 474}