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}