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