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