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(new SearchCompiler.Never());
313            }
314        }
315    }
316
317    private 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                sysSel.setContents(old, null);
592            } else {
593                cb.requestFocusInWindow();
594                cb.getEditor().selectAll();
595            }
596        }
597
598        public void selectKeysComboBox() {
599            selectACComboBoxSavingUnixBuffer(keys);
600        }
601
602        public void selectValuesCombobox()   {
603            selectACComboBoxSavingUnixBuffer(values);
604        }
605
606        /**
607        * Create a focus handling adapter and apply in to the editor component of value
608        * autocompletion box.
609        * @param autocomplete Manager handling the autocompletion
610        * @param comparator Class to decide what values are offered on autocompletion
611        * @return The created adapter
612        */
613        protected FocusAdapter addFocusAdapter(final AutoCompletionManager autocomplete, final Comparator<AutoCompletionListItem> comparator) {
614           // get the combo box' editor component
615           final JTextComponent editor = values.getEditorComponent();
616           // Refresh the values model when focus is gained
617           FocusAdapter focus = new FocusAdapter() {
618               @Override
619               public void focusGained(FocusEvent e) {
620                   String key = keys.getEditor().getItem().toString();
621
622                   List<AutoCompletionListItem> valueList = autocomplete.getValues(getAutocompletionKeys(key));
623                   Collections.sort(valueList, comparator);
624                   if (Main.isTraceEnabled()) {
625                       Main.trace("Focus gained by {0}, e={1}", values, e);
626                   }
627                   values.setPossibleACItems(valueList);
628                   values.getEditor().selectAll();
629                   objKey = key;
630               }
631           };
632           editor.addFocusListener(focus);
633           return focus;
634        }
635
636        protected JPopupMenu popupMenu = new JPopupMenu() {
637            private final JCheckBoxMenuItem fixTagLanguageCb = new JCheckBoxMenuItem(
638                new AbstractAction(tr("Use English language for tag by default")) {
639                @Override
640                public void actionPerformed(ActionEvent e) {
641                    boolean use = ((JCheckBoxMenuItem) e.getSource()).getState();
642                    PROPERTY_FIX_TAG_LOCALE.put(use);
643                    keys.setFixedLocale(use);
644                }
645            });
646            {
647                add(fixTagLanguageCb);
648                fixTagLanguageCb.setState(PROPERTY_FIX_TAG_LOCALE.get());
649            }
650        };
651    }
652
653    protected class AddTagsDialog extends AbstractTagsDialog {
654        private final List<JosmAction> recentTagsActions = new ArrayList<>();
655        protected final transient FocusAdapter focus;
656        private JPanel mainPanel;
657        private JPanel recentTagsPanel;
658
659        // Counter of added commands for possible undo
660        private int commandCount;
661
662        protected AddTagsDialog() {
663            super(Main.parent, tr("Add value?"), new String[] {tr("OK"), tr("Cancel")});
664            setButtonIcons(new String[] {"ok", "cancel"});
665            setCancelButton(2);
666            configureContextsensitiveHelp("/Dialog/AddValue", true /* show help button */);
667
668            mainPanel = new JPanel(new GridBagLayout());
669            keys = new AutoCompletingComboBox();
670            values = new AutoCompletingComboBox();
671
672            mainPanel.add(new JLabel("<html>"+trn("This will change up to {0} object.",
673                "This will change up to {0} objects.", sel.size(), sel.size())
674                +"<br><br>"+tr("Please select a key")), GBC.eol().fill(GBC.HORIZONTAL));
675
676            AutoCompletionManager autocomplete = Main.main.getEditLayer().data.getAutoCompletionManager();
677            List<AutoCompletionListItem> keyList = autocomplete.getKeys();
678
679            AutoCompletionListItem itemToSelect = null;
680            // remove the object's tag keys from the list
681            Iterator<AutoCompletionListItem> iter = keyList.iterator();
682            while (iter.hasNext()) {
683                AutoCompletionListItem item = iter.next();
684                if (item.getValue().equals(lastAddKey)) {
685                    itemToSelect = item;
686                }
687                for (int i = 0; i < tagData.getRowCount(); ++i) {
688                    if (item.getValue().equals(tagData.getValueAt(i, 0) /* sic! do not use getDataKey*/)) {
689                        if (itemToSelect == item) {
690                            itemToSelect = null;
691                        }
692                        iter.remove();
693                        break;
694                    }
695                }
696            }
697
698            Collections.sort(keyList, defaultACItemComparator);
699            keys.setPossibleACItems(keyList);
700            keys.setEditable(true);
701
702            mainPanel.add(keys, GBC.eop().fill(GBC.HORIZONTAL));
703
704            mainPanel.add(new JLabel(tr("Please select a value")), GBC.eol());
705            values.setEditable(true);
706            mainPanel.add(values, GBC.eop().fill(GBC.HORIZONTAL));
707            if (itemToSelect != null) {
708                keys.setSelectedItem(itemToSelect);
709                if (lastAddValue != null) {
710                    values.setSelectedItem(lastAddValue);
711                }
712            }
713
714            focus = addFocusAdapter(autocomplete, defaultACItemComparator);
715            // fire focus event in advance or otherwise the popup list will be too small at first
716            focus.focusGained(null);
717
718            // Add tag on Shift-Enter
719            mainPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
720                        KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.SHIFT_MASK), "addAndContinue");
721                mainPanel.getActionMap().put("addAndContinue", new AbstractAction() {
722                    @Override
723                    public void actionPerformed(ActionEvent e) {
724                        performTagAdding();
725                        refreshRecentTags();
726                        selectKeysComboBox();
727                    }
728                });
729
730            cacheRecentTags();
731            suggestRecentlyAddedTags();
732
733            mainPanel.add(Box.createVerticalGlue(), GBC.eop().fill());
734            setContent(mainPanel, false);
735
736            selectKeysComboBox();
737
738            popupMenu.add(new AbstractAction(tr("Set number of recently added tags")) {
739                @Override
740                public void actionPerformed(ActionEvent e) {
741                    selectNumberOfTags();
742                    suggestRecentlyAddedTags();
743                }
744            });
745
746            popupMenu.add(buildMenuRecentExisting());
747            popupMenu.add(buildMenuRefreshRecent());
748
749            JCheckBoxMenuItem rememberLastTags = new JCheckBoxMenuItem(
750                new AbstractAction(tr("Remember last used tags after a restart")) {
751                @Override
752                public void actionPerformed(ActionEvent e) {
753                    boolean state = ((JCheckBoxMenuItem) e.getSource()).getState();
754                    PROPERTY_REMEMBER_TAGS.put(state);
755                    if (state)
756                        saveTagsIfNeeded();
757                }
758            });
759            rememberLastTags.setState(PROPERTY_REMEMBER_TAGS.get());
760            popupMenu.add(rememberLastTags);
761        }
762
763        private JMenu buildMenuRecentExisting() {
764            JMenu menu = new JMenu(tr("Recent tags with existing key"));
765            TreeMap<RecentExisting, String> radios = new TreeMap<>();
766            radios.put(RecentExisting.ENABLE, tr("Enable"));
767            radios.put(RecentExisting.DISABLE, tr("Disable"));
768            radios.put(RecentExisting.HIDE, tr("Hide"));
769            ButtonGroup buttonGroup = new ButtonGroup();
770            for (final Map.Entry<RecentExisting, String> entry : radios.entrySet()) {
771                JRadioButtonMenuItem radio = new JRadioButtonMenuItem(new AbstractAction(entry.getValue()) {
772                    @Override
773                    public void actionPerformed(ActionEvent e) {
774                        PROPERTY_RECENT_EXISTING.put(entry.getKey());
775                        suggestRecentlyAddedTags();
776                    }
777                });
778                buttonGroup.add(radio);
779                radio.setSelected(PROPERTY_RECENT_EXISTING.get() == entry.getKey());
780                menu.add(radio);
781            }
782            return menu;
783        }
784
785        private JMenu buildMenuRefreshRecent() {
786            JMenu menu = new JMenu(tr("Refresh recent tags list after applying tag"));
787            TreeMap<RefreshRecent, String> radios = new TreeMap<>();
788            radios.put(RefreshRecent.NO, tr("No refresh"));
789            radios.put(RefreshRecent.STATUS, tr("Refresh tag status only (enabled / disabled)"));
790            radios.put(RefreshRecent.REFRESH, tr("Refresh tag status and list of recently added tags"));
791            ButtonGroup buttonGroup = new ButtonGroup();
792            for (final Map.Entry<RefreshRecent, String> entry : radios.entrySet()) {
793                JRadioButtonMenuItem radio = new JRadioButtonMenuItem(new AbstractAction(entry.getValue()) {
794                    @Override
795                    public void actionPerformed(ActionEvent e) {
796                        PROPERTY_REFRESH_RECENT.put(entry.getKey());
797                    }
798                });
799                buttonGroup.add(radio);
800                radio.setSelected(PROPERTY_REFRESH_RECENT.get() == entry.getKey());
801                menu.add(radio);
802            }
803            return menu;
804        }
805
806        @Override
807        public void setContentPane(Container contentPane) {
808            final int commandDownMask = GuiHelper.getMenuShortcutKeyMaskEx();
809            List<String> lines = new ArrayList<>();
810            Shortcut sc = Shortcut.findShortcut(KeyEvent.VK_1, commandDownMask);
811            if (sc != null) {
812                lines.add(sc.getKeyText() + " " + tr("to apply first suggestion"));
813            }
814            lines.add(KeyEvent.getKeyModifiersText(KeyEvent.SHIFT_MASK)+'+'+KeyEvent.getKeyText(KeyEvent.VK_ENTER) + " "
815                    +tr("to add without closing the dialog"));
816            sc = Shortcut.findShortcut(KeyEvent.VK_1, commandDownMask | KeyEvent.SHIFT_DOWN_MASK);
817            if (sc != null) {
818                lines.add(sc.getKeyText() + " " + tr("to add first suggestion without closing the dialog"));
819            }
820            final JLabel helpLabel = new JLabel("<html>" + Utils.join("<br>", lines) + "</html>");
821            helpLabel.setFont(helpLabel.getFont().deriveFont(Font.PLAIN));
822            contentPane.add(helpLabel, GBC.eol().fill(GridBagConstraints.HORIZONTAL).insets(5, 5, 5, 5));
823            super.setContentPane(contentPane);
824        }
825
826        protected void selectNumberOfTags() {
827            String s = String.format("%d", PROPERTY_RECENT_TAGS_NUMBER.get());
828            while (true) {
829                s = JOptionPane.showInputDialog(this, tr("Please enter the number of recently added tags to display"), s);
830                if (s == null) {
831                    return;
832                }
833                try {
834                    int v = Integer.parseInt(s);
835                    if (v >= 0 && v <= MAX_LRU_TAGS_NUMBER) {
836                        PROPERTY_RECENT_TAGS_NUMBER.put(v);
837                        return;
838                    }
839                } catch (NumberFormatException ex) {
840                    Main.warn(ex);
841                }
842                JOptionPane.showMessageDialog(this, tr("Please enter integer number between 0 and {0}", MAX_LRU_TAGS_NUMBER));
843            }
844        }
845
846        protected void suggestRecentlyAddedTags() {
847            if (recentTagsPanel == null) {
848                recentTagsPanel = new JPanel(new GridBagLayout());
849                buildRecentTagsPanel();
850                mainPanel.add(recentTagsPanel, GBC.eol().fill(GBC.HORIZONTAL));
851            } else {
852                Dimension panelOldSize = recentTagsPanel.getPreferredSize();
853                recentTagsPanel.removeAll();
854                buildRecentTagsPanel();
855                Dimension panelNewSize = recentTagsPanel.getPreferredSize();
856                Dimension dialogOldSize = getMinimumSize();
857                Dimension dialogNewSize = new Dimension(dialogOldSize.width, dialogOldSize.height-panelOldSize.height+panelNewSize.height);
858                setMinimumSize(dialogNewSize);
859                setPreferredSize(dialogNewSize);
860                setSize(dialogNewSize);
861                revalidate();
862                repaint();
863            }
864        }
865
866        protected void buildRecentTagsPanel() {
867            final int tagsToShow = Math.min(PROPERTY_RECENT_TAGS_NUMBER.get(), MAX_LRU_TAGS_NUMBER);
868            if (!(tagsToShow > 0 && !recentTags.isEmpty()))
869                return;
870            recentTagsPanel.add(new JLabel(tr("Recently added tags")), GBC.eol());
871
872            int count = 0;
873            destroyActions();
874            // We store the maximum number of recent tags to allow dynamic change of number of tags shown in the preferences.
875            // This implies to iterate in descending order, as the oldest elements will only be removed after we reach the maximum
876            // number and not the number of tags to show.
877            for (int i = tags.size()-1; i >= 0 && count < tagsToShow; i--) {
878                final Tag t = tags.get(i);
879                boolean keyExists = keyExists(t);
880                if (keyExists && PROPERTY_RECENT_EXISTING.get() == RecentExisting.HIDE)
881                    continue;
882                count++;
883                // Create action for reusing the tag, with keyboard shortcut
884                /* POSSIBLE SHORTCUTS: 1,2,3,4,5,6,7,8,9,0=10 */
885                final Shortcut sc = count > 10 ? null : Shortcut.registerShortcut("properties:recent:" + count,
886                        tr("Choose recent tag {0}", count), KeyEvent.VK_0 + (count % 10), Shortcut.CTRL);
887                final JosmAction action = new JosmAction(
888                        tr("Choose recent tag {0}", count), null, tr("Use this tag again"), sc, false) {
889                    @Override
890                    public void actionPerformed(ActionEvent e) {
891                        keys.setSelectedItem(t.getKey());
892                        // fix #7951, #8298 - update list of values before setting value (?)
893                        focus.focusGained(null);
894                        values.setSelectedItem(t.getValue());
895                        selectValuesCombobox();
896                    }
897                };
898                /* POSSIBLE SHORTCUTS: 1,2,3,4,5,6,7,8,9,0=10 */
899                final Shortcut scShift = count > 10 ? null : Shortcut.registerShortcut("properties:recent:apply:" + count,
900                         tr("Apply recent tag {0}", count), KeyEvent.VK_0 + (count % 10), Shortcut.CTRL_SHIFT);
901                final JosmAction actionShift = new JosmAction(
902                        tr("Apply recent tag {0}", count), null, tr("Use this tag again"), scShift, false) {
903                    @Override
904                    public void actionPerformed(ActionEvent e) {
905                        action.actionPerformed(null);
906                        performTagAdding();
907                        refreshRecentTags();
908                        selectKeysComboBox();
909                    }
910                };
911                recentTagsActions.add(action);
912                recentTagsActions.add(actionShift);
913                if (keyExists && PROPERTY_RECENT_EXISTING.get() == RecentExisting.DISABLE) {
914                    action.setEnabled(false);
915                }
916                // Find and display icon
917                ImageIcon icon = MapPaintStyles.getNodeIcon(t, false); // Filters deprecated icon
918                if (icon == null) {
919                    // If no icon found in map style look at presets
920                    Map<String, String> map = new HashMap<>();
921                    map.put(t.getKey(), t.getValue());
922                    for (TaggingPreset tp : TaggingPresets.getMatchingPresets(null, map, false)) {
923                        icon = tp.getIcon();
924                        if (icon != null) {
925                            break;
926                        }
927                    }
928                    // If still nothing display an empty icon
929                    if (icon == null) {
930                        icon = new ImageIcon(new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB));
931                    }
932                }
933                GridBagConstraints gbc = new GridBagConstraints();
934                gbc.ipadx = 5;
935                recentTagsPanel.add(new JLabel(action.isEnabled() ? icon : GuiHelper.getDisabledIcon(icon)), gbc);
936                // Create tag label
937                final String color = action.isEnabled() ? "" : "; color:gray";
938                final JLabel tagLabel = new JLabel("<html>"
939                        + "<style>td{" + color + "}</style>"
940                        + "<table><tr>"
941                        + "<td>" + count + ".</td>"
942                        + "<td style='border:1px solid gray'>" + XmlWriter.encode(t.toString(), true) + '<' +
943                        "/td></tr></table></html>");
944                tagLabel.setFont(tagLabel.getFont().deriveFont(Font.PLAIN));
945                if (action.isEnabled() && sc != null && scShift != null) {
946                    // Register action
947                    recentTagsPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(sc.getKeyStroke(), "choose"+count);
948                    recentTagsPanel.getActionMap().put("choose"+count, action);
949                    recentTagsPanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scShift.getKeyStroke(), "apply"+count);
950                    recentTagsPanel.getActionMap().put("apply"+count, actionShift);
951                }
952                if (action.isEnabled()) {
953                    // Make the tag label clickable and set tooltip to the action description (this displays also the keyboard shortcut)
954                    tagLabel.setToolTipText((String) action.getValue(Action.SHORT_DESCRIPTION));
955                    tagLabel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
956                    tagLabel.addMouseListener(new MouseAdapter() {
957                        @Override
958                        public void mouseClicked(MouseEvent e) {
959                            action.actionPerformed(null);
960                            if (SwingUtilities.isRightMouseButton(e)) {
961                                new TagPopupMenu(t).show(e.getComponent(), e.getX(), e.getY());
962                            } else if (e.isShiftDown()) {
963                                // add tags on Shift-Click
964                                performTagAdding();
965                                refreshRecentTags();
966                                selectKeysComboBox();
967                            } else if (e.getClickCount() > 1) {
968                                // add tags and close window on double-click
969                                buttonAction(0, null); // emulate OK click and close the dialog
970                            }
971                        }
972                    });
973                } else {
974                    // Disable tag label
975                    tagLabel.setEnabled(false);
976                    // Explain in the tooltip why
977                    tagLabel.setToolTipText(tr("The key ''{0}'' is already used", t.getKey()));
978                }
979                // Finally add label to the resulting panel
980                JPanel tagPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
981                tagPanel.add(tagLabel);
982                recentTagsPanel.add(tagPanel, GBC.eol().fill(GBC.HORIZONTAL));
983            }
984            // Clear label if no tags were added
985            if (count == 0) {
986                recentTagsPanel.removeAll();
987            }
988        }
989
990        class TagPopupMenu extends JPopupMenu {
991
992            TagPopupMenu(Tag t) {
993                add(new IgnoreTagAction(tr("Ignore key ''{0}''", t.getKey()), new Tag(t.getKey(), "")));
994                add(new IgnoreTagAction(tr("Ignore tag ''{0}''", t), t));
995                add(new EditIgnoreTagsAction());
996            }
997        }
998
999        class IgnoreTagAction extends AbstractAction {
1000            final Tag tag;
1001
1002            IgnoreTagAction(String name, Tag tag) {
1003                super(name);
1004                this.tag = tag;
1005            }
1006
1007            @Override
1008            public void actionPerformed(ActionEvent e) {
1009                try {
1010                    recentTags.ignoreTag(tag, tagsToIgnore);
1011                    PROPERTY_TAGS_TO_IGNORE.put(tagsToIgnore.writeToString());
1012                } catch (SearchCompiler.ParseError parseError) {
1013                    throw new IllegalStateException(parseError);
1014                }
1015            }
1016        }
1017
1018        class EditIgnoreTagsAction extends AbstractAction {
1019
1020            EditIgnoreTagsAction() {
1021                super(tr("Edit ignore list"));
1022            }
1023
1024            @Override
1025            public void actionPerformed(ActionEvent e) {
1026                final SearchAction.SearchSetting newTagsToIngore = SearchAction.showSearchDialog(tagsToIgnore);
1027                if (newTagsToIngore == null) {
1028                    return;
1029                }
1030                try {
1031                    tagsToIgnore = newTagsToIngore;
1032                    recentTags.setTagsToIgnore(tagsToIgnore);
1033                    PROPERTY_TAGS_TO_IGNORE.put(tagsToIgnore.writeToString());
1034                } catch (SearchCompiler.ParseError parseError) {
1035                    warnAboutParseError(parseError);
1036                }
1037            }
1038        }
1039
1040        public void destroyActions() {
1041            for (JosmAction action : recentTagsActions) {
1042                action.destroy();
1043            }
1044        }
1045
1046        /**
1047         * Read tags from comboboxes and add it to all selected objects
1048         */
1049        public final void performTagAdding() {
1050            String key = Tag.removeWhiteSpaces(keys.getEditor().getItem().toString());
1051            String value = Tag.removeWhiteSpaces(values.getEditor().getItem().toString());
1052            if (key.isEmpty() || value.isEmpty())
1053                return;
1054            for (OsmPrimitive osm : sel) {
1055                String val = osm.get(key);
1056                if (val != null && !val.equals(value)) {
1057                    if (!warnOverwriteKey(tr("You changed the value of ''{0}'' from ''{1}'' to ''{2}''.", key, val, value),
1058                            "overwriteAddKey"))
1059                        return;
1060                    break;
1061                }
1062            }
1063            lastAddKey = key;
1064            lastAddValue = value;
1065            recentTags.add(new Tag(key, value));
1066            valueCount.put(key, new TreeMap<String, Integer>());
1067            AutoCompletionManager.rememberUserInput(key, value, false);
1068            commandCount++;
1069            Main.main.undoRedo.add(new ChangePropertyCommand(sel, key, value));
1070            changedKey = key;
1071            clearEntries();
1072        }
1073
1074        protected void clearEntries() {
1075            keys.getEditor().setItem("");
1076            values.getEditor().setItem("");
1077        }
1078
1079        public void undoAllTagsAdding() {
1080            Main.main.undoRedo.undo(commandCount);
1081        }
1082
1083        private boolean keyExists(final Tag t) {
1084            return valueCount.containsKey(t.getKey());
1085        }
1086
1087        private void refreshRecentTags() {
1088            switch (PROPERTY_REFRESH_RECENT.get()) {
1089                case REFRESH: cacheRecentTags(); // break missing intentionally
1090                case STATUS: suggestRecentlyAddedTags();
1091            }
1092        }
1093    }
1094}