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