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