001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.tagging; 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.ActionListener; 011import java.awt.event.ItemEvent; 012import java.awt.event.ItemListener; 013import java.awt.event.KeyAdapter; 014import java.awt.event.KeyEvent; 015import java.awt.event.MouseAdapter; 016import java.awt.event.MouseEvent; 017import java.util.ArrayList; 018import java.util.Collection; 019import java.util.Collections; 020import java.util.EnumSet; 021import java.util.HashSet; 022import java.util.Iterator; 023import java.util.List; 024 025import javax.swing.AbstractAction; 026import javax.swing.AbstractListModel; 027import javax.swing.Action; 028import javax.swing.BoxLayout; 029import javax.swing.DefaultListCellRenderer; 030import javax.swing.Icon; 031import javax.swing.JCheckBox; 032import javax.swing.JLabel; 033import javax.swing.JList; 034import javax.swing.JPanel; 035import javax.swing.JPopupMenu; 036import javax.swing.JScrollPane; 037import javax.swing.ListCellRenderer; 038import javax.swing.event.DocumentEvent; 039import javax.swing.event.DocumentListener; 040import javax.swing.event.ListSelectionEvent; 041import javax.swing.event.ListSelectionListener; 042 043import org.openstreetmap.josm.Main; 044import org.openstreetmap.josm.data.SelectionChangedListener; 045import org.openstreetmap.josm.data.osm.DataSet; 046import org.openstreetmap.josm.data.osm.OsmPrimitive; 047import org.openstreetmap.josm.data.preferences.BooleanProperty; 048import org.openstreetmap.josm.gui.tagging.TaggingPresetItems.Key; 049import org.openstreetmap.josm.gui.tagging.TaggingPresetItems.KeyedItem; 050import org.openstreetmap.josm.gui.tagging.TaggingPresetItems.Role; 051import org.openstreetmap.josm.gui.tagging.TaggingPresetItems.Roles; 052import org.openstreetmap.josm.gui.widgets.JosmTextField; 053import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher; 054import org.openstreetmap.josm.tools.Predicate; 055import org.openstreetmap.josm.tools.Utils; 056 057/** 058 * GUI component to select tagging preset: the list with filter and two checkboxes 059 * @since 6068 060 */ 061public class TaggingPresetSelector extends JPanel implements SelectionChangedListener { 062 063 private static final int CLASSIFICATION_IN_FAVORITES = 300; 064 private static final int CLASSIFICATION_NAME_MATCH = 300; 065 private static final int CLASSIFICATION_GROUP_MATCH = 200; 066 private static final int CLASSIFICATION_TAGS_MATCH = 100; 067 068 private static final BooleanProperty SEARCH_IN_TAGS = new BooleanProperty("taggingpreset.dialog.search-in-tags", true); 069 private static final BooleanProperty ONLY_APPLICABLE = new BooleanProperty("taggingpreset.dialog.only-applicable-to-selection", true); 070 071 private JosmTextField edSearchText; 072 private JList<TaggingPreset> lsResult; 073 private JCheckBox ckOnlyApplicable; 074 private JCheckBox ckSearchInTags; 075 private final EnumSet<TaggingPresetType> typesInSelection = EnumSet.noneOf(TaggingPresetType.class); 076 private boolean typesInSelectionDirty = true; 077 private final PresetClassifications classifications = new PresetClassifications(); 078 private ResultListModel lsResultModel = new ResultListModel(); 079 080 private ActionListener dblClickListener; 081 private ActionListener clickListener; 082 083 private static class ResultListCellRenderer implements ListCellRenderer<TaggingPreset> { 084 final DefaultListCellRenderer def = new DefaultListCellRenderer(); 085 @Override 086 public Component getListCellRendererComponent(JList<? extends TaggingPreset> list, TaggingPreset tp, int index, boolean isSelected, boolean cellHasFocus) { 087 JLabel result = (JLabel) def.getListCellRendererComponent(list, tp, index, isSelected, cellHasFocus); 088 result.setText(tp.getName()); 089 result.setIcon((Icon) tp.getValue(Action.SMALL_ICON)); 090 return result; 091 } 092 } 093 094 private static class ResultListModel extends AbstractListModel<TaggingPreset> { 095 096 private List<PresetClassification> presets = new ArrayList<>(); 097 098 public void setPresets(List<PresetClassification> presets) { 099 this.presets = presets; 100 fireContentsChanged(this, 0, Integer.MAX_VALUE); 101 } 102 103 public List<PresetClassification> getPresets() { 104 return presets; 105 } 106 107 @Override 108 public TaggingPreset getElementAt(int index) { 109 return presets.get(index).preset; 110 } 111 112 @Override 113 public int getSize() { 114 return presets.size(); 115 } 116 } 117 118 /** 119 * Computes the match ration of a {@link TaggingPreset} wrt. a searchString. 120 */ 121 static class PresetClassification implements Comparable<PresetClassification> { 122 public final TaggingPreset preset; 123 public int classification; 124 public int favoriteIndex; 125 private final Collection<String> groups = new HashSet<>(); 126 private final Collection<String> names = new HashSet<>(); 127 private final Collection<String> tags = new HashSet<>(); 128 129 PresetClassification(TaggingPreset preset) { 130 this.preset = preset; 131 TaggingPreset group = preset.group; 132 while (group != null) { 133 Collections.addAll(groups, group.getLocaleName().toLowerCase().split("\\s")); 134 group = group.group; 135 } 136 Collections.addAll(names, preset.getLocaleName().toLowerCase().split("\\s")); 137 for (TaggingPresetItem item: preset.data) { 138 if (item instanceof KeyedItem) { 139 tags.add(((KeyedItem) item).key); 140 if (item instanceof TaggingPresetItems.ComboMultiSelect) { 141 final TaggingPresetItems.ComboMultiSelect cms = (TaggingPresetItems.ComboMultiSelect) item; 142 if (Boolean.parseBoolean(cms.values_searchable)) { 143 tags.addAll(cms.getDisplayValues()); 144 } 145 } 146 if (item instanceof Key && ((Key) item).value != null) { 147 tags.add(((Key) item).value); 148 } 149 } else if (item instanceof Roles) { 150 for (Role role : ((Roles) item).roles) { 151 tags.add(role.key); 152 } 153 } 154 } 155 } 156 157 private int isMatching(Collection<String> values, String[] searchString) { 158 int sum = 0; 159 for (String word: searchString) { 160 boolean found = false; 161 boolean foundFirst = false; 162 for (String value: values) { 163 int index = value.toLowerCase().indexOf(word); 164 if (index == 0) { 165 foundFirst = true; 166 break; 167 } else if (index > 0) { 168 found = true; 169 } 170 } 171 if (foundFirst) { 172 sum += 2; 173 } else if (found) { 174 sum += 1; 175 } else 176 return 0; 177 } 178 return sum; 179 } 180 181 int isMatchingGroup(String[] words) { 182 return isMatching(groups, words); 183 } 184 185 int isMatchingName(String[] words) { 186 return isMatching(names, words); 187 } 188 189 int isMatchingTags(String[] words) { 190 return isMatching(tags, words); 191 } 192 193 @Override 194 public int compareTo(PresetClassification o) { 195 int result = o.classification - classification; 196 if (result == 0) 197 return preset.getName().compareTo(o.preset.getName()); 198 else 199 return result; 200 } 201 202 @Override 203 public String toString() { 204 return classification + " " + preset.toString(); 205 } 206 } 207 208 /** 209 * Constructs a new {@code TaggingPresetSelector}. 210 */ 211 public TaggingPresetSelector(boolean displayOnlyApplicable, boolean displaySearchInTags) { 212 super(new BorderLayout()); 213 classifications.loadPresets(TaggingPresets.getTaggingPresets()); 214 215 edSearchText = new JosmTextField(); 216 edSearchText.getDocument().addDocumentListener(new DocumentListener() { 217 @Override public void removeUpdate(DocumentEvent e) { filterPresets(); } 218 @Override public void insertUpdate(DocumentEvent e) { filterPresets(); } 219 @Override public void changedUpdate(DocumentEvent e) { filterPresets(); } 220 }); 221 edSearchText.addKeyListener(new KeyAdapter() { 222 @Override 223 public void keyPressed(KeyEvent e) { 224 switch (e.getKeyCode()) { 225 case KeyEvent.VK_DOWN: 226 selectPreset(lsResult.getSelectedIndex() + 1); 227 break; 228 case KeyEvent.VK_UP: 229 selectPreset(lsResult.getSelectedIndex() - 1); 230 break; 231 case KeyEvent.VK_PAGE_DOWN: 232 selectPreset(lsResult.getSelectedIndex() + 10); 233 break; 234 case KeyEvent.VK_PAGE_UP: 235 selectPreset(lsResult.getSelectedIndex() - 10); 236 break; 237 case KeyEvent.VK_HOME: 238 selectPreset(0); 239 break; 240 case KeyEvent.VK_END: 241 selectPreset(lsResultModel.getSize()); 242 break; 243 } 244 } 245 }); 246 add(edSearchText, BorderLayout.NORTH); 247 248 lsResult = new JList<>(); 249 lsResult.setModel(lsResultModel); 250 lsResult.setCellRenderer(new ResultListCellRenderer()); 251 lsResult.addMouseListener(new MouseAdapter() { 252 @Override 253 public void mouseClicked(MouseEvent e) { 254 if (e.getClickCount()>1) { 255 if (dblClickListener!=null) 256 dblClickListener.actionPerformed(null); 257 } else { 258 if (clickListener!=null) 259 clickListener.actionPerformed(null); 260 } 261 } 262 }); 263 add(new JScrollPane(lsResult), BorderLayout.CENTER); 264 265 JPanel pnChecks = new JPanel(); 266 pnChecks.setLayout(new BoxLayout(pnChecks, BoxLayout.Y_AXIS)); 267 268 if (displayOnlyApplicable) { 269 ckOnlyApplicable = new JCheckBox(); 270 ckOnlyApplicable.setText(tr("Show only applicable to selection")); 271 pnChecks.add(ckOnlyApplicable); 272 ckOnlyApplicable.addItemListener(new ItemListener() { 273 @Override 274 public void itemStateChanged(ItemEvent e) { 275 filterPresets(); 276 } 277 }); 278 } 279 280 if (displaySearchInTags) { 281 ckSearchInTags = new JCheckBox(); 282 ckSearchInTags.setText(tr("Search in tags")); 283 ckSearchInTags.setSelected(SEARCH_IN_TAGS.get()); 284 ckSearchInTags.addItemListener(new ItemListener() { 285 @Override 286 public void itemStateChanged(ItemEvent e) { 287 filterPresets(); 288 } 289 }); 290 pnChecks.add(ckSearchInTags); 291 } 292 293 add(pnChecks, BorderLayout.SOUTH); 294 295 setPreferredSize(new Dimension(400, 300)); 296 filterPresets(); 297 JPopupMenu popupMenu = new JPopupMenu(); 298 popupMenu.add(new AbstractAction(tr("Add toolbar button")) { 299 @Override 300 public void actionPerformed(ActionEvent ae) { 301 String res = getSelectedPreset().getToolbarString(); 302 Main.toolbar.addCustomButton(res, -1, false); 303 } 304 }); 305 lsResult.addMouseListener(new PopupMenuLauncher(popupMenu)); 306 } 307 308 private void selectPreset(int newIndex) { 309 if (newIndex < 0) { 310 newIndex = 0; 311 } 312 if (newIndex > lsResultModel.getSize() - 1) { 313 newIndex = lsResultModel.getSize() - 1; 314 } 315 lsResult.setSelectedIndex(newIndex); 316 lsResult.ensureIndexIsVisible(newIndex); 317 } 318 319 /** 320 * Search expression can be in form: "group1/group2/name" where names can contain multiple words 321 */ 322 private void filterPresets() { 323 //TODO Save favorites to file 324 String text = edSearchText.getText().toLowerCase(); 325 boolean onlyApplicable = ckOnlyApplicable != null && ckOnlyApplicable.isSelected(); 326 boolean inTags = ckSearchInTags != null && ckSearchInTags.isSelected(); 327 328 DataSet ds = Main.main.getCurrentDataSet(); 329 Collection<OsmPrimitive> selected = (ds==null)? Collections.<OsmPrimitive>emptyList() : ds.getSelected(); 330 final List<PresetClassification> result = classifications.getMatchingPresets( 331 text, onlyApplicable, inTags, getTypesInSelection(), selected); 332 333 lsResultModel.setPresets(result); 334 335 } 336 337 /** 338 * A collection of {@link PresetClassification}s with the functionality of filtering wrt. searchString. 339 */ 340 static class PresetClassifications implements Iterable<PresetClassification> { 341 342 private final List<PresetClassification> classifications = new ArrayList<>(); 343 344 public List<PresetClassification> getMatchingPresets(String searchText, boolean onlyApplicable, boolean inTags, EnumSet<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) { 345 final String[] groupWords; 346 final String[] nameWords; 347 348 if (searchText.contains("/")) { 349 groupWords = searchText.substring(0, searchText.lastIndexOf('/')).split("[\\s/]"); 350 nameWords = searchText.substring(searchText.indexOf('/') + 1).split("\\s"); 351 } else { 352 groupWords = null; 353 nameWords = searchText.split("\\s"); 354 } 355 356 return getMatchingPresets(groupWords, nameWords, onlyApplicable, inTags, presetTypes, selectedPrimitives); 357 } 358 359 public List<PresetClassification> getMatchingPresets(String[] groupWords, String[] nameWords, boolean onlyApplicable, boolean inTags, EnumSet<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) { 360 361 final List<PresetClassification> result = new ArrayList<>(); 362 for (PresetClassification presetClassification : classifications) { 363 TaggingPreset preset = presetClassification.preset; 364 presetClassification.classification = 0; 365 366 if (onlyApplicable) { 367 boolean suitable = preset.typeMatches(presetTypes); 368 369 if (!suitable && preset.types.contains(TaggingPresetType.RELATION) && preset.roles != null && !preset.roles.roles.isEmpty()) { 370 final Predicate<Role> memberExpressionMatchesOnePrimitive = new Predicate<Role>() { 371 372 @Override 373 public boolean evaluate(Role object) { 374 return object.memberExpression != null 375 && Utils.exists(selectedPrimitives, object.memberExpression); 376 } 377 }; 378 suitable = Utils.exists(preset.roles.roles, memberExpressionMatchesOnePrimitive); 379 // keep the preset to allow the creation of new relations 380 } 381 if (!suitable) { 382 continue; 383 } 384 } 385 386 if (groupWords != null && presetClassification.isMatchingGroup(groupWords) == 0) { 387 continue; 388 } 389 390 int matchName = presetClassification.isMatchingName(nameWords); 391 392 if (matchName == 0) { 393 if (groupWords == null) { 394 int groupMatch = presetClassification.isMatchingGroup(nameWords); 395 if (groupMatch > 0) { 396 presetClassification.classification = CLASSIFICATION_GROUP_MATCH + groupMatch; 397 } 398 } 399 if (presetClassification.classification == 0 && inTags) { 400 int tagsMatch = presetClassification.isMatchingTags(nameWords); 401 if (tagsMatch > 0) { 402 presetClassification.classification = CLASSIFICATION_TAGS_MATCH + tagsMatch; 403 } 404 } 405 } else { 406 presetClassification.classification = CLASSIFICATION_NAME_MATCH + matchName; 407 } 408 409 if (presetClassification.classification > 0) { 410 presetClassification.classification += presetClassification.favoriteIndex; 411 result.add(presetClassification); 412 } 413 } 414 415 Collections.sort(result); 416 return result; 417 418 } 419 420 public void clear() { 421 classifications.clear(); 422 } 423 424 public void loadPresets(Collection<TaggingPreset> presets) { 425 for (TaggingPreset preset : presets) { 426 if (preset instanceof TaggingPresetSeparator || preset instanceof TaggingPresetMenu) { 427 continue; 428 } 429 classifications.add(new PresetClassification(preset)); 430 } 431 } 432 433 @Override 434 public Iterator<PresetClassification> iterator() { 435 return classifications.iterator(); 436 } 437 } 438 439 private EnumSet<TaggingPresetType> getTypesInSelection() { 440 if (typesInSelectionDirty) { 441 synchronized (typesInSelection) { 442 typesInSelectionDirty = false; 443 typesInSelection.clear(); 444 if (Main.main==null || Main.main.getCurrentDataSet() == null) return typesInSelection; 445 for (OsmPrimitive primitive : Main.main.getCurrentDataSet().getSelected()) { 446 typesInSelection.add(TaggingPresetType.forPrimitive(primitive)); 447 } 448 } 449 } 450 return typesInSelection; 451 } 452 453 @Override 454 public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) { 455 typesInSelectionDirty = true; 456 } 457 458 public void init() { 459 if (ckOnlyApplicable != null) { 460 ckOnlyApplicable.setEnabled(!getTypesInSelection().isEmpty()); 461 ckOnlyApplicable.setSelected(!getTypesInSelection().isEmpty() && ONLY_APPLICABLE.get()); 462 } 463 edSearchText.setText(""); 464 filterPresets(); 465 } 466 467 public void init(Collection<TaggingPreset> presets) { 468 classifications.clear(); 469 classifications.loadPresets(presets); 470 init(); 471 } 472 473 public void clearSelection() { 474 lsResult.getSelectionModel().clearSelection(); 475 } 476 477 /** 478 * Save checkbox values in preferences for future reuse 479 */ 480 public void savePreferences() { 481 if (ckSearchInTags != null) { 482 SEARCH_IN_TAGS.put(ckSearchInTags.isSelected()); 483 } 484 if (ckOnlyApplicable != null && ckOnlyApplicable.isEnabled()) { 485 ONLY_APPLICABLE.put(ckOnlyApplicable.isSelected()); 486 } 487 } 488 489 /** 490 * Determines, which preset is selected at the current moment 491 * @return selected preset (as action) 492 */ 493 public TaggingPreset getSelectedPreset() { 494 List<PresetClassification> presets = lsResultModel.getPresets(); 495 if (presets.isEmpty()) return null; 496 int idx = lsResult.getSelectedIndex(); 497 if (idx == -1) { 498 idx = 0; 499 } 500 TaggingPreset preset = presets.get(idx).preset; 501 for (PresetClassification pc: classifications) { 502 if (pc.preset == preset) { 503 pc.favoriteIndex = CLASSIFICATION_IN_FAVORITES; 504 } else if (pc.favoriteIndex > 0) { 505 pc.favoriteIndex--; 506 } 507 } 508 return preset; 509 } 510 511 public void setSelectedPreset(TaggingPreset p) { 512 lsResult.setSelectedValue(p, true); 513 } 514 515 public int getItemCount() { 516 return lsResultModel.getSize(); 517 } 518 519 public void setDblClickListener(ActionListener dblClickListener) { 520 this.dblClickListener = dblClickListener; 521 } 522 523 public void setClickListener(ActionListener clickListener) { 524 this.clickListener = clickListener; 525 } 526 527 public void addSelectionListener(final ActionListener selectListener) { 528 lsResult.getSelectionModel().addListSelectionListener(new ListSelectionListener() { 529 @Override 530 public void valueChanged(ListSelectionEvent e) { 531 if (!e.getValueIsAdjusting()) 532 selectListener.actionPerformed(null); 533 } 534 }); 535 } 536}