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.util.ArrayList;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.EnumSet;
014import java.util.HashSet;
015import java.util.Iterator;
016import java.util.List;
017import java.util.Locale;
018import java.util.Objects;
019import java.util.Set;
020
021import javax.swing.AbstractAction;
022import javax.swing.Action;
023import javax.swing.BoxLayout;
024import javax.swing.DefaultListCellRenderer;
025import javax.swing.Icon;
026import javax.swing.JCheckBox;
027import javax.swing.JLabel;
028import javax.swing.JList;
029import javax.swing.JPanel;
030import javax.swing.JPopupMenu;
031import javax.swing.ListCellRenderer;
032import javax.swing.event.ListSelectionEvent;
033import javax.swing.event.ListSelectionListener;
034
035import org.openstreetmap.josm.Main;
036import org.openstreetmap.josm.data.SelectionChangedListener;
037import org.openstreetmap.josm.data.osm.DataSet;
038import org.openstreetmap.josm.data.osm.OsmPrimitive;
039import org.openstreetmap.josm.data.preferences.BooleanProperty;
040import org.openstreetmap.josm.gui.tagging.presets.items.ComboMultiSelect;
041import org.openstreetmap.josm.gui.tagging.presets.items.Key;
042import org.openstreetmap.josm.gui.tagging.presets.items.KeyedItem;
043import org.openstreetmap.josm.gui.tagging.presets.items.Roles;
044import org.openstreetmap.josm.gui.tagging.presets.items.Roles.Role;
045import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
046import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel;
047import org.openstreetmap.josm.tools.Utils;
048
049/**
050 * GUI component to select tagging preset: the list with filter and two checkboxes
051 * @since 6068
052 */
053public class TaggingPresetSelector extends SearchTextResultListPanel<TaggingPreset> implements SelectionChangedListener {
054
055    private static final int CLASSIFICATION_IN_FAVORITES = 300;
056    private static final int CLASSIFICATION_NAME_MATCH = 300;
057    private static final int CLASSIFICATION_GROUP_MATCH = 200;
058    private static final int CLASSIFICATION_TAGS_MATCH = 100;
059
060    private static final BooleanProperty SEARCH_IN_TAGS = new BooleanProperty("taggingpreset.dialog.search-in-tags", true);
061    private static final BooleanProperty ONLY_APPLICABLE = new BooleanProperty("taggingpreset.dialog.only-applicable-to-selection", true);
062
063    private final JCheckBox ckOnlyApplicable;
064    private final JCheckBox ckSearchInTags;
065    private final Set<TaggingPresetType> typesInSelection = EnumSet.noneOf(TaggingPresetType.class);
066    private boolean typesInSelectionDirty = true;
067    private final transient PresetClassifications classifications = new PresetClassifications();
068
069    private static class ResultListCellRenderer implements ListCellRenderer<TaggingPreset> {
070        private final DefaultListCellRenderer def = new DefaultListCellRenderer();
071        @Override
072        public Component getListCellRendererComponent(JList<? extends TaggingPreset> list, TaggingPreset tp, int index,
073                boolean isSelected, boolean cellHasFocus) {
074            JLabel result = (JLabel) def.getListCellRendererComponent(list, tp, index, isSelected, cellHasFocus);
075            result.setText(tp.getName());
076            result.setIcon((Icon) tp.getValue(Action.SMALL_ICON));
077            return result;
078        }
079    }
080
081    /**
082     * Computes the match ration of a {@link TaggingPreset} wrt. a searchString.
083     */
084    public static class PresetClassification implements Comparable<PresetClassification> {
085        public final TaggingPreset preset;
086        public int classification;
087        public int favoriteIndex;
088        private final Collection<String> groups = new HashSet<>();
089        private final Collection<String> names = new HashSet<>();
090        private final Collection<String> tags = new HashSet<>();
091
092        PresetClassification(TaggingPreset preset) {
093            this.preset = preset;
094            TaggingPreset group = preset.group;
095            while (group != null) {
096                addLocaleNames(groups, group);
097                group = group.group;
098            }
099            addLocaleNames(names, preset);
100            for (TaggingPresetItem item: preset.data) {
101                if (item instanceof KeyedItem) {
102                    tags.add(((KeyedItem) item).key);
103                    if (item instanceof ComboMultiSelect) {
104                        final ComboMultiSelect cms = (ComboMultiSelect) item;
105                        if (Boolean.parseBoolean(cms.values_searchable)) {
106                            tags.addAll(cms.getDisplayValues());
107                        }
108                    }
109                    if (item instanceof Key && ((Key) item).value != null) {
110                        tags.add(((Key) item).value);
111                    }
112                } else if (item instanceof Roles) {
113                    for (Role role : ((Roles) item).roles) {
114                        tags.add(role.key);
115                    }
116                }
117            }
118        }
119
120        private static void addLocaleNames(Collection<String> collection, TaggingPreset preset) {
121            String locName = preset.getLocaleName();
122            if (locName != null) {
123                Collections.addAll(collection, locName.toLowerCase(Locale.ENGLISH).split("\\s"));
124            }
125        }
126
127        private static int isMatching(Collection<String> values, String ... searchString) {
128            int sum = 0;
129            for (String word: searchString) {
130                boolean found = false;
131                boolean foundFirst = false;
132                for (String value: values) {
133                    int index = value.toLowerCase(Locale.ENGLISH).indexOf(word);
134                    if (index == 0) {
135                        foundFirst = true;
136                        break;
137                    } else if (index > 0) {
138                        found = true;
139                    }
140                }
141                if (foundFirst) {
142                    sum += 2;
143                } else if (found) {
144                    sum += 1;
145                } else
146                    return 0;
147            }
148            return sum;
149        }
150
151        int isMatchingGroup(String ... words) {
152            return isMatching(groups, words);
153        }
154
155        int isMatchingName(String ... words) {
156            return isMatching(names, words);
157        }
158
159        int isMatchingTags(String ... words) {
160            return isMatching(tags, words);
161        }
162
163        @Override
164        public int compareTo(PresetClassification o) {
165            int result = o.classification - classification;
166            if (result == 0)
167                return preset.getName().compareTo(o.preset.getName());
168            else
169                return result;
170        }
171
172        @Override
173        public String toString() {
174            return Integer.toString(classification) + ' ' + preset;
175        }
176    }
177
178    /**
179     * Constructs a new {@code TaggingPresetSelector}.
180     * @param displayOnlyApplicable if {@code true} display "Show only applicable to selection" checkbox
181     * @param displaySearchInTags if {@code true} display "Search in tags" checkbox
182     */
183    public TaggingPresetSelector(boolean displayOnlyApplicable, boolean displaySearchInTags) {
184        super();
185        lsResult.setCellRenderer(new ResultListCellRenderer());
186        classifications.loadPresets(TaggingPresets.getTaggingPresets());
187
188        JPanel pnChecks = new JPanel();
189        pnChecks.setLayout(new BoxLayout(pnChecks, BoxLayout.Y_AXIS));
190
191        if (displayOnlyApplicable) {
192            ckOnlyApplicable = new JCheckBox();
193            ckOnlyApplicable.setText(tr("Show only applicable to selection"));
194            pnChecks.add(ckOnlyApplicable);
195            ckOnlyApplicable.addItemListener(e -> filterItems());
196        } else {
197            ckOnlyApplicable = null;
198        }
199
200        if (displaySearchInTags) {
201            ckSearchInTags = new JCheckBox();
202            ckSearchInTags.setText(tr("Search in tags"));
203            ckSearchInTags.setSelected(SEARCH_IN_TAGS.get());
204            ckSearchInTags.addItemListener(e -> filterItems());
205            pnChecks.add(ckSearchInTags);
206        } else {
207            ckSearchInTags = null;
208        }
209
210        add(pnChecks, BorderLayout.SOUTH);
211
212        setPreferredSize(new Dimension(400, 300));
213        filterItems();
214        JPopupMenu popupMenu = new JPopupMenu();
215        popupMenu.add(new AbstractAction(tr("Add toolbar button")) {
216            @Override
217            public void actionPerformed(ActionEvent ae) {
218                final TaggingPreset preset = getSelectedPreset();
219                if (preset != null) {
220                    Main.toolbar.addCustomButton(preset.getToolbarString(), -1, false);
221                }
222            }
223        });
224        lsResult.addMouseListener(new PopupMenuLauncher(popupMenu));
225    }
226
227    /**
228     * Search expression can be in form: "group1/group2/name" where names can contain multiple words
229     */
230    @Override
231    protected synchronized void filterItems() {
232        //TODO Save favorites to file
233        String text = edSearchText.getText().toLowerCase(Locale.ENGLISH);
234        boolean onlyApplicable = ckOnlyApplicable != null && ckOnlyApplicable.isSelected();
235        boolean inTags = ckSearchInTags != null && ckSearchInTags.isSelected();
236
237        DataSet ds = Main.getLayerManager().getEditDataSet();
238        Collection<OsmPrimitive> selected = (ds == null) ? Collections.<OsmPrimitive>emptyList() : ds.getSelected();
239        final List<PresetClassification> result = classifications.getMatchingPresets(
240                text, onlyApplicable, inTags, getTypesInSelection(), selected);
241
242        final TaggingPreset oldPreset = getSelectedPreset();
243        lsResultModel.setItems(Utils.transform(result, x -> x.preset));
244        final TaggingPreset newPreset = getSelectedPreset();
245        if (!Objects.equals(oldPreset, newPreset)) {
246            int[] indices = lsResult.getSelectedIndices();
247            for (ListSelectionListener listener : listSelectionListeners) {
248                listener.valueChanged(new ListSelectionEvent(lsResult, lsResult.getSelectedIndex(),
249                        indices.length > 0 ? indices[indices.length-1] : -1, false));
250            }
251        }
252    }
253
254    /**
255     * A collection of {@link PresetClassification}s with the functionality of filtering wrt. searchString.
256     */
257    public static class PresetClassifications implements Iterable<PresetClassification> {
258
259        private final List<PresetClassification> classifications = new ArrayList<>();
260
261        public List<PresetClassification> getMatchingPresets(String searchText, boolean onlyApplicable, boolean inTags,
262                Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) {
263            final String[] groupWords;
264            final String[] nameWords;
265
266            if (searchText.contains("/")) {
267                groupWords = searchText.substring(0, searchText.lastIndexOf('/')).split("[\\s/]");
268                nameWords = searchText.substring(searchText.indexOf('/') + 1).split("\\s");
269            } else {
270                groupWords = null;
271                nameWords = searchText.split("\\s");
272            }
273
274            return getMatchingPresets(groupWords, nameWords, onlyApplicable, inTags, presetTypes, selectedPrimitives);
275        }
276
277        public List<PresetClassification> getMatchingPresets(String[] groupWords, String[] nameWords, boolean onlyApplicable,
278                boolean inTags, Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) {
279
280            final List<PresetClassification> result = new ArrayList<>();
281            for (PresetClassification presetClassification : classifications) {
282                TaggingPreset preset = presetClassification.preset;
283                presetClassification.classification = 0;
284
285                if (onlyApplicable) {
286                    boolean suitable = preset.typeMatches(presetTypes);
287
288                    if (!suitable && preset.types.contains(TaggingPresetType.RELATION)
289                            && preset.roles != null && !preset.roles.roles.isEmpty()) {
290                        suitable = preset.roles.roles.stream().anyMatch(
291                                object -> object.memberExpression != null && selectedPrimitives.stream().anyMatch(object.memberExpression));
292                        // keep the preset to allow the creation of new relations
293                    }
294                    if (!suitable) {
295                        continue;
296                    }
297                }
298
299                if (groupWords != null && presetClassification.isMatchingGroup(groupWords) == 0) {
300                    continue;
301                }
302
303                int matchName = presetClassification.isMatchingName(nameWords);
304
305                if (matchName == 0) {
306                    if (groupWords == null) {
307                        int groupMatch = presetClassification.isMatchingGroup(nameWords);
308                        if (groupMatch > 0) {
309                            presetClassification.classification = CLASSIFICATION_GROUP_MATCH + groupMatch;
310                        }
311                    }
312                    if (presetClassification.classification == 0 && inTags) {
313                        int tagsMatch = presetClassification.isMatchingTags(nameWords);
314                        if (tagsMatch > 0) {
315                            presetClassification.classification = CLASSIFICATION_TAGS_MATCH + tagsMatch;
316                        }
317                    }
318                } else {
319                    presetClassification.classification = CLASSIFICATION_NAME_MATCH + matchName;
320                }
321
322                if (presetClassification.classification > 0) {
323                    presetClassification.classification += presetClassification.favoriteIndex;
324                    result.add(presetClassification);
325                }
326            }
327
328            Collections.sort(result);
329            return result;
330
331        }
332
333        public void clear() {
334            classifications.clear();
335        }
336
337        public void loadPresets(Collection<TaggingPreset> presets) {
338            for (TaggingPreset preset : presets) {
339                if (preset instanceof TaggingPresetSeparator || preset instanceof TaggingPresetMenu) {
340                    continue;
341                }
342                classifications.add(new PresetClassification(preset));
343            }
344        }
345
346        @Override
347        public Iterator<PresetClassification> iterator() {
348            return classifications.iterator();
349        }
350    }
351
352    private Set<TaggingPresetType> getTypesInSelection() {
353        if (typesInSelectionDirty) {
354            synchronized (typesInSelection) {
355                typesInSelectionDirty = false;
356                typesInSelection.clear();
357                if (Main.main == null || Main.getLayerManager().getEditDataSet() == null) return typesInSelection;
358                for (OsmPrimitive primitive : Main.getLayerManager().getEditDataSet().getSelected()) {
359                    typesInSelection.add(TaggingPresetType.forPrimitive(primitive));
360                }
361            }
362        }
363        return typesInSelection;
364    }
365
366    @Override
367    public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) {
368        typesInSelectionDirty = true;
369    }
370
371    @Override
372    public synchronized void init() {
373        if (ckOnlyApplicable != null) {
374            ckOnlyApplicable.setEnabled(!getTypesInSelection().isEmpty());
375            ckOnlyApplicable.setSelected(!getTypesInSelection().isEmpty() && ONLY_APPLICABLE.get());
376        }
377        super.init();
378    }
379
380    public void init(Collection<TaggingPreset> presets) {
381        classifications.clear();
382        classifications.loadPresets(presets);
383        init();
384    }
385
386    /**
387     * Save checkbox values in preferences for future reuse
388     */
389    public void savePreferences() {
390        if (ckSearchInTags != null) {
391            SEARCH_IN_TAGS.put(ckSearchInTags.isSelected());
392        }
393        if (ckOnlyApplicable != null && ckOnlyApplicable.isEnabled()) {
394            ONLY_APPLICABLE.put(ckOnlyApplicable.isSelected());
395        }
396    }
397
398    /**
399     * Determines, which preset is selected at the moment.
400     * @return selected preset (as action)
401     */
402    public synchronized TaggingPreset getSelectedPreset() {
403        if (lsResultModel.isEmpty()) return null;
404        int idx = lsResult.getSelectedIndex();
405        if (idx < 0 || idx >= lsResultModel.getSize()) {
406            idx = 0;
407        }
408        return lsResultModel.getElementAt(idx);
409    }
410
411    /**
412     * Determines, which preset is selected at the moment. Updates {@link PresetClassification#favoriteIndex}!
413     * @return selected preset (as action)
414     */
415    public synchronized TaggingPreset getSelectedPresetAndUpdateClassification() {
416        final TaggingPreset preset = getSelectedPreset();
417        for (PresetClassification pc: classifications) {
418            if (pc.preset == preset) {
419                pc.favoriteIndex = CLASSIFICATION_IN_FAVORITES;
420            } else if (pc.favoriteIndex > 0) {
421                pc.favoriteIndex--;
422            }
423        }
424        return preset;
425    }
426
427    public synchronized void setSelectedPreset(TaggingPreset p) {
428        lsResult.setSelectedValue(p, true);
429    }
430}