// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.gui.tagging; import static org.openstreetmap.josm.tools.I18n.tr; import java.awt.Component; import java.awt.Dimension; import java.awt.KeyboardFocusManager; import java.awt.Window; import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.util.Collections; import java.util.EventObject; import java.util.concurrent.CopyOnWriteArrayList; import javax.swing.AbstractAction; import javax.swing.CellEditor; import javax.swing.JComponent; import javax.swing.JTable; import javax.swing.KeyStroke; import javax.swing.ListSelectionModel; import javax.swing.SwingUtilities; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.text.JTextComponent; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.osm.Relation; import org.openstreetmap.josm.data.osm.TagMap; import org.openstreetmap.josm.gui.datatransfer.OsmTransferHandler; import org.openstreetmap.josm.gui.tagging.TagEditorModel.EndEditListener; import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionList; import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionManager; import org.openstreetmap.josm.gui.widgets.JosmTable; import org.openstreetmap.josm.tools.ImageProvider; /** * This is the tabular editor component for OSM tags. * @since 1762 */ public class TagTable extends JosmTable implements EndEditListener { /** the table cell editor used by this table */ private TagCellEditor editor; private final TagEditorModel model; private Component nextFocusComponent; /** a list of components to which focus can be transferred without stopping * cell editing this table. */ private final CopyOnWriteArrayList<Component> doNotStopCellEditingWhenFocused = new CopyOnWriteArrayList<>(); private transient CellEditorRemover editorRemover; /** * Action to be run when the user navigates to the next cell in the table, * for instance by pressing TAB or ENTER. The action alters the standard * navigation path from cell to cell: * <ul> * <li>it jumps over cells in the first column</li> * <li>it automatically add a new empty row when the user leaves the * last cell in the table</li> * </ul> */ class SelectNextColumnCellAction extends AbstractAction { @Override public void actionPerformed(ActionEvent e) { run(); } public void run() { int col = getSelectedColumn(); int row = getSelectedRow(); if (getCellEditor() != null) { getCellEditor().stopCellEditing(); } if (row == -1 && col == -1) { requestFocusInCell(0, 0); return; } if (col == 0) { col++; } else if (col == 1 && row < getRowCount()-1) { col = 0; row++; } else if (col == 1 && row == getRowCount()-1) { // we are at the end. Append an empty row and move the focus to its second column String key = ((TagModel) model.getValueAt(row, 0)).getName(); if (!key.trim().isEmpty()) { model.appendNewTag(); col = 0; row++; } else { clearSelection(); if (nextFocusComponent != null) nextFocusComponent.requestFocusInWindow(); return; } } requestFocusInCell(row, col); } } /** * Action to be run when the user navigates to the previous cell in the table, * for instance by pressing Shift-TAB */ class SelectPreviousColumnCellAction extends AbstractAction { @Override public void actionPerformed(ActionEvent e) { int col = getSelectedColumn(); int row = getSelectedRow(); if (getCellEditor() != null) { getCellEditor().stopCellEditing(); } if (col <= 0 && row <= 0) { // change nothing } else if (col == 1) { col--; } else { col = 1; row--; } requestFocusInCell(row, col); } } /** * Action to be run when the user invokes a delete action on the table, for * instance by pressing DEL. * * Depending on the shape on the current selection the action deletes individual * values or entire tags from the model. * * If the current selection consists of cells in the second column only, the keys of * the selected tags are set to the empty string. * * If the current selection consists of cell in the third column only, the values of the * selected tags are set to the empty string. * * If the current selection consists of cells in the second and the third column, * the selected tags are removed from the model. * * This action listens to the table selection. It becomes enabled when the selection * is non-empty, otherwise it is disabled. * * */ class DeleteAction extends AbstractAction implements ListSelectionListener { DeleteAction() { new ImageProvider("dialogs", "delete").getResource().attachImageIcon(this); putValue(SHORT_DESCRIPTION, tr("Delete the selection in the tag table")); getSelectionModel().addListSelectionListener(this); getColumnModel().getSelectionModel().addListSelectionListener(this); updateEnabledState(); } /** * delete a selection of tag names */ protected void deleteTagNames() { int[] rows = getSelectedRows(); model.deleteTagNames(rows); } /** * delete a selection of tag values */ protected void deleteTagValues() { int[] rows = getSelectedRows(); model.deleteTagValues(rows); } /** * delete a selection of tags */ protected void deleteTags() { int[] rows = getSelectedRows(); model.deleteTags(rows); } @Override public void actionPerformed(ActionEvent e) { if (!isEnabled()) return; switch(getSelectedColumnCount()) { case 1: if (getSelectedColumn() == 0) { deleteTagNames(); } else if (getSelectedColumn() == 1) { deleteTagValues(); } break; case 2: deleteTags(); break; default: // Do nothing } endCellEditing(); if (model.getRowCount() == 0) { model.ensureOneTag(); requestFocusInCell(0, 0); } } /** * listens to the table selection model */ @Override public void valueChanged(ListSelectionEvent e) { updateEnabledState(); } protected final void updateEnabledState() { if (getSelectedColumnCount() >= 1 && getSelectedRowCount() >= 1) { setEnabled(true); } else { setEnabled(false); } } } /** * Action to be run when the user adds a new tag. * */ class AddAction extends AbstractAction implements PropertyChangeListener { AddAction() { new ImageProvider("dialogs", "add").getResource().attachImageIcon(this); putValue(SHORT_DESCRIPTION, tr("Add a new tag")); TagTable.this.addPropertyChangeListener(this); updateEnabledState(); } @Override public void actionPerformed(ActionEvent e) { CellEditor cEditor = getCellEditor(); if (cEditor != null) { cEditor.stopCellEditing(); } final int rowIdx = model.getRowCount()-1; if (rowIdx < 0 || !((TagModel) model.getValueAt(rowIdx, 0)).getName().trim().isEmpty()) { model.appendNewTag(); } requestFocusInCell(model.getRowCount()-1, 0); } protected final void updateEnabledState() { setEnabled(TagTable.this.isEnabled()); } @Override public void propertyChange(PropertyChangeEvent evt) { updateEnabledState(); } } /** * Action to be run when the user wants to paste tags from buffer */ class PasteAction extends AbstractAction implements PropertyChangeListener { PasteAction() { new ImageProvider("pastetags").getResource().attachImageIcon(this); putValue(SHORT_DESCRIPTION, tr("Paste tags from buffer")); TagTable.this.addPropertyChangeListener(this); updateEnabledState(); } @Override public void actionPerformed(ActionEvent e) { Relation relation = new Relation(); model.applyToPrimitive(relation); new OsmTransferHandler().pasteTags(Collections.singleton(relation)); model.updateTags(new TagMap(relation.getKeys()).getTags()); } protected final void updateEnabledState() { setEnabled(TagTable.this.isEnabled()); } @Override public void propertyChange(PropertyChangeEvent evt) { updateEnabledState(); } } /** the delete action */ private DeleteAction deleteAction; /** the add action */ private AddAction addAction; /** the tag paste action */ private PasteAction pasteAction; /** * Returns the delete action. * @return the delete action used by this table */ public DeleteAction getDeleteAction() { return deleteAction; } /** * Returns the add action. * @return the add action used by this table */ public AddAction getAddAction() { return addAction; } /** * Returns the paste action. * @return the paste action used by this table */ public PasteAction getPasteAction() { return pasteAction; } /** * initialize the table * @param maxCharacters maximum number of characters allowed for keys and values, 0 for unlimited */ protected final void init(final int maxCharacters) { setAutoResizeMode(JTable.AUTO_RESIZE_OFF); setRowSelectionAllowed(true); setColumnSelectionAllowed(true); setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION); // make ENTER behave like TAB // getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0, false), "selectNextColumnCell"); // install custom navigation actions // getActionMap().put("selectNextColumnCell", new SelectNextColumnCellAction()); getActionMap().put("selectPreviousColumnCell", new SelectPreviousColumnCellAction()); // create a delete action. Installing this action in the input and action map // didn't work. We therefore handle delete requests in processKeyBindings(...) // deleteAction = new DeleteAction(); // create the add action // addAction = new AddAction(); getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) .put(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_MASK), "addTag"); getActionMap().put("addTag", addAction); pasteAction = new PasteAction(); // create the table cell editor and set it to key and value columns // TagCellEditor tmpEditor = new TagCellEditor(maxCharacters); setRowHeight(tmpEditor.getEditor().getPreferredSize().height); setTagCellEditor(tmpEditor); } /** * Creates a new tag table * * @param model the tag editor model * @param maxCharacters maximum number of characters allowed for keys and values, 0 for unlimited */ public TagTable(TagEditorModel model, final int maxCharacters) { super(model, new TagTableColumnModelBuilder(new TagCellRenderer(), tr("Key"), tr("Value")) .setSelectionModel(model.getColumnSelectionModel()).build(), model.getRowSelectionModel()); this.model = model; model.setEndEditListener(this); init(maxCharacters); } @Override public Dimension getPreferredSize() { return getPreferredFullWidthSize(); } @Override protected boolean processKeyBinding(KeyStroke ks, KeyEvent e, int condition, boolean pressed) { // handle delete key // if (e.getKeyCode() == KeyEvent.VK_DELETE) { if (isEditing() && getSelectedColumnCount() == 1 && getSelectedRowCount() == 1) // if DEL was pressed and only the currently edited cell is selected, // don't run the delete action. DEL is handled by the CellEditor as normal // DEL in the text input. // return super.processKeyBinding(ks, e, condition, pressed); getDeleteAction().actionPerformed(null); } return super.processKeyBinding(ks, e, condition, pressed); } /** * Sets the editor autocompletion list * @param autoCompletionList autocompletion list */ public void setAutoCompletionList(AutoCompletionList autoCompletionList) { if (autoCompletionList == null) return; if (editor != null) { editor.setAutoCompletionList(autoCompletionList); } } public void setAutoCompletionManager(AutoCompletionManager autocomplete) { if (autocomplete == null) { Main.warn("argument autocomplete should not be null. Aborting."); Thread.dumpStack(); return; } if (editor != null) { editor.setAutoCompletionManager(autocomplete); } } public AutoCompletionList getAutoCompletionList() { if (editor != null) return editor.getAutoCompletionList(); else return null; } /** * Sets the next component to request focus after navigation (with tab or enter). * @param nextFocusComponent next component to request focus after navigation (with tab or enter) */ public void setNextFocusComponent(Component nextFocusComponent) { this.nextFocusComponent = nextFocusComponent; } public TagCellEditor getTableCellEditor() { return editor; } /** * Inject a tag cell editor in the tag table * * @param editor tag cell editor */ public void setTagCellEditor(TagCellEditor editor) { endCellEditing(); this.editor = editor; getColumnModel().getColumn(0).setCellEditor(editor); getColumnModel().getColumn(1).setCellEditor(editor); } public void requestFocusInCell(final int row, final int col) { changeSelection(row, col, false, false); editCellAt(row, col); Component c = getEditorComponent(); if (c != null) { c.requestFocusInWindow(); if (c instanceof JTextComponent) { ((JTextComponent) c).selectAll(); } } // there was a bug here - on older 1.6 Java versions Tab was not working // after such activation. In 1.7 it works OK, // previous solution of using awt.Robot was resetting mouse speed on Windows } public void addComponentNotStoppingCellEditing(Component component) { if (component == null) return; doNotStopCellEditingWhenFocused.addIfAbsent(component); } public void removeComponentNotStoppingCellEditing(Component component) { if (component == null) return; doNotStopCellEditingWhenFocused.remove(component); } @Override public boolean editCellAt(int row, int column, EventObject e) { // a snipped copied from the Java 1.5 implementation of JTable // if (cellEditor != null && !cellEditor.stopCellEditing()) return false; if (row < 0 || row >= getRowCount() || column < 0 || column >= getColumnCount()) return false; if (!isCellEditable(row, column)) return false; // make sure our custom implementation of CellEditorRemover is created if (editorRemover == null) { KeyboardFocusManager fm = KeyboardFocusManager.getCurrentKeyboardFocusManager(); editorRemover = new CellEditorRemover(fm); fm.addPropertyChangeListener("permanentFocusOwner", editorRemover); } // delegate to the default implementation return super.editCellAt(row, column, e); } @Override public void endCellEditing() { if (isEditing()) { CellEditor cEditor = getCellEditor(); if (cEditor != null) { // First attempt to commit. If this does not work, cancel. cEditor.stopCellEditing(); cEditor.cancelCellEditing(); } } } @Override public void removeEditor() { // make sure we unregister our custom implementation of CellEditorRemover KeyboardFocusManager.getCurrentKeyboardFocusManager(). removePropertyChangeListener("permanentFocusOwner", editorRemover); editorRemover = null; super.removeEditor(); } @Override public void removeNotify() { // make sure we unregister our custom implementation of CellEditorRemover KeyboardFocusManager.getCurrentKeyboardFocusManager(). removePropertyChangeListener("permanentFocusOwner", editorRemover); editorRemover = null; super.removeNotify(); } /** * This is a custom implementation of the CellEditorRemover used in JTable * to handle the client property <tt>terminateEditOnFocusLost</tt>. * * This implementation also checks whether focus is transferred to one of a list * of dedicated components, see {@link TagTable#doNotStopCellEditingWhenFocused}. * A typical example for such a component is a button in {@link TagEditorPanel} * which isn't a child component of {@link TagTable} but which should respond to * to focus transfer in a similar way to a child of TagTable. * */ class CellEditorRemover implements PropertyChangeListener { private final KeyboardFocusManager focusManager; CellEditorRemover(KeyboardFocusManager fm) { this.focusManager = fm; } @Override public void propertyChange(PropertyChangeEvent ev) { if (!isEditing()) return; Component c = focusManager.getPermanentFocusOwner(); while (c != null) { if (c == TagTable.this) // focus remains inside the table return; if (doNotStopCellEditingWhenFocused.contains(c)) // focus remains on one of the associated components return; else if (c instanceof Window) { if (c == SwingUtilities.getRoot(TagTable.this) && !getCellEditor().stopCellEditing()) { getCellEditor().cancelCellEditing(); } break; } c = c.getParent(); } } } }