001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.tagging; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.ht; 005import static org.openstreetmap.josm.tools.I18n.tr; 006 007import java.awt.Component; 008import java.awt.Container; 009import java.awt.Dimension; 010import java.awt.KeyboardFocusManager; 011import java.awt.Window; 012import java.awt.event.ActionEvent; 013import java.awt.event.KeyEvent; 014import java.awt.event.KeyListener; 015import java.beans.PropertyChangeEvent; 016import java.beans.PropertyChangeListener; 017import java.util.ArrayList; 018import java.util.Collections; 019import java.util.EventObject; 020import java.util.List; 021import java.util.Map; 022import java.util.concurrent.CopyOnWriteArrayList; 023 024import javax.swing.AbstractAction; 025import javax.swing.CellEditor; 026import javax.swing.DefaultListSelectionModel; 027import javax.swing.JComponent; 028import javax.swing.JTable; 029import javax.swing.JViewport; 030import javax.swing.KeyStroke; 031import javax.swing.ListSelectionModel; 032import javax.swing.SwingUtilities; 033import javax.swing.event.ListSelectionEvent; 034import javax.swing.event.ListSelectionListener; 035import javax.swing.table.DefaultTableColumnModel; 036import javax.swing.table.TableColumn; 037import javax.swing.text.JTextComponent; 038 039import org.openstreetmap.josm.Main; 040import org.openstreetmap.josm.actions.CopyAction; 041import org.openstreetmap.josm.actions.PasteTagsAction; 042import org.openstreetmap.josm.data.osm.OsmPrimitive; 043import org.openstreetmap.josm.data.osm.PrimitiveData; 044import org.openstreetmap.josm.data.osm.Relation; 045import org.openstreetmap.josm.data.osm.Tag; 046import org.openstreetmap.josm.gui.dialogs.relation.RunnableAction; 047import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionList; 048import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionManager; 049import org.openstreetmap.josm.tools.ImageProvider; 050import org.openstreetmap.josm.tools.TextTagParser; 051import org.openstreetmap.josm.tools.Utils; 052 053/** 054 * This is the tabular editor component for OSM tags. 055 * 056 */ 057public class TagTable extends JTable { 058 /** the table cell editor used by this table */ 059 private TagCellEditor editor; 060 private final TagEditorModel model; 061 private Component nextFocusComponent; 062 063 /** a list of components to which focus can be transferred without stopping 064 * cell editing this table. 065 */ 066 private final CopyOnWriteArrayList<Component> doNotStopCellEditingWhenFocused = new CopyOnWriteArrayList<>(); 067 private transient CellEditorRemover editorRemover; 068 069 /** 070 * The table has two columns. The first column is used for editing rendering and 071 * editing tag keys, the second for rendering and editing tag values. 072 * 073 */ 074 static class TagTableColumnModel extends DefaultTableColumnModel { 075 TagTableColumnModel(DefaultListSelectionModel selectionModel) { 076 setSelectionModel(selectionModel); 077 TableColumn col = null; 078 TagCellRenderer renderer = new TagCellRenderer(); 079 080 // column 0 - tag key 081 col = new TableColumn(0); 082 col.setHeaderValue(tr("Key")); 083 col.setResizable(true); 084 col.setCellRenderer(renderer); 085 addColumn(col); 086 087 // column 1 - tag value 088 col = new TableColumn(1); 089 col.setHeaderValue(tr("Value")); 090 col.setResizable(true); 091 col.setCellRenderer(renderer); 092 addColumn(col); 093 } 094 } 095 096 /** 097 * Action to be run when the user navigates to the next cell in the table, 098 * for instance by pressing TAB or ENTER. The action alters the standard 099 * navigation path from cell to cell: 100 * <ul> 101 * <li>it jumps over cells in the first column</li> 102 * <li>it automatically add a new empty row when the user leaves the 103 * last cell in the table</li> 104 * </ul> 105 * 106 */ 107 class SelectNextColumnCellAction extends AbstractAction { 108 @Override 109 public void actionPerformed(ActionEvent e) { 110 run(); 111 } 112 113 public void run() { 114 int col = getSelectedColumn(); 115 int row = getSelectedRow(); 116 if (getCellEditor() != null) { 117 getCellEditor().stopCellEditing(); 118 } 119 120 if (row == -1 && col == -1) { 121 requestFocusInCell(0, 0); 122 return; 123 } 124 125 if (col == 0) { 126 col++; 127 } else if (col == 1 && row < getRowCount()-1) { 128 col = 0; 129 row++; 130 } else if (col == 1 && row == getRowCount()-1) { 131 // we are at the end. Append an empty row and move the focus to its second column 132 String key = ((TagModel) model.getValueAt(row, 0)).getName(); 133 if (!key.trim().isEmpty()) { 134 model.appendNewTag(); 135 col = 0; 136 row++; 137 } else { 138 clearSelection(); 139 if (nextFocusComponent != null) 140 nextFocusComponent.requestFocusInWindow(); 141 return; 142 } 143 } 144 requestFocusInCell(row, col); 145 } 146 } 147 148 /** 149 * Action to be run when the user navigates to the previous cell in the table, 150 * for instance by pressing Shift-TAB 151 * 152 */ 153 class SelectPreviousColumnCellAction extends AbstractAction { 154 155 @Override 156 public void actionPerformed(ActionEvent e) { 157 int col = getSelectedColumn(); 158 int row = getSelectedRow(); 159 if (getCellEditor() != null) { 160 getCellEditor().stopCellEditing(); 161 } 162 163 if (col <= 0 && row <= 0) { 164 // change nothing 165 } else if (col == 1) { 166 col--; 167 } else { 168 col = 1; 169 row--; 170 } 171 requestFocusInCell(row, col); 172 } 173 } 174 175 /** 176 * Action to be run when the user invokes a delete action on the table, for 177 * instance by pressing DEL. 178 * 179 * Depending on the shape on the current selection the action deletes individual 180 * values or entire tags from the model. 181 * 182 * If the current selection consists of cells in the second column only, the keys of 183 * the selected tags are set to the empty string. 184 * 185 * If the current selection consists of cell in the third column only, the values of the 186 * selected tags are set to the empty string. 187 * 188 * If the current selection consists of cells in the second and the third column, 189 * the selected tags are removed from the model. 190 * 191 * This action listens to the table selection. It becomes enabled when the selection 192 * is non-empty, otherwise it is disabled. 193 * 194 * 195 */ 196 class DeleteAction extends RunnableAction implements ListSelectionListener { 197 198 DeleteAction() { 199 putValue(SMALL_ICON, ImageProvider.get("dialogs", "delete")); 200 putValue(SHORT_DESCRIPTION, tr("Delete the selection in the tag table")); 201 getSelectionModel().addListSelectionListener(this); 202 getColumnModel().getSelectionModel().addListSelectionListener(this); 203 updateEnabledState(); 204 } 205 206 /** 207 * delete a selection of tag names 208 */ 209 protected void deleteTagNames() { 210 int[] rows = getSelectedRows(); 211 model.deleteTagNames(rows); 212 } 213 214 /** 215 * delete a selection of tag values 216 */ 217 protected void deleteTagValues() { 218 int[] rows = getSelectedRows(); 219 model.deleteTagValues(rows); 220 } 221 222 /** 223 * delete a selection of tags 224 */ 225 protected void deleteTags() { 226 int[] rows = getSelectedRows(); 227 model.deleteTags(rows); 228 } 229 230 @Override 231 public void run() { 232 if (!isEnabled()) 233 return; 234 switch(getSelectedColumnCount()) { 235 case 1: 236 if (getSelectedColumn() == 0) { 237 deleteTagNames(); 238 } else if (getSelectedColumn() == 1) { 239 deleteTagValues(); 240 } 241 break; 242 case 2: 243 deleteTags(); 244 break; 245 } 246 247 if (isEditing()) { 248 CellEditor editor = getCellEditor(); 249 if (editor != null) { 250 editor.cancelCellEditing(); 251 } 252 } 253 254 if (model.getRowCount() == 0) { 255 model.ensureOneTag(); 256 requestFocusInCell(0, 0); 257 } 258 } 259 260 /** 261 * listens to the table selection model 262 */ 263 @Override 264 public void valueChanged(ListSelectionEvent e) { 265 updateEnabledState(); 266 } 267 268 protected final void updateEnabledState() { 269 if (isEditing() && getSelectedColumnCount() == 1 && getSelectedRowCount() == 1) { 270 setEnabled(true); 271 } else if (!isEditing() && getSelectedColumnCount() == 1 && getSelectedRowCount() == 1) { 272 setEnabled(true); 273 } else if (getSelectedColumnCount() > 1 || getSelectedRowCount() > 1) { 274 setEnabled(true); 275 } else { 276 setEnabled(false); 277 } 278 } 279 } 280 281 /** 282 * Action to be run when the user adds a new tag. 283 * 284 */ 285 class AddAction extends RunnableAction implements PropertyChangeListener { 286 AddAction() { 287 putValue(SMALL_ICON, ImageProvider.get("dialogs", "add")); 288 putValue(SHORT_DESCRIPTION, tr("Add a new tag")); 289 TagTable.this.addPropertyChangeListener(this); 290 updateEnabledState(); 291 } 292 293 @Override 294 public void run() { 295 CellEditor editor = getCellEditor(); 296 if (editor != null) { 297 getCellEditor().stopCellEditing(); 298 } 299 final int rowIdx = model.getRowCount()-1; 300 String key = ((TagModel) model.getValueAt(rowIdx, 0)).getName(); 301 if (!key.trim().isEmpty()) { 302 model.appendNewTag(); 303 } 304 requestFocusInCell(model.getRowCount()-1, 0); 305 } 306 307 protected final void updateEnabledState() { 308 setEnabled(TagTable.this.isEnabled()); 309 } 310 311 @Override 312 public void propertyChange(PropertyChangeEvent evt) { 313 updateEnabledState(); 314 } 315 } 316 317 /** 318 * Action to be run when the user wants to paste tags from buffer 319 */ 320 class PasteAction extends RunnableAction implements PropertyChangeListener { 321 PasteAction() { 322 putValue(SMALL_ICON, ImageProvider.get("", "pastetags")); 323 putValue(SHORT_DESCRIPTION, tr("Paste tags from buffer")); 324 TagTable.this.addPropertyChangeListener(this); 325 updateEnabledState(); 326 } 327 328 @Override 329 public void run() { 330 Relation relation = new Relation(); 331 model.applyToPrimitive(relation); 332 333 String buf = Utils.getClipboardContent(); 334 if (buf == null || buf.isEmpty() || buf.matches(CopyAction.CLIPBOARD_REGEXP)) { 335 List<PrimitiveData> directlyAdded = Main.pasteBuffer.getDirectlyAdded(); 336 if (directlyAdded == null || directlyAdded.isEmpty()) return; 337 PasteTagsAction.TagPaster tagPaster = new PasteTagsAction.TagPaster(directlyAdded, 338 Collections.<OsmPrimitive>singletonList(relation)); 339 model.updateTags(tagPaster.execute()); 340 } else { 341 // Paste tags from arbitrary text 342 Map<String, String> tags = TextTagParser.readTagsFromText(buf); 343 if (tags == null || tags.isEmpty()) { 344 TextTagParser.showBadBufferMessage(ht("/Action/PasteTags")); 345 } else if (TextTagParser.validateTags(tags)) { 346 List<Tag> newTags = new ArrayList<>(); 347 for (Map.Entry<String, String> entry: tags.entrySet()) { 348 String k = entry.getKey(); 349 String v = entry.getValue(); 350 newTags.add(new Tag(k, v)); 351 } 352 model.updateTags(newTags); 353 } 354 } 355 } 356 357 protected final void updateEnabledState() { 358 setEnabled(TagTable.this.isEnabled()); 359 } 360 361 @Override 362 public void propertyChange(PropertyChangeEvent evt) { 363 updateEnabledState(); 364 } 365 } 366 367 /** the delete action */ 368 private RunnableAction deleteAction; 369 370 /** the add action */ 371 private RunnableAction addAction; 372 373 /** the tag paste action */ 374 private RunnableAction pasteAction; 375 376 /** 377 * 378 * @return the delete action used by this table 379 */ 380 public RunnableAction getDeleteAction() { 381 return deleteAction; 382 } 383 384 public RunnableAction getAddAction() { 385 return addAction; 386 } 387 388 public RunnableAction getPasteAction() { 389 return pasteAction; 390 } 391 392 /** 393 * initialize the table 394 * @param maxCharacters maximum number of characters allowed for keys and values, 0 for unlimited 395 */ 396 protected final void init(final int maxCharacters) { 397 setAutoResizeMode(JTable.AUTO_RESIZE_OFF); 398 setRowSelectionAllowed(true); 399 setColumnSelectionAllowed(true); 400 setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION); 401 402 // make ENTER behave like TAB 403 // 404 getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) 405 .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0, false), "selectNextColumnCell"); 406 407 // install custom navigation actions 408 // 409 getActionMap().put("selectNextColumnCell", new SelectNextColumnCellAction()); 410 getActionMap().put("selectPreviousColumnCell", new SelectPreviousColumnCellAction()); 411 412 // create a delete action. Installing this action in the input and action map 413 // didn't work. We therefore handle delete requests in processKeyBindings(...) 414 // 415 deleteAction = new DeleteAction(); 416 417 // create the add action 418 // 419 addAction = new AddAction(); 420 getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) 421 .put(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_MASK), "addTag"); 422 getActionMap().put("addTag", addAction); 423 424 pasteAction = new PasteAction(); 425 426 // create the table cell editor and set it to key and value columns 427 // 428 TagCellEditor tmpEditor = new TagCellEditor(maxCharacters); 429 setRowHeight(tmpEditor.getEditor().getPreferredSize().height); 430 setTagCellEditor(tmpEditor); 431 } 432 433 /** 434 * Creates a new tag table 435 * 436 * @param model the tag editor model 437 * @param maxCharacters maximum number of characters allowed for keys and values, 0 for unlimited 438 */ 439 public TagTable(TagEditorModel model, final int maxCharacters) { 440 super(model, new TagTableColumnModel(model.getColumnSelectionModel()), model.getRowSelectionModel()); 441 this.model = model; 442 init(maxCharacters); 443 } 444 445 @Override 446 public Dimension getPreferredSize() { 447 Container c = getParent(); 448 while (c != null && !(c instanceof JViewport)) { 449 c = c.getParent(); 450 } 451 if (c != null) { 452 Dimension d = super.getPreferredSize(); 453 d.width = c.getSize().width; 454 return d; 455 } 456 return super.getPreferredSize(); 457 } 458 459 @Override protected boolean processKeyBinding(KeyStroke ks, KeyEvent e, 460 int condition, boolean pressed) { 461 462 // handle delete key 463 // 464 if (e.getKeyCode() == KeyEvent.VK_DELETE) { 465 if (isEditing() && getSelectedColumnCount() == 1 && getSelectedRowCount() == 1) 466 // if DEL was pressed and only the currently edited cell is selected, 467 // don't run the delete action. DEL is handled by the CellEditor as normal 468 // DEL in the text input. 469 // 470 return super.processKeyBinding(ks, e, condition, pressed); 471 getDeleteAction().run(); 472 } 473 return super.processKeyBinding(ks, e, condition, pressed); 474 } 475 476 /** 477 * Sets the editor autocompletion list 478 * @param autoCompletionList autocompletion list 479 */ 480 public void setAutoCompletionList(AutoCompletionList autoCompletionList) { 481 if (autoCompletionList == null) 482 return; 483 if (editor != null) { 484 editor.setAutoCompletionList(autoCompletionList); 485 } 486 } 487 488 public void setAutoCompletionManager(AutoCompletionManager autocomplete) { 489 if (autocomplete == null) { 490 Main.warn("argument autocomplete should not be null. Aborting."); 491 Thread.dumpStack(); 492 return; 493 } 494 if (editor != null) { 495 editor.setAutoCompletionManager(autocomplete); 496 } 497 } 498 499 public AutoCompletionList getAutoCompletionList() { 500 if (editor != null) 501 return editor.getAutoCompletionList(); 502 else 503 return null; 504 } 505 506 public void setNextFocusComponent(Component nextFocusComponent) { 507 this.nextFocusComponent = nextFocusComponent; 508 } 509 510 public TagCellEditor getTableCellEditor() { 511 return editor; 512 } 513 514 public void addOKAccelatorListener(KeyListener l) { 515 addKeyListener(l); 516 if (editor != null) { 517 editor.getEditor().addKeyListener(l); 518 } 519 } 520 521 /** 522 * Inject a tag cell editor in the tag table 523 * 524 * @param editor tag cell editor 525 */ 526 public void setTagCellEditor(TagCellEditor editor) { 527 if (isEditing()) { 528 this.editor.cancelCellEditing(); 529 } 530 this.editor = editor; 531 getColumnModel().getColumn(0).setCellEditor(editor); 532 getColumnModel().getColumn(1).setCellEditor(editor); 533 } 534 535 public void requestFocusInCell(final int row, final int col) { 536 changeSelection(row, col, false, false); 537 editCellAt(row, col); 538 Component c = getEditorComponent(); 539 if (c != null) { 540 c.requestFocusInWindow(); 541 if (c instanceof JTextComponent) { 542 ((JTextComponent) c).selectAll(); 543 } 544 } 545 // there was a bug here - on older 1.6 Java versions Tab was not working 546 // after such activation. In 1.7 it works OK, 547 // previous solution of using awt.Robot was resetting mouse speed on Windows 548 } 549 550 public void addComponentNotStoppingCellEditing(Component component) { 551 if (component == null) return; 552 doNotStopCellEditingWhenFocused.addIfAbsent(component); 553 } 554 555 public void removeComponentNotStoppingCellEditing(Component component) { 556 if (component == null) return; 557 doNotStopCellEditingWhenFocused.remove(component); 558 } 559 560 @Override 561 public boolean editCellAt(int row, int column, EventObject e) { 562 563 // a snipped copied from the Java 1.5 implementation of JTable 564 // 565 if (cellEditor != null && !cellEditor.stopCellEditing()) 566 return false; 567 568 if (row < 0 || row >= getRowCount() || 569 column < 0 || column >= getColumnCount()) 570 return false; 571 572 if (!isCellEditable(row, column)) 573 return false; 574 575 // make sure our custom implementation of CellEditorRemover is created 576 if (editorRemover == null) { 577 KeyboardFocusManager fm = 578 KeyboardFocusManager.getCurrentKeyboardFocusManager(); 579 editorRemover = new CellEditorRemover(fm); 580 fm.addPropertyChangeListener("permanentFocusOwner", editorRemover); 581 } 582 583 // delegate to the default implementation 584 return super.editCellAt(row, column, e); 585 } 586 587 @Override 588 public void removeEditor() { 589 // make sure we unregister our custom implementation of CellEditorRemover 590 KeyboardFocusManager.getCurrentKeyboardFocusManager(). 591 removePropertyChangeListener("permanentFocusOwner", editorRemover); 592 editorRemover = null; 593 super.removeEditor(); 594 } 595 596 @Override 597 public void removeNotify() { 598 // make sure we unregister our custom implementation of CellEditorRemover 599 KeyboardFocusManager.getCurrentKeyboardFocusManager(). 600 removePropertyChangeListener("permanentFocusOwner", editorRemover); 601 editorRemover = null; 602 super.removeNotify(); 603 } 604 605 /** 606 * This is a custom implementation of the CellEditorRemover used in JTable 607 * to handle the client property <tt>terminateEditOnFocusLost</tt>. 608 * 609 * This implementation also checks whether focus is transferred to one of a list 610 * of dedicated components, see {@link TagTable#doNotStopCellEditingWhenFocused}. 611 * A typical example for such a component is a button in {@link TagEditorPanel} 612 * which isn't a child component of {@link TagTable} but which should respond to 613 * to focus transfer in a similar way to a child of TagTable. 614 * 615 */ 616 class CellEditorRemover implements PropertyChangeListener { 617 private KeyboardFocusManager focusManager; 618 619 CellEditorRemover(KeyboardFocusManager fm) { 620 this.focusManager = fm; 621 } 622 623 @Override 624 public void propertyChange(PropertyChangeEvent ev) { 625 if (!isEditing()) 626 return; 627 628 Component c = focusManager.getPermanentFocusOwner(); 629 while (c != null) { 630 if (c == TagTable.this) 631 // focus remains inside the table 632 return; 633 if (doNotStopCellEditingWhenFocused.contains(c)) 634 // focus remains on one of the associated components 635 return; 636 else if (c instanceof Window) { 637 if (c == SwingUtilities.getRoot(TagTable.this)) { 638 if (!getCellEditor().stopCellEditing()) { 639 getCellEditor().cancelCellEditing(); 640 } 641 } 642 break; 643 } 644 c = c.getParent(); 645 } 646 } 647 } 648}