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