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}