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 * @param displayOnlyApplicable if {@code true} display "Show only applicable to selection" checkbox 218 * @param displaySearchInTags if {@code true} display "Search in tags" checkbox 219 */ 220 public TaggingPresetSelector(boolean displayOnlyApplicable, boolean displaySearchInTags) { 221 super(new BorderLayout()); 222 classifications.loadPresets(TaggingPresets.getTaggingPresets()); 223 224 edSearchText = new JosmTextField(); 225 edSearchText.getDocument().addDocumentListener(new DocumentListener() { 226 @Override 227 public void removeUpdate(DocumentEvent e) { 228 filterPresets(); 229 } 230 231 @Override 232 public void insertUpdate(DocumentEvent e) { 233 filterPresets(); 234 } 235 236 @Override 237 public void changedUpdate(DocumentEvent e) { 238 filterPresets(); 239 } 240 }); 241 edSearchText.addKeyListener(new KeyAdapter() { 242 @Override 243 public void keyPressed(KeyEvent e) { 244 switch (e.getKeyCode()) { 245 case KeyEvent.VK_DOWN: 246 selectPreset(lsResult.getSelectedIndex() + 1); 247 break; 248 case KeyEvent.VK_UP: 249 selectPreset(lsResult.getSelectedIndex() - 1); 250 break; 251 case KeyEvent.VK_PAGE_DOWN: 252 selectPreset(lsResult.getSelectedIndex() + 10); 253 break; 254 case KeyEvent.VK_PAGE_UP: 255 selectPreset(lsResult.getSelectedIndex() - 10); 256 break; 257 case KeyEvent.VK_HOME: 258 selectPreset(0); 259 break; 260 case KeyEvent.VK_END: 261 selectPreset(lsResultModel.getSize()); 262 break; 263 } 264 } 265 }); 266 add(edSearchText, BorderLayout.NORTH); 267 268 lsResult = new JList<>(lsResultModel); 269 lsResult.setCellRenderer(new ResultListCellRenderer()); 270 lsResult.addMouseListener(new MouseAdapter() { 271 @Override 272 public void mouseClicked(MouseEvent e) { 273 if (e.getClickCount() > 1) { 274 if (dblClickListener != null) 275 dblClickListener.actionPerformed(null); 276 } else { 277 if (clickListener != null) 278 clickListener.actionPerformed(null); 279 } 280 } 281 }); 282 add(new JScrollPane(lsResult), BorderLayout.CENTER); 283 284 JPanel pnChecks = new JPanel(); 285 pnChecks.setLayout(new BoxLayout(pnChecks, BoxLayout.Y_AXIS)); 286 287 if (displayOnlyApplicable) { 288 ckOnlyApplicable = new JCheckBox(); 289 ckOnlyApplicable.setText(tr("Show only applicable to selection")); 290 pnChecks.add(ckOnlyApplicable); 291 ckOnlyApplicable.addItemListener(new ItemListener() { 292 @Override 293 public void itemStateChanged(ItemEvent e) { 294 filterPresets(); 295 } 296 }); 297 } else { 298 ckOnlyApplicable = null; 299 } 300 301 if (displaySearchInTags) { 302 ckSearchInTags = new JCheckBox(); 303 ckSearchInTags.setText(tr("Search in tags")); 304 ckSearchInTags.setSelected(SEARCH_IN_TAGS.get()); 305 ckSearchInTags.addItemListener(new ItemListener() { 306 @Override 307 public void itemStateChanged(ItemEvent e) { 308 filterPresets(); 309 } 310 }); 311 pnChecks.add(ckSearchInTags); 312 } else { 313 ckSearchInTags = null; 314 } 315 316 add(pnChecks, BorderLayout.SOUTH); 317 318 setPreferredSize(new Dimension(400, 300)); 319 filterPresets(); 320 JPopupMenu popupMenu = new JPopupMenu(); 321 popupMenu.add(new AbstractAction(tr("Add toolbar button")) { 322 @Override 323 public void actionPerformed(ActionEvent ae) { 324 final TaggingPreset preset = lsResult.getSelectedValue(); 325 if (preset != null) { 326 Main.toolbar.addCustomButton(preset.getToolbarString(), -1, false); 327 } 328 } 329 }); 330 lsResult.addMouseListener(new PopupMenuLauncher(popupMenu)); 331 } 332 333 private synchronized void selectPreset(int newIndex) { 334 if (newIndex < 0) { 335 newIndex = 0; 336 } 337 if (newIndex > lsResultModel.getSize() - 1) { 338 newIndex = lsResultModel.getSize() - 1; 339 } 340 lsResult.setSelectedIndex(newIndex); 341 lsResult.ensureIndexIsVisible(newIndex); 342 } 343 344 /** 345 * Search expression can be in form: "group1/group2/name" where names can contain multiple words 346 */ 347 private synchronized void filterPresets() { 348 //TODO Save favorites to file 349 String text = edSearchText.getText().toLowerCase(Locale.ENGLISH); 350 boolean onlyApplicable = ckOnlyApplicable != null && ckOnlyApplicable.isSelected(); 351 boolean inTags = ckSearchInTags != null && ckSearchInTags.isSelected(); 352 353 DataSet ds = Main.main.getCurrentDataSet(); 354 Collection<OsmPrimitive> selected = (ds == null) ? Collections.<OsmPrimitive>emptyList() : ds.getSelected(); 355 final List<PresetClassification> result = classifications.getMatchingPresets( 356 text, onlyApplicable, inTags, getTypesInSelection(), selected); 357 358 final TaggingPreset oldPreset = lsResult.getSelectedValue(); 359 lsResultModel.setPresets(result); 360 final TaggingPreset newPreset = lsResult.getSelectedValue(); 361 if (!Objects.equals(oldPreset, newPreset)) { 362 int[] indices = lsResult.getSelectedIndices(); 363 for (ListSelectionListener listener : listSelectionListeners) { 364 listener.valueChanged(new ListSelectionEvent(lsResult, lsResult.getSelectedIndex(), 365 indices.length > 0 ? indices[indices.length-1] : -1, false)); 366 } 367 } 368 } 369 370 /** 371 * A collection of {@link PresetClassification}s with the functionality of filtering wrt. searchString. 372 */ 373 public static class PresetClassifications implements Iterable<PresetClassification> { 374 375 private final List<PresetClassification> classifications = new ArrayList<>(); 376 377 public List<PresetClassification> getMatchingPresets(String searchText, boolean onlyApplicable, boolean inTags, 378 Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) { 379 final String[] groupWords; 380 final String[] nameWords; 381 382 if (searchText.contains("/")) { 383 groupWords = searchText.substring(0, searchText.lastIndexOf('/')).split("[\\s/]"); 384 nameWords = searchText.substring(searchText.indexOf('/') + 1).split("\\s"); 385 } else { 386 groupWords = null; 387 nameWords = searchText.split("\\s"); 388 } 389 390 return getMatchingPresets(groupWords, nameWords, onlyApplicable, inTags, presetTypes, selectedPrimitives); 391 } 392 393 public List<PresetClassification> getMatchingPresets(String[] groupWords, String[] nameWords, boolean onlyApplicable, 394 boolean inTags, Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) { 395 396 final List<PresetClassification> result = new ArrayList<>(); 397 for (PresetClassification presetClassification : classifications) { 398 TaggingPreset preset = presetClassification.preset; 399 presetClassification.classification = 0; 400 401 if (onlyApplicable) { 402 boolean suitable = preset.typeMatches(presetTypes); 403 404 if (!suitable && preset.types.contains(TaggingPresetType.RELATION) 405 && preset.roles != null && !preset.roles.roles.isEmpty()) { 406 final Predicate<Role> memberExpressionMatchesOnePrimitive = new Predicate<Role>() { 407 @Override 408 public boolean evaluate(Role object) { 409 return object.memberExpression != null 410 && Utils.exists(selectedPrimitives, object.memberExpression); 411 } 412 }; 413 suitable = Utils.exists(preset.roles.roles, memberExpressionMatchesOnePrimitive); 414 // keep the preset to allow the creation of new relations 415 } 416 if (!suitable) { 417 continue; 418 } 419 } 420 421 if (groupWords != null && presetClassification.isMatchingGroup(groupWords) == 0) { 422 continue; 423 } 424 425 int matchName = presetClassification.isMatchingName(nameWords); 426 427 if (matchName == 0) { 428 if (groupWords == null) { 429 int groupMatch = presetClassification.isMatchingGroup(nameWords); 430 if (groupMatch > 0) { 431 presetClassification.classification = CLASSIFICATION_GROUP_MATCH + groupMatch; 432 } 433 } 434 if (presetClassification.classification == 0 && inTags) { 435 int tagsMatch = presetClassification.isMatchingTags(nameWords); 436 if (tagsMatch > 0) { 437 presetClassification.classification = CLASSIFICATION_TAGS_MATCH + tagsMatch; 438 } 439 } 440 } else { 441 presetClassification.classification = CLASSIFICATION_NAME_MATCH + matchName; 442 } 443 444 if (presetClassification.classification > 0) { 445 presetClassification.classification += presetClassification.favoriteIndex; 446 result.add(presetClassification); 447 } 448 } 449 450 Collections.sort(result); 451 return result; 452 453 } 454 455 public void clear() { 456 classifications.clear(); 457 } 458 459 public void loadPresets(Collection<TaggingPreset> presets) { 460 for (TaggingPreset preset : presets) { 461 if (preset instanceof TaggingPresetSeparator || preset instanceof TaggingPresetMenu) { 462 continue; 463 } 464 classifications.add(new PresetClassification(preset)); 465 } 466 } 467 468 @Override 469 public Iterator<PresetClassification> iterator() { 470 return classifications.iterator(); 471 } 472 } 473 474 private Set<TaggingPresetType> getTypesInSelection() { 475 if (typesInSelectionDirty) { 476 synchronized (typesInSelection) { 477 typesInSelectionDirty = false; 478 typesInSelection.clear(); 479 if (Main.main == null || Main.main.getCurrentDataSet() == null) return typesInSelection; 480 for (OsmPrimitive primitive : Main.main.getCurrentDataSet().getSelected()) { 481 typesInSelection.add(TaggingPresetType.forPrimitive(primitive)); 482 } 483 } 484 } 485 return typesInSelection; 486 } 487 488 @Override 489 public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) { 490 typesInSelectionDirty = true; 491 } 492 493 public synchronized void init() { 494 if (ckOnlyApplicable != null) { 495 ckOnlyApplicable.setEnabled(!getTypesInSelection().isEmpty()); 496 ckOnlyApplicable.setSelected(!getTypesInSelection().isEmpty() && ONLY_APPLICABLE.get()); 497 } 498 listSelectionListeners.clear(); 499 edSearchText.setText(""); 500 filterPresets(); 501 } 502 503 public void init(Collection<TaggingPreset> presets) { 504 classifications.clear(); 505 classifications.loadPresets(presets); 506 init(); 507 } 508 509 public synchronized void clearSelection() { 510 lsResult.getSelectionModel().clearSelection(); 511 } 512 513 /** 514 * Save checkbox values in preferences for future reuse 515 */ 516 public void savePreferences() { 517 if (ckSearchInTags != null) { 518 SEARCH_IN_TAGS.put(ckSearchInTags.isSelected()); 519 } 520 if (ckOnlyApplicable != null && ckOnlyApplicable.isEnabled()) { 521 ONLY_APPLICABLE.put(ckOnlyApplicable.isSelected()); 522 } 523 } 524 525 /** 526 * Determines, which preset is selected at the moment. Updates {@link PresetClassification#favoriteIndex}! 527 * @return selected preset (as action) 528 */ 529 public synchronized TaggingPreset getSelectedPreset() { 530 if (lsResultModel.isEmpty()) return null; 531 int idx = lsResult.getSelectedIndex(); 532 if (idx < 0 || idx >= lsResultModel.getSize()) { 533 idx = 0; 534 } 535 TaggingPreset preset = lsResultModel.getElementAt(idx); 536 for (PresetClassification pc: classifications) { 537 if (pc.preset == preset) { 538 pc.favoriteIndex = CLASSIFICATION_IN_FAVORITES; 539 } else if (pc.favoriteIndex > 0) { 540 pc.favoriteIndex--; 541 } 542 } 543 return preset; 544 } 545 546 public synchronized void setSelectedPreset(TaggingPreset p) { 547 lsResult.setSelectedValue(p, true); 548 } 549 550 public synchronized int getItemCount() { 551 return lsResultModel.getSize(); 552 } 553 554 public void setDblClickListener(ActionListener dblClickListener) { 555 this.dblClickListener = dblClickListener; 556 } 557 558 public void setClickListener(ActionListener clickListener) { 559 this.clickListener = clickListener; 560 } 561 562 /** 563 * Adds a selection listener to the presets list. 564 * @param selectListener The list selection listener 565 * @since 7412 566 */ 567 public synchronized void addSelectionListener(ListSelectionListener selectListener) { 568 lsResult.getSelectionModel().addListSelectionListener(selectListener); 569 listSelectionListeners.add(selectListener); 570 } 571 572 /** 573 * Removes a selection listener from the presets list. 574 * @param selectListener The list selection listener 575 * @since 7412 576 */ 577 public synchronized void removeSelectionListener(ListSelectionListener selectListener) { 578 listSelectionListeners.remove(selectListener); 579 lsResult.getSelectionModel().removeListSelectionListener(selectListener); 580 } 581}