001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.widgets;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.GraphicsEnvironment;
007import java.awt.Toolkit;
008import java.awt.event.ActionEvent;
009import java.awt.event.KeyEvent;
010import java.beans.PropertyChangeEvent;
011import java.beans.PropertyChangeListener;
012
013import javax.swing.AbstractAction;
014import javax.swing.Action;
015import javax.swing.ImageIcon;
016import javax.swing.JMenuItem;
017import javax.swing.JPopupMenu;
018import javax.swing.KeyStroke;
019import javax.swing.event.UndoableEditEvent;
020import javax.swing.event.UndoableEditListener;
021import javax.swing.text.DefaultEditorKit;
022import javax.swing.text.JTextComponent;
023import javax.swing.undo.CannotRedoException;
024import javax.swing.undo.CannotUndoException;
025import javax.swing.undo.UndoManager;
026
027import org.openstreetmap.josm.Main;
028import org.openstreetmap.josm.tools.ImageProvider;
029
030/**
031 * A popup menu designed for text components. It displays the following actions:
032 * <ul>
033 * <li>Undo</li>
034 * <li>Redo</li>
035 * <li>Cut</li>
036 * <li>Copy</li>
037 * <li>Paste</li>
038 * <li>Delete</li>
039 * <li>Select All</li>
040 * </ul>
041 * @since 5886
042 */
043public class TextContextualPopupMenu extends JPopupMenu {
044
045    private static final String EDITABLE = "editable";
046
047    protected JTextComponent component;
048    protected boolean undoRedo;
049    protected final UndoAction undoAction = new UndoAction();
050    protected final RedoAction redoAction = new RedoAction();
051    protected final UndoManager undo = new UndoManager();
052
053    protected final transient UndoableEditListener undoEditListener = new UndoableEditListener() {
054        @Override
055        public void undoableEditHappened(UndoableEditEvent e) {
056            undo.addEdit(e.getEdit());
057            undoAction.updateUndoState();
058            redoAction.updateRedoState();
059        }
060    };
061
062    protected final transient PropertyChangeListener propertyChangeListener = new PropertyChangeListener() {
063        @Override
064        public void propertyChange(PropertyChangeEvent evt) {
065            if (EDITABLE.equals(evt.getPropertyName())) {
066                removeAll();
067                addMenuEntries();
068            }
069        }
070    };
071
072    /**
073     * Creates a new {@link TextContextualPopupMenu}.
074     */
075    protected TextContextualPopupMenu() {
076        // Restricts visibility
077    }
078
079    /**
080     * Attaches this contextual menu to the given text component.
081     * A menu can only be attached to a single component.
082     * @param component The text component that will display the menu and handle its actions.
083     * @param undoRedo {@code true} if undo/redo must be supported
084     * @return {@code this}
085     * @see #detach()
086     */
087    protected TextContextualPopupMenu attach(JTextComponent component, boolean undoRedo) {
088        if (component != null && !isAttached()) {
089            this.component = component;
090            this.undoRedo = undoRedo;
091            if (undoRedo && component.isEditable()) {
092                component.getDocument().addUndoableEditListener(undoEditListener);
093                if (!GraphicsEnvironment.isHeadless()) {
094                    component.getInputMap().put(
095                            KeyStroke.getKeyStroke(KeyEvent.VK_Z, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), undoAction);
096                    component.getInputMap().put(
097                            KeyStroke.getKeyStroke(KeyEvent.VK_Y, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), redoAction);
098                }
099            }
100            addMenuEntries();
101            component.addPropertyChangeListener(EDITABLE, propertyChangeListener);
102        }
103        return this;
104    }
105
106    private void addMenuEntries() {
107        if (component.isEditable()) {
108            if (undoRedo) {
109                add(new JMenuItem(undoAction));
110                add(new JMenuItem(redoAction));
111                addSeparator();
112            }
113            addMenuEntry(component, tr("Cut"), DefaultEditorKit.cutAction, null);
114        }
115        addMenuEntry(component, tr("Copy"), DefaultEditorKit.copyAction, "copy");
116        if (component.isEditable()) {
117            addMenuEntry(component, tr("Paste"), DefaultEditorKit.pasteAction, "paste");
118            addMenuEntry(component, tr("Delete"), DefaultEditorKit.deleteNextCharAction, null);
119        }
120        addSeparator();
121        addMenuEntry(component, tr("Select All"), DefaultEditorKit.selectAllAction, null);
122    }
123
124    /**
125     * Detaches this contextual menu from its text component.
126     * @return {@code this}
127     * @see #attach(JTextComponent, boolean)
128     */
129    protected TextContextualPopupMenu detach() {
130        if (isAttached()) {
131            component.removePropertyChangeListener(EDITABLE, propertyChangeListener);
132            removeAll();
133            if (undoRedo) {
134                component.getDocument().removeUndoableEditListener(undoEditListener);
135            }
136            component = null;
137        }
138        return this;
139    }
140
141    /**
142     * Creates a new {@link TextContextualPopupMenu} and enables it for the given text component.
143     * @param component The component that will display the menu and handle its actions.
144     * @param undoRedo Enables or not Undo/Redo feature. Not recommended for table cell editors, unless each cell provides its own editor
145     * @return The {@link PopupMenuLauncher} responsible of displaying the popup menu.
146     *         Call {@link #disableMenuFor} with this object if you want to disable the menu later.
147     * @see #disableMenuFor
148     */
149    public static PopupMenuLauncher enableMenuFor(JTextComponent component, boolean undoRedo) {
150        PopupMenuLauncher launcher = new PopupMenuLauncher(new TextContextualPopupMenu().attach(component, undoRedo), true);
151        component.addMouseListener(launcher);
152        return launcher;
153    }
154
155    /**
156     * Disables the {@link TextContextualPopupMenu} attached to the given popup menu launcher and text component.
157     * @param component The component that currently displays the menu and handles its actions.
158     * @param launcher The {@link PopupMenuLauncher} obtained via {@link #enableMenuFor}.
159     * @see #enableMenuFor
160     */
161    public static void disableMenuFor(JTextComponent component, PopupMenuLauncher launcher) {
162        if (launcher.getMenu() instanceof TextContextualPopupMenu) {
163            ((TextContextualPopupMenu) launcher.getMenu()).detach();
164            component.removeMouseListener(launcher);
165        }
166    }
167
168    /**
169     * Determines if this popup is currently attached to a component.
170     * @return {@code true} if this popup is currently attached to a component, {@code false} otherwise.
171     */
172    public final boolean isAttached() {
173        return component != null;
174    }
175
176    protected void addMenuEntry(JTextComponent component,  String label, String actionName, String iconName) {
177        Action action = component.getActionMap().get(actionName);
178        if (action != null) {
179            JMenuItem mi = new JMenuItem(action);
180            mi.setText(label);
181            if (iconName != null && Main.pref.getBoolean("text.popupmenu.useicons", true)) {
182                ImageIcon icon = new ImageProvider(iconName).setWidth(16).get();
183                if (icon != null) {
184                    mi.setIcon(icon);
185                }
186            }
187            add(mi);
188        }
189    }
190
191    protected class UndoAction extends AbstractAction {
192
193        /**
194         * Constructs a new {@code UndoAction}.
195         */
196        public UndoAction() {
197            super(tr("Undo"));
198            setEnabled(false);
199        }
200
201        @Override
202        public void actionPerformed(ActionEvent e) {
203            try {
204                undo.undo();
205            } catch (CannotUndoException ex) {
206                if (Main.isTraceEnabled()) {
207                    Main.trace(ex.getMessage());
208                }
209            } finally {
210                updateUndoState();
211                redoAction.updateRedoState();
212            }
213        }
214
215        public void updateUndoState() {
216            if (undo.canUndo()) {
217                setEnabled(true);
218                putValue(Action.NAME, undo.getUndoPresentationName());
219            } else {
220                setEnabled(false);
221                putValue(Action.NAME, tr("Undo"));
222            }
223        }
224    }
225
226    protected class RedoAction extends AbstractAction {
227
228        /**
229         * Constructs a new {@code RedoAction}.
230         */
231        public RedoAction() {
232            super(tr("Redo"));
233            setEnabled(false);
234        }
235
236        @Override
237        public void actionPerformed(ActionEvent e) {
238            try {
239                undo.redo();
240            } catch (CannotRedoException ex) {
241                if (Main.isTraceEnabled()) {
242                    Main.trace(ex.getMessage());
243                }
244            } finally {
245                updateRedoState();
246                undoAction.updateUndoState();
247            }
248        }
249
250        public void updateRedoState() {
251            if (undo.canRedo()) {
252                setEnabled(true);
253                putValue(Action.NAME, undo.getRedoPresentationName());
254            } else {
255                setEnabled(false);
256                putValue(Action.NAME, tr("Redo"));
257            }
258        }
259    }
260}