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