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