001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs.properties;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.BorderLayout;
008import java.awt.Component;
009import java.awt.Container;
010import java.awt.Cursor;
011import java.awt.Dimension;
012import java.awt.FlowLayout;
013import java.awt.Font;
014import java.awt.GridBagConstraints;
015import java.awt.GridBagLayout;
016import java.awt.datatransfer.Clipboard;
017import java.awt.datatransfer.Transferable;
018import java.awt.event.ActionEvent;
019import java.awt.event.FocusAdapter;
020import java.awt.event.FocusEvent;
021import java.awt.event.InputEvent;
022import java.awt.event.KeyEvent;
023import java.awt.event.MouseAdapter;
024import java.awt.event.MouseEvent;
025import java.awt.event.WindowAdapter;
026import java.awt.event.WindowEvent;
027import java.awt.image.BufferedImage;
028import java.text.Normalizer;
029import java.util.ArrayList;
030import java.util.Arrays;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.Comparator;
034import java.util.HashMap;
035import java.util.Iterator;
036import java.util.List;
037import java.util.Map;
038import java.util.Objects;
039import java.util.TreeMap;
040
041import javax.swing.AbstractAction;
042import javax.swing.Action;
043import javax.swing.Box;
044import javax.swing.ButtonGroup;
045import javax.swing.DefaultListCellRenderer;
046import javax.swing.ImageIcon;
047import javax.swing.JCheckBoxMenuItem;
048import javax.swing.JComponent;
049import javax.swing.JLabel;
050import javax.swing.JList;
051import javax.swing.JMenu;
052import javax.swing.JOptionPane;
053import javax.swing.JPanel;
054import javax.swing.JPopupMenu;
055import javax.swing.JRadioButtonMenuItem;
056import javax.swing.JTable;
057import javax.swing.KeyStroke;
058import javax.swing.ListCellRenderer;
059import javax.swing.SwingUtilities;
060import javax.swing.table.DefaultTableModel;
061import javax.swing.text.JTextComponent;
062
063import org.openstreetmap.josm.Main;
064import org.openstreetmap.josm.actions.JosmAction;
065import org.openstreetmap.josm.actions.search.SearchAction;
066import org.openstreetmap.josm.actions.search.SearchCompiler;
067import org.openstreetmap.josm.command.ChangePropertyCommand;
068import org.openstreetmap.josm.command.Command;
069import org.openstreetmap.josm.command.SequenceCommand;
070import org.openstreetmap.josm.data.osm.OsmPrimitive;
071import org.openstreetmap.josm.data.osm.Tag;
072import org.openstreetmap.josm.data.preferences.BooleanProperty;
073import org.openstreetmap.josm.data.preferences.CollectionProperty;
074import org.openstreetmap.josm.data.preferences.EnumProperty;
075import org.openstreetmap.josm.data.preferences.IntegerProperty;
076import org.openstreetmap.josm.data.preferences.StringProperty;
077import org.openstreetmap.josm.gui.ExtendedDialog;
078import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils;
079import org.openstreetmap.josm.gui.mappaint.MapPaintStyles;
080import org.openstreetmap.josm.gui.tagging.ac.AutoCompletingComboBox;
081import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionListItem;
082import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionManager;
083import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset;
084import org.openstreetmap.josm.gui.tagging.presets.TaggingPresets;
085import org.openstreetmap.josm.gui.util.GuiHelper;
086import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
087import org.openstreetmap.josm.io.XmlWriter;
088import org.openstreetmap.josm.tools.GBC;
089import org.openstreetmap.josm.tools.Shortcut;
090import org.openstreetmap.josm.tools.Utils;
091import org.openstreetmap.josm.tools.WindowGeometry;
092
093/**
094 * Class that helps PropertiesDialog add and edit tag values.
095 * @since 5633
096 */
097public class TagEditHelper {
098
099    private final JTable tagTable;
100    private final DefaultTableModel tagData;
101    private final Map<String, Map<String, Integer>> valueCount;
102
103    // Selection that we are editing by using both dialogs
104    protected Collection<OsmPrimitive> sel;
105
106    private String changedKey;
107    private String objKey;
108
109    private final Comparator<AutoCompletionListItem> defaultACItemComparator =
110            (o1, o2) -> String.CASE_INSENSITIVE_ORDER.compare(o1.getValue(), o2.getValue());
111
112    private String lastAddKey;
113    private String lastAddValue;
114
115    /** Default number of recent tags */
116    public static final int DEFAULT_LRU_TAGS_NUMBER = 5;
117    /** Maximum number of recent tags */
118    public static final int MAX_LRU_TAGS_NUMBER = 30;
119
120    /** Use English language for tag by default */
121    public static final BooleanProperty PROPERTY_FIX_TAG_LOCALE = new BooleanProperty("properties.fix-tag-combobox-locale", false);
122    /** Whether recent tags must be remembered */
123    public static final BooleanProperty PROPERTY_REMEMBER_TAGS = new BooleanProperty("properties.remember-recently-added-tags", true);
124    /** Number of recent tags */
125    public static final IntegerProperty PROPERTY_RECENT_TAGS_NUMBER = new IntegerProperty("properties.recently-added-tags",
126            DEFAULT_LRU_TAGS_NUMBER);
127    /** The preference storage of recent tags */
128    public static final CollectionProperty PROPERTY_RECENT_TAGS = new CollectionProperty("properties.recent-tags",
129            Collections.<String>emptyList());
130    public static final StringProperty PROPERTY_TAGS_TO_IGNORE = new StringProperty("properties.recent-tags.ignore",
131            new SearchAction.SearchSetting().writeToString());
132
133    /**
134     * What to do with recent tags where keys already exist
135     */
136    private enum RecentExisting {
137        ENABLE,
138        DISABLE,
139        HIDE
140    }
141
142    /**
143     * Preference setting for popup menu item "Recent tags with existing key"
144     */
145    public static final EnumProperty<RecentExisting> PROPERTY_RECENT_EXISTING = new EnumProperty<>(
146        "properties.recently-added-tags-existing-key", RecentExisting.class, RecentExisting.DISABLE);
147
148    /**
149     * What to do after applying tag
150     */
151    private enum RefreshRecent {
152        NO,
153        STATUS,
154        REFRESH
155    }
156
157    /**
158     * Preference setting for popup menu item "Refresh recent tags list after applying tag"
159     */
160    public static final EnumProperty<RefreshRecent> PROPERTY_REFRESH_RECENT = new EnumProperty<>(
161        "properties.refresh-recently-added-tags", RefreshRecent.class, RefreshRecent.STATUS);
162
163    final RecentTagCollection recentTags = new RecentTagCollection(MAX_LRU_TAGS_NUMBER);
164    SearchAction.SearchSetting tagsToIgnore;
165
166    // Copy of recently added tags, used to cache initial status
167    private List<Tag> tags;
168
169    /**
170     * Constructs a new {@code TagEditHelper}.
171     * @param tagTable tag table
172     * @param propertyData table model
173     * @param valueCount tag value count
174     */
175    public TagEditHelper(JTable tagTable, DefaultTableModel propertyData, Map<String, Map<String, Integer>> valueCount) {
176        this.tagTable = tagTable;
177        this.tagData = propertyData;
178        this.valueCount = valueCount;
179    }
180
181    /**
182     * Finds the key from given row of tag editor.
183     * @param viewRow index of row
184     * @return key of tag
185     */
186    public final String getDataKey(int viewRow) {
187        return tagData.getValueAt(tagTable.convertRowIndexToModel(viewRow), 0).toString();
188    }
189
190    /**
191     * Finds the values from given row of tag editor.
192     * @param viewRow index of row
193     * @return map of values and number of occurrences
194     */
195    @SuppressWarnings("unchecked")
196    public final Map<String, Integer> getDataValues(int viewRow) {
197        return (Map<String, Integer>) tagData.getValueAt(tagTable.convertRowIndexToModel(viewRow), 1);
198    }
199
200    /**
201     * Open the add selection dialog and add a new key/value to the table (and
202     * to the dataset, of course).
203     */
204    public void addTag() {
205        changedKey = null;
206        sel = Main.main.getInProgressSelection();
207        if (sel == null || sel.isEmpty())
208            return;
209
210        final AddTagsDialog addDialog = getAddTagsDialog();
211
212        addDialog.showDialog();
213
214        addDialog.destroyActions();
215        if (addDialog.getValue() == 1)
216            addDialog.performTagAdding();
217        else
218            addDialog.undoAllTagsAdding();
219    }
220
221    protected AddTagsDialog getAddTagsDialog() {
222        return new AddTagsDialog();
223    }
224
225    /**
226    * Edit the value in the tags table row.
227    * @param row The row of the table from which the value is edited.
228    * @param focusOnKey Determines if the initial focus should be set on key instead of value
229    * @since 5653
230    */
231    public void editTag(final int row, boolean focusOnKey) {
232        changedKey = null;
233        sel = Main.main.getInProgressSelection();
234        if (sel == null || sel.isEmpty())
235            return;
236
237        String key = getDataKey(row);
238        objKey = key;
239
240        final IEditTagDialog editDialog = getEditTagDialog(row, focusOnKey, key);
241        editDialog.showDialog();
242        if (editDialog.getValue() != 1)
243            return;
244        editDialog.performTagEdit();
245    }
246
247    protected interface IEditTagDialog {
248        ExtendedDialog showDialog();
249
250        int getValue();
251
252        void performTagEdit();
253    }
254
255    protected IEditTagDialog getEditTagDialog(int row, boolean focusOnKey, String key) {
256        return new EditTagDialog(key, getDataValues(row), focusOnKey);
257    }
258
259    /**
260     * If during last editProperty call user changed the key name, this key will be returned
261     * Elsewhere, returns null.
262     * @return The modified key, or {@code null}
263     */
264    public String getChangedKey() {
265        return changedKey;
266    }
267
268    /**
269     * Reset last changed key.
270     */
271    public void resetChangedKey() {
272        changedKey = null;
273    }
274
275    /**
276     * For a given key k, return a list of keys which are used as keys for
277     * auto-completing values to increase the search space.
278     * @param key the key k
279     * @return a list of keys
280     */
281    private static List<String> getAutocompletionKeys(String key) {
282        if ("name".equals(key) || "addr:street".equals(key))
283            return Arrays.asList("addr:street", "name");
284        else
285            return Arrays.asList(key);
286    }
287
288    /**
289     * Load recently used tags from preferences if needed.
290     */
291    public void loadTagsIfNeeded() {
292        loadTagsToIgnore();
293        if (PROPERTY_REMEMBER_TAGS.get() && recentTags.isEmpty()) {
294            recentTags.loadFromPreference(PROPERTY_RECENT_TAGS);
295        }
296    }
297
298    void loadTagsToIgnore() {
299        final SearchAction.SearchSetting searchSetting = Utils.firstNonNull(
300                SearchAction.SearchSetting.readFromString(PROPERTY_TAGS_TO_IGNORE.get()), new SearchAction.SearchSetting());
301        if (!Objects.equals(tagsToIgnore, searchSetting)) {
302            try {
303                tagsToIgnore = searchSetting;
304                recentTags.setTagsToIgnore(tagsToIgnore);
305            } catch (SearchCompiler.ParseError parseError) {
306                warnAboutParseError(parseError);
307                tagsToIgnore = new SearchAction.SearchSetting();
308                recentTags.setTagsToIgnore(SearchCompiler.Never.INSTANCE);
309            }
310        }
311    }
312
313    private static void warnAboutParseError(SearchCompiler.ParseError parseError) {
314        Main.warn(parseError);
315        JOptionPane.showMessageDialog(
316                Main.parent,
317                parseError.getMessage(),
318                tr("Error"),
319                JOptionPane.ERROR_MESSAGE
320        );
321    }
322
323    /**
324     * Store recently used tags in preferences if needed.
325     */
326    public void saveTagsIfNeeded() {
327        if (PROPERTY_REMEMBER_TAGS.get() && !recentTags.isEmpty()) {
328            recentTags.saveToPreference(PROPERTY_RECENT_TAGS);
329        }
330    }
331
332    /**
333     * Update cache of recent tags used for displaying tags.
334     */
335    private void cacheRecentTags() {
336        tags = recentTags.toList();
337    }
338
339    /**
340     * Warns user about a key being overwritten.
341     * @param action The action done by the user. Must state what key is changed
342     * @param togglePref  The preference to save the checkbox state to
343     * @return {@code true} if the user accepts to overwrite key, {@code false} otherwise
344     */
345    private static boolean warnOverwriteKey(String action, String togglePref) {
346        ExtendedDialog ed = new ExtendedDialog(
347                Main.parent,
348                tr("Overwrite key"),
349                new String[]{tr("Replace"), tr("Cancel")});
350        ed.setButtonIcons(new String[]{"purge", "cancel"});
351        ed.setContent(action+'\n'+ tr("The new key is already used, overwrite values?"));
352        ed.setCancelButton(2);
353        ed.toggleEnable(togglePref);
354        ed.showDialog();
355
356        return ed.getValue() == 1;
357    }
358
359    protected class EditTagDialog extends AbstractTagsDialog implements IEditTagDialog {
360        private final String key;
361        private final transient Map<String, Integer> m;
362        private final transient Comparator<AutoCompletionListItem> usedValuesAwareComparator;
363
364        private final transient ListCellRenderer<AutoCompletionListItem> cellRenderer = new ListCellRenderer<AutoCompletionListItem>() {
365            private final DefaultListCellRenderer def = new DefaultListCellRenderer();
366            @Override
367            public Component getListCellRendererComponent(JList<? extends AutoCompletionListItem> list,
368                    AutoCompletionListItem value, int index, boolean isSelected, boolean cellHasFocus) {
369                Component c = def.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
370                if (c instanceof JLabel) {
371                    String str = value.getValue();
372                    if (valueCount.containsKey(objKey)) {
373                        Map<String, Integer> map = valueCount.get(objKey);
374                        if (map.containsKey(str)) {
375                            str = tr("{0} ({1})", str, map.get(str));
376                            c.setFont(c.getFont().deriveFont(Font.ITALIC + Font.BOLD));
377                        }
378                    }
379                    ((JLabel) c).setText(str);
380                }
381                return c;
382            }
383        };
384
385        protected EditTagDialog(String key, Map<String, Integer> map, final boolean initialFocusOnKey) {
386            super(Main.parent, trn("Change value?", "Change values?", map.size()), new String[] {tr("OK"), tr("Cancel")});
387            setButtonIcons(new String[] {"ok", "cancel"});
388            setCancelButton(2);
389            configureContextsensitiveHelp("/Dialog/EditValue", true /* show help button */);
390            this.key = key;
391            this.m = map;
392
393            usedValuesAwareComparator = (o1, o2) -> {
394                boolean c1 = m.containsKey(o1.getValue());
395                boolean c2 = m.containsKey(o2.getValue());
396                if (c1 == c2)
397                    return String.CASE_INSENSITIVE_ORDER.compare(o1.getValue(), o2.getValue());
398                else if (c1)
399                    return -1;
400                else
401                    return +1;
402            };
403
404            JPanel mainPanel = new JPanel(new BorderLayout());
405
406            String msg = "<html>"+trn("This will change {0} object.",
407                    "This will change up to {0} objects.", sel.size(), sel.size())
408                    +"<br><br>("+tr("An empty value deletes the tag.", key)+")</html>";
409
410            mainPanel.add(new JLabel(msg), BorderLayout.NORTH);
411
412            JPanel p = new JPanel(new GridBagLayout());
413            mainPanel.add(p, BorderLayout.CENTER);
414
415            AutoCompletionManager autocomplete = Main.getLayerManager().getEditLayer().data.getAutoCompletionManager();
416            List<AutoCompletionListItem> keyList = autocomplete.getKeys();
417            keyList.sort(defaultACItemComparator);
418
419            keys = new AutoCompletingComboBox(key);
420            keys.setPossibleACItems(keyList);
421            keys.setEditable(true);
422            keys.setSelectedItem(key);
423
424            p.add(Box.createVerticalStrut(5), GBC.eol());
425            p.add(new JLabel(tr("Key")), GBC.std());
426            p.add(Box.createHorizontalStrut(10), GBC.std());
427            p.add(keys, GBC.eol().fill(GBC.HORIZONTAL));
428
429            List<AutoCompletionListItem> valueList = autocomplete.getValues(getAutocompletionKeys(key));
430            valueList.sort(usedValuesAwareComparator);
431
432            final String selection = m.size() != 1 ? tr("<different>") : m.entrySet().iterator().next().getKey();
433
434            values = new AutoCompletingComboBox(selection);
435            values.setRenderer(cellRenderer);
436
437            values.setEditable(true);
438            values.setPossibleACItems(valueList);
439            values.setSelectedItem(selection);
440            values.getEditor().setItem(selection);
441            p.add(Box.createVerticalStrut(5), GBC.eol());
442            p.add(new JLabel(tr("Value")), GBC.std());
443            p.add(Box.createHorizontalStrut(10), GBC.std());
444            p.add(values, GBC.eol().fill(GBC.HORIZONTAL));
445            values.getEditor().addActionListener(e -> buttonAction(0, null));
446            addFocusAdapter(autocomplete, usedValuesAwareComparator);
447
448            setContent(mainPanel, false);
449
450            addWindowListener(new WindowAdapter() {
451                @Override
452                public void windowOpened(WindowEvent e) {
453                    if (initialFocusOnKey) {
454                        selectKeysComboBox();
455                    } else {
456                        selectValuesCombobox();
457                    }
458                }
459            });
460        }
461
462        /**
463         * Edit tags of multiple selected objects according to selected ComboBox values
464         * If value == "", tag will be deleted
465         * Confirmations may be needed.
466         */
467        @Override
468        public void performTagEdit() {
469            String value = Tag.removeWhiteSpaces(values.getEditor().getItem().toString());
470            value = Normalizer.normalize(value, Normalizer.Form.NFC);
471            if (value.isEmpty()) {
472                value = null; // delete the key
473            }
474            String newkey = Tag.removeWhiteSpaces(keys.getEditor().getItem().toString());
475            newkey = Normalizer.normalize(newkey, Normalizer.Form.NFC);
476            if (newkey.isEmpty()) {
477                newkey = key;
478                value = null; // delete the key instead
479            }
480            if (key.equals(newkey) && tr("<different>").equals(value))
481                return;
482            if (key.equals(newkey) || value == null) {
483                Main.main.undoRedo.add(new ChangePropertyCommand(sel, newkey, value));
484                AutoCompletionManager.rememberUserInput(newkey, value, true);
485            } else {
486                for (OsmPrimitive osm: sel) {
487                    if (osm.get(newkey) != null) {
488                        if (!warnOverwriteKey(tr("You changed the key from ''{0}'' to ''{1}''.", key, newkey),
489                                "overwriteEditKey"))
490                            return;
491                        break;
492                    }
493                }
494                Collection<Command> commands = new ArrayList<>();
495                commands.add(new ChangePropertyCommand(sel, key, null));
496                if (value.equals(tr("<different>"))) {
497                    Map<String, List<OsmPrimitive>> map = new HashMap<>();
498                    for (OsmPrimitive osm: sel) {
499                        String val = osm.get(key);
500                        if (val != null) {
501                            if (map.containsKey(val)) {
502                                map.get(val).add(osm);
503                            } else {
504                                List<OsmPrimitive> v = new ArrayList<>();
505                                v.add(osm);
506                                map.put(val, v);
507                            }
508                        }
509                    }
510                    for (Map.Entry<String, List<OsmPrimitive>> e: map.entrySet()) {
511                        commands.add(new ChangePropertyCommand(e.getValue(), newkey, e.getKey()));
512                    }
513                } else {
514                    commands.add(new ChangePropertyCommand(sel, newkey, value));
515                    AutoCompletionManager.rememberUserInput(newkey, value, false);
516                }
517                Main.main.undoRedo.add(new SequenceCommand(
518                        trn("Change properties of up to {0} object",
519                                "Change properties of up to {0} objects", sel.size(), sel.size()),
520                                commands));
521            }
522
523            changedKey = newkey;
524        }
525    }
526
527    protected abstract class AbstractTagsDialog extends ExtendedDialog {
528        protected AutoCompletingComboBox keys;
529        protected AutoCompletingComboBox values;
530
531        AbstractTagsDialog(Component parent, String title, String ... buttonTexts) {
532            super(parent, title, buttonTexts);
533            addMouseListener(new PopupMenuLauncher(popupMenu));
534        }
535
536        @Override
537        public void setupDialog() {
538            super.setupDialog();
539            final Dimension size = getSize();
540            // Set resizable only in width
541            setMinimumSize(size);
542            setPreferredSize(size);
543            // setMaximumSize does not work, and never worked, but still it seems not to bother Oracle to fix this 10-year-old bug
544            // https://bugs.openjdk.java.net/browse/JDK-6200438
545            // https://bugs.openjdk.java.net/browse/JDK-6464548
546
547            setRememberWindowGeometry(getClass().getName() + ".geometry",
548                WindowGeometry.centerInWindow(Main.parent, size));
549        }
550
551        @Override
552        public void setVisible(boolean visible) {
553            // Do not want dialog to be resizable in height, as its size may increase each time because of the recently added tags
554            // So need to modify the stored geometry (size part only) in order to use the automatic positioning mechanism
555            if (visible) {
556                WindowGeometry geometry = initWindowGeometry();
557                Dimension storedSize = geometry.getSize();
558                Dimension size = getSize();
559                if (!storedSize.equals(size)) {
560                    if (storedSize.width < size.width) {
561                        storedSize.width = size.width;
562                    }
563                    if (storedSize.height != size.height) {
564                        storedSize.height = size.height;
565                    }
566                    rememberWindowGeometry(geometry);
567                }
568                keys.setFixedLocale(PROPERTY_FIX_TAG_LOCALE.get());
569            }
570            super.setVisible(visible);
571        }
572
573        private void selectACComboBoxSavingUnixBuffer(AutoCompletingComboBox cb) {
574            // select combobox with saving unix system selection (middle mouse paste)
575            Clipboard sysSel = ClipboardUtils.getSystemSelection();
576            if (sysSel != null) {
577                Transferable old = ClipboardUtils.getClipboardContent(sysSel);
578                cb.requestFocusInWindow();
579                cb.getEditor().selectAll();
580                if (old != null) {
581                    sysSel.setContents(old, null);
582                }
583            } else {
584                cb.requestFocusInWindow();
585                cb.getEditor().selectAll();
586            }
587        }
588
589        public void selectKeysComboBox() {
590            selectACComboBoxSavingUnixBuffer(keys);
591        }
592
593        public void selectValuesCombobox() {
594            selectACComboBoxSavingUnixBuffer(values);
595        }
596
597        /**
598        * Create a focus handling adapter and apply in to the editor component of value
599        * autocompletion box.
600        * @param autocomplete Manager handling the autocompletion
601        * @param comparator Class to decide what values are offered on autocompletion
602        * @return The created adapter
603        */
604        protected FocusAdapter addFocusAdapter(final AutoCompletionManager autocomplete, final Comparator<AutoCompletionListItem> comparator) {
605           // get the combo box' editor component
606           final JTextComponent editor = values.getEditorComponent();
607           // Refresh the values model when focus is gained
608           FocusAdapter focus = new FocusAdapter() {
609               @Override
610               public void focusGained(FocusEvent e) {
611                   String key = keys.getEditor().getItem().toString();
612
613                   List<AutoCompletionListItem> valueList = autocomplete.getValues(getAutocompletionKeys(key));
614                   valueList.sort(comparator);
615                   if (Main.isTraceEnabled()) {
616                       Main.trace("Focus gained by {0}, e={1}", values, e);
617                   }
618                   values.setPossibleACItems(valueList);
619                   values.getEditor().selectAll();
620                   objKey = key;
621               }
622           };
623           editor.addFocusListener(focus);
624           return focus;
625        }
626
627        protected JPopupMenu popupMenu = new JPopupMenu() {
628            private final JCheckBoxMenuItem fixTagLanguageCb = new JCheckBoxMenuItem(
629                new AbstractAction(tr("Use English language for tag by default")) {
630                @Override
631                public void actionPerformed(ActionEvent e) {
632                    boolean use = ((JCheckBoxMenuItem) e.getSource()).getState();
633                    PROPERTY_FIX_TAG_LOCALE.put(use);
634                    keys.setFixedLocale(use);
635                }
636            });
637            {
638                add(fixTagLanguageCb);
639                fixTagLanguageCb.setState(PROPERTY_FIX_TAG_LOCALE.get());
640            }
641        };
642    }
643
644    protected class AddTagsDialog extends AbstractTagsDialog {
645        private final List<JosmAction> recentTagsActions = new ArrayList<>();
646        protected final transient FocusAdapter focus;
647        private final JPanel mainPanel;
648        private JPanel recentTagsPanel;
649
650        // Counter of added commands for possible undo
651        private int commandCount;
652
653        protected AddTagsDialog() {
654            super(Main.parent, tr("Add value?"), new String[] {tr("OK"), tr("Cancel")});
655            setButtonIcons(new String[] {"ok", "cancel"});
656            setCancelButton(2);
657            configureContextsensitiveHelp("/Dialog/AddValue", true /* show help button */);
658
659            mainPanel = new JPanel(new GridBagLayout());
660            keys = new AutoCompletingComboBox();
661            values = new AutoCompletingComboBox();
662
663            mainPanel.add(new JLabel("<html>"+trn("This will change up to {0} object.",
664                "This will change up to {0} objects.", sel.size(), sel.size())
665                +"<br><br>"+tr("Please select a key")), GBC.eol().fill(GBC.HORIZONTAL));
666
667            AutoCompletionManager autocomplete = Main.getLayerManager().getEditLayer().data.getAutoCompletionManager();
668            List<AutoCompletionListItem> keyList = autocomplete.getKeys();
669
670            AutoCompletionListItem itemToSelect = null;
671            // remove the object's tag keys from the list
672            Iterator<AutoCompletionListItem> iter = keyList.iterator();
673            while (iter.hasNext()) {
674                AutoCompletionListItem item = iter.next();
675                if (item.getValue().equals(lastAddKey)) {
676                    itemToSelect = item;
677                }
678                for (int i = 0; i < tagData.getRowCount(); ++i) {
679                    if (item.getValue().equals(tagData.getValueAt(i, 0) /* sic! do not use getDataKey*/)) {
680                        if (itemToSelect == item) {
681                            itemToSelect = null;
682                        }
683                        iter.remove();
684                        break;
685                    }
686                }
687            }
688
689            keyList.sort(defaultACItemComparator);
690            keys.setPossibleACItems(keyList);
691            keys.setEditable(true);
692
693            mainPanel.add(keys, GBC.eop().fill(GBC.HORIZONTAL));
694
695            mainPanel.add(new JLabel(tr("Please select a value")), GBC.eol());
696            values.setEditable(true);
697            mainPanel.add(values, GBC.eop().fill(GBC.HORIZONTAL));
698            if (itemToSelect != null) {
699                keys.setSelectedItem(itemToSelect);
700                if (lastAddValue != null) {
701                    values.setSelectedItem(lastAddValue);
702                }
703            }
704
705            focus = addFocusAdapter(autocomplete, defaultACItemComparator);
706            // fire focus event in advance or otherwise the popup list will be too small at first
707            focus.focusGained(null);
708
709            // Add tag on Shift-Enter
710            mainPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
711                        KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.SHIFT_MASK), "addAndContinue");
712                mainPanel.getActionMap().put("addAndContinue", new AbstractAction() {
713                    @Override
714                    public void actionPerformed(ActionEvent e) {
715                        performTagAdding();
716                        refreshRecentTags();
717                        selectKeysComboBox();
718                    }
719                });
720
721            cacheRecentTags();
722            suggestRecentlyAddedTags();
723
724            mainPanel.add(Box.createVerticalGlue(), GBC.eop().fill());
725            setContent(mainPanel, false);
726
727            selectKeysComboBox();
728
729            popupMenu.add(new AbstractAction(tr("Set number of recently added tags")) {
730                @Override
731                public void actionPerformed(ActionEvent e) {
732                    selectNumberOfTags();
733                    suggestRecentlyAddedTags();
734                }
735            });
736
737            popupMenu.add(buildMenuRecentExisting());
738            popupMenu.add(buildMenuRefreshRecent());
739
740            JCheckBoxMenuItem rememberLastTags = new JCheckBoxMenuItem(
741                new AbstractAction(tr("Remember last used tags after a restart")) {
742                @Override
743                public void actionPerformed(ActionEvent e) {
744                    boolean state = ((JCheckBoxMenuItem) e.getSource()).getState();
745                    PROPERTY_REMEMBER_TAGS.put(state);
746                    if (state)
747                        saveTagsIfNeeded();
748                }
749            });
750            rememberLastTags.setState(PROPERTY_REMEMBER_TAGS.get());
751            popupMenu.add(rememberLastTags);
752        }
753
754        private JMenu buildMenuRecentExisting() {
755            JMenu menu = new JMenu(tr("Recent tags with existing key"));
756            TreeMap<RecentExisting, String> radios = new TreeMap<>();
757            radios.put(RecentExisting.ENABLE, tr("Enable"));
758            radios.put(RecentExisting.DISABLE, tr("Disable"));
759            radios.put(RecentExisting.HIDE, tr("Hide"));
760            ButtonGroup buttonGroup = new ButtonGroup();
761            for (final Map.Entry<RecentExisting, String> entry : radios.entrySet()) {
762                JRadioButtonMenuItem radio = new JRadioButtonMenuItem(new AbstractAction(entry.getValue()) {
763                    @Override
764                    public void actionPerformed(ActionEvent e) {
765                        PROPERTY_RECENT_EXISTING.put(entry.getKey());
766                        suggestRecentlyAddedTags();
767                    }
768                });
769                buttonGroup.add(radio);
770                radio.setSelected(PROPERTY_RECENT_EXISTING.get() == entry.getKey());
771                menu.add(radio);
772            }
773            return menu;
774        }
775
776        private JMenu buildMenuRefreshRecent() {
777            JMenu menu = new JMenu(tr("Refresh recent tags list after applying tag"));
778            TreeMap<RefreshRecent, String> radios = new TreeMap<>();
779            radios.put(RefreshRecent.NO, tr("No refresh"));
780            radios.put(RefreshRecent.STATUS, tr("Refresh tag status only (enabled / disabled)"));
781            radios.put(RefreshRecent.REFRESH, tr("Refresh tag status and list of recently added tags"));
782            ButtonGroup buttonGroup = new ButtonGroup();
783            for (final Map.Entry<RefreshRecent, String> entry : radios.entrySet()) {
784                JRadioButtonMenuItem radio = new JRadioButtonMenuItem(new AbstractAction(entry.getValue()) {
785                    @Override
786                    public void actionPerformed(ActionEvent e) {
787                        PROPERTY_REFRESH_RECENT.put(entry.getKey());
788                    }
789                });
790                buttonGroup.add(radio);
791                radio.setSelected(PROPERTY_REFRESH_RECENT.get() == entry.getKey());
792                menu.add(radio);
793            }
794            return menu;
795        }
796
797        @Override
798        public void setContentPane(Container contentPane) {
799            final int commandDownMask = GuiHelper.getMenuShortcutKeyMaskEx();
800            List<String> lines = new ArrayList<>();
801            Shortcut.findShortcut(KeyEvent.VK_1, commandDownMask).ifPresent(sc ->
802                    lines.add(sc.getKeyText() + ' ' + tr("to apply first suggestion"))
803            );
804            lines.add(KeyEvent.getKeyModifiersText(KeyEvent.SHIFT_MASK)+'+'+KeyEvent.getKeyText(KeyEvent.VK_ENTER) + ' '
805                    +tr("to add without closing the dialog"));
806            Shortcut.findShortcut(KeyEvent.VK_1, commandDownMask | KeyEvent.SHIFT_DOWN_MASK).ifPresent(sc ->
807                    lines.add(sc.getKeyText() + ' ' + tr("to add first suggestion without closing the dialog"))
808            );
809            final JLabel helpLabel = new JLabel("<html>" + Utils.join("<br>", lines) + "</html>");
810            helpLabel.setFont(helpLabel.getFont().deriveFont(Font.PLAIN));
811            contentPane.add(helpLabel, GBC.eol().fill(GridBagConstraints.HORIZONTAL).insets(5, 5, 5, 5));
812            super.setContentPane(contentPane);
813        }
814
815        protected void selectNumberOfTags() {
816            String s = String.format("%d", PROPERTY_RECENT_TAGS_NUMBER.get());
817            while (true) {
818                s = JOptionPane.showInputDialog(this, tr("Please enter the number of recently added tags to display"), s);
819                if (s == null || s.isEmpty()) {
820                    return;
821                }
822                try {
823                    int v = Integer.parseInt(s);
824                    if (v >= 0 && v <= MAX_LRU_TAGS_NUMBER) {
825                        PROPERTY_RECENT_TAGS_NUMBER.put(v);
826                        return;
827                    }
828                } catch (NumberFormatException ex) {
829                    Main.warn(ex);
830                }
831                JOptionPane.showMessageDialog(this, tr("Please enter integer number between 0 and {0}", MAX_LRU_TAGS_NUMBER));
832            }
833        }
834
835        protected void suggestRecentlyAddedTags() {
836            if (recentTagsPanel == null) {
837                recentTagsPanel = new JPanel(new GridBagLayout());
838                buildRecentTagsPanel();
839                mainPanel.add(recentTagsPanel, GBC.eol().fill(GBC.HORIZONTAL));
840            } else {
841                Dimension panelOldSize = recentTagsPanel.getPreferredSize();
842                recentTagsPanel.removeAll();
843                buildRecentTagsPanel();
844                Dimension panelNewSize = recentTagsPanel.getPreferredSize();
845                Dimension dialogOldSize = getMinimumSize();
846                Dimension dialogNewSize = new Dimension(dialogOldSize.width, dialogOldSize.height-panelOldSize.height+panelNewSize.height);
847                setMinimumSize(dialogNewSize);
848                setPreferredSize(dialogNewSize);
849                setSize(dialogNewSize);
850                revalidate();
851                repaint();
852            }
853        }
854
855        protected void buildRecentTagsPanel() {
856            final int tagsToShow = Math.min(PROPERTY_RECENT_TAGS_NUMBER.get(), MAX_LRU_TAGS_NUMBER);
857            if (!(tagsToShow > 0 && !recentTags.isEmpty()))
858                return;
859            recentTagsPanel.add(new JLabel(tr("Recently added tags")), GBC.eol());
860
861            int count = 0;
862            destroyActions();
863            // We store the maximum number of recent tags to allow dynamic change of number of tags shown in the preferences.
864            // This implies to iterate in descending order, as the oldest elements will only be removed after we reach the maximum
865            // number and not the number of tags to show.
866            for (int i = tags.size()-1; i >= 0 && count < tagsToShow; i--) {
867                final Tag t = tags.get(i);
868                boolean keyExists = keyExists(t);
869                if (keyExists && PROPERTY_RECENT_EXISTING.get() == RecentExisting.HIDE)
870                    continue;
871                count++;
872                // Create action for reusing the tag, with keyboard shortcut
873                /* POSSIBLE SHORTCUTS: 1,2,3,4,5,6,7,8,9,0=10 */
874                final Shortcut sc = count > 10 ? null : Shortcut.registerShortcut("properties:recent:" + count,
875                        tr("Choose recent tag {0}", count), KeyEvent.VK_0 + (count % 10), Shortcut.CTRL);
876                final JosmAction action = new JosmAction(
877                        tr("Choose recent tag {0}", count), null, tr("Use this tag again"), sc, false) {
878                    @Override
879                    public void actionPerformed(ActionEvent e) {
880                        keys.setSelectedItem(t.getKey());
881                        // fix #7951, #8298 - update list of values before setting value (?)
882                        focus.focusGained(null);
883                        values.setSelectedItem(t.getValue());
884                        selectValuesCombobox();
885                    }
886                };
887                /* POSSIBLE SHORTCUTS: 1,2,3,4,5,6,7,8,9,0=10 */
888                final Shortcut scShift = count > 10 ? null : Shortcut.registerShortcut("properties:recent:apply:" + count,
889                         tr("Apply recent tag {0}", count), KeyEvent.VK_0 + (count % 10), Shortcut.CTRL_SHIFT);
890                final JosmAction actionShift = new JosmAction(
891                        tr("Apply recent tag {0}", count), null, tr("Use this tag again"), scShift, false) {
892                    @Override
893                    public void actionPerformed(ActionEvent e) {
894                        action.actionPerformed(null);
895                        performTagAdding();
896                        refreshRecentTags();
897                        selectKeysComboBox();
898                    }
899                };
900                recentTagsActions.add(action);
901                recentTagsActions.add(actionShift);
902                if (keyExists && PROPERTY_RECENT_EXISTING.get() == RecentExisting.DISABLE) {
903                    action.setEnabled(false);
904                }
905                // Find and display icon
906                ImageIcon icon = MapPaintStyles.getNodeIcon(t, false); // Filters deprecated icon
907                if (icon == null) {
908                    // If no icon found in map style look at presets
909                    Map<String, String> map = new HashMap<>();
910                    map.put(t.getKey(), t.getValue());
911                    for (TaggingPreset tp : TaggingPresets.getMatchingPresets(null, map, false)) {
912                        icon = tp.getIcon();
913                        if (icon != null) {
914                            break;
915                        }
916                    }
917                    // If still nothing display an empty icon
918                    if (icon == null) {
919                        icon = new ImageIcon(new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB));
920                    }
921                }
922                GridBagConstraints gbc = new GridBagConstraints();
923                gbc.ipadx = 5;
924                recentTagsPanel.add(new JLabel(action.isEnabled() ? icon : GuiHelper.getDisabledIcon(icon)), gbc);
925                // Create tag label
926                final String color = action.isEnabled() ? "" : "; color:gray";
927                final JLabel tagLabel = new JLabel("<html>"
928                        + "<style>td{" + color + "}</style>"
929                        + "<table><tr>"
930                        + "<td>" + count + ".</td>"
931                        + "<td style='border:1px solid gray'>" + XmlWriter.encode(t.toString(), true) + '<' +
932                        "/td></tr></table></html>");
933                tagLabel.setFont(tagLabel.getFont().deriveFont(Font.PLAIN));
934                if (action.isEnabled() && sc != null && scShift != null) {
935                    // Register action
936                    recentTagsPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(sc.getKeyStroke(), "choose"+count);
937                    recentTagsPanel.getActionMap().put("choose"+count, action);
938                    recentTagsPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scShift.getKeyStroke(), "apply"+count);
939                    recentTagsPanel.getActionMap().put("apply"+count, actionShift);
940                }
941                if (action.isEnabled()) {
942                    // Make the tag label clickable and set tooltip to the action description (this displays also the keyboard shortcut)
943                    tagLabel.setToolTipText((String) action.getValue(Action.SHORT_DESCRIPTION));
944                    tagLabel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
945                    tagLabel.addMouseListener(new MouseAdapter() {
946                        @Override
947                        public void mouseClicked(MouseEvent e) {
948                            action.actionPerformed(null);
949                            if (SwingUtilities.isRightMouseButton(e)) {
950                                new TagPopupMenu(t).show(e.getComponent(), e.getX(), e.getY());
951                            } else if (e.isShiftDown()) {
952                                // add tags on Shift-Click
953                                performTagAdding();
954                                refreshRecentTags();
955                                selectKeysComboBox();
956                            } else if (e.getClickCount() > 1) {
957                                // add tags and close window on double-click
958                                buttonAction(0, null); // emulate OK click and close the dialog
959                            }
960                        }
961                    });
962                } else {
963                    // Disable tag label
964                    tagLabel.setEnabled(false);
965                    // Explain in the tooltip why
966                    tagLabel.setToolTipText(tr("The key ''{0}'' is already used", t.getKey()));
967                }
968                // Finally add label to the resulting panel
969                JPanel tagPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
970                tagPanel.add(tagLabel);
971                recentTagsPanel.add(tagPanel, GBC.eol().fill(GBC.HORIZONTAL));
972            }
973            // Clear label if no tags were added
974            if (count == 0) {
975                recentTagsPanel.removeAll();
976            }
977        }
978
979        class TagPopupMenu extends JPopupMenu {
980
981            TagPopupMenu(Tag t) {
982                add(new IgnoreTagAction(tr("Ignore key ''{0}''", t.getKey()), new Tag(t.getKey(), "")));
983                add(new IgnoreTagAction(tr("Ignore tag ''{0}''", t), t));
984                add(new EditIgnoreTagsAction());
985            }
986        }
987
988        class IgnoreTagAction extends AbstractAction {
989            final transient Tag tag;
990
991            IgnoreTagAction(String name, Tag tag) {
992                super(name);
993                this.tag = tag;
994            }
995
996            @Override
997            public void actionPerformed(ActionEvent e) {
998                try {
999                    if (tagsToIgnore != null) {
1000                        recentTags.ignoreTag(tag, tagsToIgnore);
1001                        PROPERTY_TAGS_TO_IGNORE.put(tagsToIgnore.writeToString());
1002                    }
1003                } catch (SearchCompiler.ParseError parseError) {
1004                    throw new IllegalStateException(parseError);
1005                }
1006            }
1007        }
1008
1009        class EditIgnoreTagsAction extends AbstractAction {
1010
1011            EditIgnoreTagsAction() {
1012                super(tr("Edit ignore list"));
1013            }
1014
1015            @Override
1016            public void actionPerformed(ActionEvent e) {
1017                final SearchAction.SearchSetting newTagsToIngore = SearchAction.showSearchDialog(tagsToIgnore);
1018                if (newTagsToIngore == null) {
1019                    return;
1020                }
1021                try {
1022                    tagsToIgnore = newTagsToIngore;
1023                    recentTags.setTagsToIgnore(tagsToIgnore);
1024                    PROPERTY_TAGS_TO_IGNORE.put(tagsToIgnore.writeToString());
1025                } catch (SearchCompiler.ParseError parseError) {
1026                    warnAboutParseError(parseError);
1027                }
1028            }
1029        }
1030
1031        /**
1032         * Destroy the recentTagsActions.
1033         */
1034        public void destroyActions() {
1035            for (JosmAction action : recentTagsActions) {
1036                action.destroy();
1037            }
1038            recentTagsActions.clear();
1039        }
1040
1041        /**
1042         * Read tags from comboboxes and add it to all selected objects
1043         */
1044        public final void performTagAdding() {
1045            String key = Tag.removeWhiteSpaces(keys.getEditor().getItem().toString());
1046            String value = Tag.removeWhiteSpaces(values.getEditor().getItem().toString());
1047            if (key.isEmpty() || value.isEmpty())
1048                return;
1049            for (OsmPrimitive osm : sel) {
1050                String val = osm.get(key);
1051                if (val != null && !val.equals(value)) {
1052                    if (!warnOverwriteKey(tr("You changed the value of ''{0}'' from ''{1}'' to ''{2}''.", key, val, value),
1053                            "overwriteAddKey"))
1054                        return;
1055                    break;
1056                }
1057            }
1058            lastAddKey = key;
1059            lastAddValue = value;
1060            recentTags.add(new Tag(key, value));
1061            valueCount.put(key, new TreeMap<String, Integer>());
1062            AutoCompletionManager.rememberUserInput(key, value, false);
1063            commandCount++;
1064            Main.main.undoRedo.add(new ChangePropertyCommand(sel, key, value));
1065            changedKey = key;
1066            clearEntries();
1067        }
1068
1069        protected void clearEntries() {
1070            keys.getEditor().setItem("");
1071            values.getEditor().setItem("");
1072        }
1073
1074        public void undoAllTagsAdding() {
1075            Main.main.undoRedo.undo(commandCount);
1076        }
1077
1078        private boolean keyExists(final Tag t) {
1079            return valueCount.containsKey(t.getKey());
1080        }
1081
1082        private void refreshRecentTags() {
1083            switch (PROPERTY_REFRESH_RECENT.get()) {
1084                case REFRESH: cacheRecentTags(); // break missing intentionally
1085                case STATUS: suggestRecentlyAddedTags(); break;
1086                default: // Do nothing
1087            }
1088        }
1089    }
1090}