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