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