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