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