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