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 if (rowIdx < 0 || !((TagModel) model.getValueAt(rowIdx, 0)).getName().trim().isEmpty()) { 301 model.appendNewTag(); 302 } 303 requestFocusInCell(model.getRowCount()-1, 0); 304 } 305 306 protected final void updateEnabledState() { 307 setEnabled(TagTable.this.isEnabled()); 308 } 309 310 @Override 311 public void propertyChange(PropertyChangeEvent evt) { 312 updateEnabledState(); 313 } 314 } 315 316 /** 317 * Action to be run when the user wants to paste tags from buffer 318 */ 319 class PasteAction extends RunnableAction implements PropertyChangeListener { 320 PasteAction() { 321 putValue(SMALL_ICON, ImageProvider.get("", "pastetags")); 322 putValue(SHORT_DESCRIPTION, tr("Paste tags from buffer")); 323 TagTable.this.addPropertyChangeListener(this); 324 updateEnabledState(); 325 } 326 327 @Override 328 public void run() { 329 Relation relation = new Relation(); 330 model.applyToPrimitive(relation); 331 332 String buf = Utils.getClipboardContent(); 333 if (buf == null || buf.isEmpty() || buf.matches(CopyAction.CLIPBOARD_REGEXP)) { 334 List<PrimitiveData> directlyAdded = Main.pasteBuffer.getDirectlyAdded(); 335 if (directlyAdded == null || directlyAdded.isEmpty()) return; 336 PasteTagsAction.TagPaster tagPaster = new PasteTagsAction.TagPaster(directlyAdded, 337 Collections.<OsmPrimitive>singletonList(relation)); 338 model.updateTags(tagPaster.execute()); 339 } else { 340 // Paste tags from arbitrary text 341 Map<String, String> tags = TextTagParser.readTagsFromText(buf); 342 if (tags == null || tags.isEmpty()) { 343 TextTagParser.showBadBufferMessage(ht("/Action/PasteTags")); 344 } else if (TextTagParser.validateTags(tags)) { 345 List<Tag> newTags = new ArrayList<>(); 346 for (Map.Entry<String, String> entry: tags.entrySet()) { 347 String k = entry.getKey(); 348 String v = entry.getValue(); 349 newTags.add(new Tag(k, v)); 350 } 351 model.updateTags(newTags); 352 } 353 } 354 } 355 356 protected final void updateEnabledState() { 357 setEnabled(TagTable.this.isEnabled()); 358 } 359 360 @Override 361 public void propertyChange(PropertyChangeEvent evt) { 362 updateEnabledState(); 363 } 364 } 365 366 /** the delete action */ 367 private RunnableAction deleteAction; 368 369 /** the add action */ 370 private RunnableAction addAction; 371 372 /** the tag paste action */ 373 private RunnableAction pasteAction; 374 375 /** 376 * 377 * @return the delete action used by this table 378 */ 379 public RunnableAction getDeleteAction() { 380 return deleteAction; 381 } 382 383 public RunnableAction getAddAction() { 384 return addAction; 385 } 386 387 public RunnableAction getPasteAction() { 388 return pasteAction; 389 } 390 391 /** 392 * initialize the table 393 * @param maxCharacters maximum number of characters allowed for keys and values, 0 for unlimited 394 */ 395 protected final void init(final int maxCharacters) { 396 setAutoResizeMode(JTable.AUTO_RESIZE_OFF); 397 setRowSelectionAllowed(true); 398 setColumnSelectionAllowed(true); 399 setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION); 400 401 // make ENTER behave like TAB 402 // 403 getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) 404 .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0, false), "selectNextColumnCell"); 405 406 // install custom navigation actions 407 // 408 getActionMap().put("selectNextColumnCell", new SelectNextColumnCellAction()); 409 getActionMap().put("selectPreviousColumnCell", new SelectPreviousColumnCellAction()); 410 411 // create a delete action. Installing this action in the input and action map 412 // didn't work. We therefore handle delete requests in processKeyBindings(...) 413 // 414 deleteAction = new DeleteAction(); 415 416 // create the add action 417 // 418 addAction = new AddAction(); 419 getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) 420 .put(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_MASK), "addTag"); 421 getActionMap().put("addTag", addAction); 422 423 pasteAction = new PasteAction(); 424 425 // create the table cell editor and set it to key and value columns 426 // 427 TagCellEditor tmpEditor = new TagCellEditor(maxCharacters); 428 setRowHeight(tmpEditor.getEditor().getPreferredSize().height); 429 setTagCellEditor(tmpEditor); 430 } 431 432 /** 433 * Creates a new tag table 434 * 435 * @param model the tag editor model 436 * @param maxCharacters maximum number of characters allowed for keys and values, 0 for unlimited 437 */ 438 public TagTable(TagEditorModel model, final int maxCharacters) { 439 super(model, new TagTableColumnModel(model.getColumnSelectionModel()), model.getRowSelectionModel()); 440 this.model = model; 441 init(maxCharacters); 442 } 443 444 @Override 445 public Dimension getPreferredSize() { 446 Container c = getParent(); 447 while (c != null && !(c instanceof JViewport)) { 448 c = c.getParent(); 449 } 450 if (c != null) { 451 Dimension d = super.getPreferredSize(); 452 d.width = c.getSize().width; 453 return d; 454 } 455 return super.getPreferredSize(); 456 } 457 458 @Override protected boolean processKeyBinding(KeyStroke ks, KeyEvent e, 459 int condition, boolean pressed) { 460 461 // handle delete key 462 // 463 if (e.getKeyCode() == KeyEvent.VK_DELETE) { 464 if (isEditing() && getSelectedColumnCount() == 1 && getSelectedRowCount() == 1) 465 // if DEL was pressed and only the currently edited cell is selected, 466 // don't run the delete action. DEL is handled by the CellEditor as normal 467 // DEL in the text input. 468 // 469 return super.processKeyBinding(ks, e, condition, pressed); 470 getDeleteAction().run(); 471 } 472 return super.processKeyBinding(ks, e, condition, pressed); 473 } 474 475 /** 476 * Sets the editor autocompletion list 477 * @param autoCompletionList autocompletion list 478 */ 479 public void setAutoCompletionList(AutoCompletionList autoCompletionList) { 480 if (autoCompletionList == null) 481 return; 482 if (editor != null) { 483 editor.setAutoCompletionList(autoCompletionList); 484 } 485 } 486 487 public void setAutoCompletionManager(AutoCompletionManager autocomplete) { 488 if (autocomplete == null) { 489 Main.warn("argument autocomplete should not be null. Aborting."); 490 Thread.dumpStack(); 491 return; 492 } 493 if (editor != null) { 494 editor.setAutoCompletionManager(autocomplete); 495 } 496 } 497 498 public AutoCompletionList getAutoCompletionList() { 499 if (editor != null) 500 return editor.getAutoCompletionList(); 501 else 502 return null; 503 } 504 505 /** 506 * Sets the next component to request focus after navigation (with tab or enter). 507 * @param nextFocusComponent next component to request focus after navigation (with tab or enter) 508 */ 509 public void setNextFocusComponent(Component nextFocusComponent) { 510 this.nextFocusComponent = nextFocusComponent; 511 } 512 513 public TagCellEditor getTableCellEditor() { 514 return editor; 515 } 516 517 public void addOKAccelatorListener(KeyListener l) { 518 addKeyListener(l); 519 if (editor != null) { 520 editor.getEditor().addKeyListener(l); 521 } 522 } 523 524 /** 525 * Inject a tag cell editor in the tag table 526 * 527 * @param editor tag cell editor 528 */ 529 public void setTagCellEditor(TagCellEditor editor) { 530 if (isEditing()) { 531 this.editor.cancelCellEditing(); 532 } 533 this.editor = editor; 534 getColumnModel().getColumn(0).setCellEditor(editor); 535 getColumnModel().getColumn(1).setCellEditor(editor); 536 } 537 538 public void requestFocusInCell(final int row, final int col) { 539 changeSelection(row, col, false, false); 540 editCellAt(row, col); 541 Component c = getEditorComponent(); 542 if (c != null) { 543 c.requestFocusInWindow(); 544 if (c instanceof JTextComponent) { 545 ((JTextComponent) c).selectAll(); 546 } 547 } 548 // there was a bug here - on older 1.6 Java versions Tab was not working 549 // after such activation. In 1.7 it works OK, 550 // previous solution of using awt.Robot was resetting mouse speed on Windows 551 } 552 553 public void addComponentNotStoppingCellEditing(Component component) { 554 if (component == null) return; 555 doNotStopCellEditingWhenFocused.addIfAbsent(component); 556 } 557 558 public void removeComponentNotStoppingCellEditing(Component component) { 559 if (component == null) return; 560 doNotStopCellEditingWhenFocused.remove(component); 561 } 562 563 @Override 564 public boolean editCellAt(int row, int column, EventObject e) { 565 566 // a snipped copied from the Java 1.5 implementation of JTable 567 // 568 if (cellEditor != null && !cellEditor.stopCellEditing()) 569 return false; 570 571 if (row < 0 || row >= getRowCount() || 572 column < 0 || column >= getColumnCount()) 573 return false; 574 575 if (!isCellEditable(row, column)) 576 return false; 577 578 // make sure our custom implementation of CellEditorRemover is created 579 if (editorRemover == null) { 580 KeyboardFocusManager fm = 581 KeyboardFocusManager.getCurrentKeyboardFocusManager(); 582 editorRemover = new CellEditorRemover(fm); 583 fm.addPropertyChangeListener("permanentFocusOwner", editorRemover); 584 } 585 586 // delegate to the default implementation 587 return super.editCellAt(row, column, e); 588 } 589 590 @Override 591 public void removeEditor() { 592 // make sure we unregister our custom implementation of CellEditorRemover 593 KeyboardFocusManager.getCurrentKeyboardFocusManager(). 594 removePropertyChangeListener("permanentFocusOwner", editorRemover); 595 editorRemover = null; 596 super.removeEditor(); 597 } 598 599 @Override 600 public void removeNotify() { 601 // make sure we unregister our custom implementation of CellEditorRemover 602 KeyboardFocusManager.getCurrentKeyboardFocusManager(). 603 removePropertyChangeListener("permanentFocusOwner", editorRemover); 604 editorRemover = null; 605 super.removeNotify(); 606 } 607 608 /** 609 * This is a custom implementation of the CellEditorRemover used in JTable 610 * to handle the client property <tt>terminateEditOnFocusLost</tt>. 611 * 612 * This implementation also checks whether focus is transferred to one of a list 613 * of dedicated components, see {@link TagTable#doNotStopCellEditingWhenFocused}. 614 * A typical example for such a component is a button in {@link TagEditorPanel} 615 * which isn't a child component of {@link TagTable} but which should respond to 616 * to focus transfer in a similar way to a child of TagTable. 617 * 618 */ 619 class CellEditorRemover implements PropertyChangeListener { 620 private final KeyboardFocusManager focusManager; 621 622 CellEditorRemover(KeyboardFocusManager fm) { 623 this.focusManager = fm; 624 } 625 626 @Override 627 public void propertyChange(PropertyChangeEvent ev) { 628 if (!isEditing()) 629 return; 630 631 Component c = focusManager.getPermanentFocusOwner(); 632 while (c != null) { 633 if (c == TagTable.this) 634 // focus remains inside the table 635 return; 636 if (doNotStopCellEditingWhenFocused.contains(c)) 637 // focus remains on one of the associated components 638 return; 639 else if (c instanceof Window) { 640 if (c == SwingUtilities.getRoot(TagTable.this)) { 641 if (!getCellEditor().stopCellEditing()) { 642 getCellEditor().cancelCellEditing(); 643 } 644 } 645 break; 646 } 647 c = c.getParent(); 648 } 649 } 650 } 651}