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