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