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}