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     * @return {@code this}
084     * @see #detach()
085     */
086    protected TextContextualPopupMenu attach(JTextComponent component, boolean undoRedo) {
087        if (component != null && !isAttached()) {
088            this.component = component;
089            this.undoRedo = undoRedo;
090            if (undoRedo && component.isEditable()) {
091                component.getDocument().addUndoableEditListener(undoEditListener);
092                if (!GraphicsEnvironment.isHeadless()) {
093                    component.getInputMap().put(
094                            KeyStroke.getKeyStroke(KeyEvent.VK_Z, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), undoAction);
095                    component.getInputMap().put(
096                            KeyStroke.getKeyStroke(KeyEvent.VK_Y, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), redoAction);
097                }
098            }
099            addMenuEntries();
100            component.addPropertyChangeListener(EDITABLE, propertyChangeListener);
101        }
102        return this;
103    }
104
105    private void addMenuEntries() {
106        if (component.isEditable()) {
107            if (undoRedo) {
108                add(new JMenuItem(undoAction));
109                add(new JMenuItem(redoAction));
110                addSeparator();
111            }
112            addMenuEntry(component, tr("Cut"), DefaultEditorKit.cutAction, null);
113        }
114        addMenuEntry(component, tr("Copy"), DefaultEditorKit.copyAction, "copy");
115        if (component.isEditable()) {
116            addMenuEntry(component, tr("Paste"), DefaultEditorKit.pasteAction, "paste");
117            addMenuEntry(component, tr("Delete"), DefaultEditorKit.deleteNextCharAction, null);
118        }
119        addSeparator();
120        addMenuEntry(component, tr("Select All"), DefaultEditorKit.selectAllAction, null);
121    }
122
123    /**
124     * Detaches this contextual menu from its text component.
125     * @return {@code this}
126     * @see #attach(JTextComponent, boolean)
127     */
128    protected TextContextualPopupMenu detach() {
129        if (isAttached()) {
130            component.removePropertyChangeListener(EDITABLE, propertyChangeListener);
131            removeAll();
132            if (undoRedo) {
133                component.getDocument().removeUndoableEditListener(undoEditListener);
134            }
135            component = null;
136        }
137        return this;
138    }
139
140    /**
141     * Creates a new {@link TextContextualPopupMenu} and enables it for the given text component.
142     * @param component The component that will display the menu and handle its actions.
143     * @param undoRedo Enables or not Undo/Redo feature. Not recommended for table cell editors, unless each cell provides its own editor
144     * @return The {@link PopupMenuLauncher} responsible of displaying the popup menu.
145     *         Call {@link #disableMenuFor} with this object if you want to disable the menu later.
146     * @see #disableMenuFor
147     */
148    public static PopupMenuLauncher enableMenuFor(JTextComponent component, boolean undoRedo) {
149        PopupMenuLauncher launcher = new PopupMenuLauncher(new TextContextualPopupMenu().attach(component, undoRedo), true);
150        component.addMouseListener(launcher);
151        return launcher;
152    }
153
154    /**
155     * Disables the {@link TextContextualPopupMenu} attached to the given popup menu launcher and text component.
156     * @param component The component that currently displays the menu and handles its actions.
157     * @param launcher The {@link PopupMenuLauncher} obtained via {@link #enableMenuFor}.
158     * @see #enableMenuFor
159     */
160    public static void disableMenuFor(JTextComponent component, PopupMenuLauncher launcher) {
161        if (launcher.getMenu() instanceof TextContextualPopupMenu) {
162            ((TextContextualPopupMenu) launcher.getMenu()).detach();
163            component.removeMouseListener(launcher);
164        }
165    }
166
167    /**
168     * Determines if this popup is currently attached to a component.
169     * @return {@code true} if this popup is currently attached to a component, {@code false} otherwise.
170     */
171    public final boolean isAttached() {
172        return component != null;
173    }
174
175    protected void addMenuEntry(JTextComponent component,  String label, String actionName, String iconName) {
176        Action action = component.getActionMap().get(actionName);
177        if (action != null) {
178            JMenuItem mi = new JMenuItem(action);
179            mi.setText(label);
180            if (iconName != null && Main.pref.getBoolean("text.popupmenu.useicons", true)) {
181                ImageIcon icon = new ImageProvider(iconName).setWidth(16).get();
182                if (icon != null) {
183                    mi.setIcon(icon);
184                }
185            }
186            add(mi);
187        }
188    }
189
190    protected class UndoAction extends AbstractAction {
191
192        /**
193         * Constructs a new {@code UndoAction}.
194         */
195        public UndoAction() {
196            super(tr("Undo"));
197            setEnabled(false);
198        }
199
200        @Override
201        public void actionPerformed(ActionEvent e) {
202            try {
203                undo.undo();
204            } catch (CannotUndoException ex) {
205                if (Main.isTraceEnabled()) {
206                    Main.trace(ex.getMessage());
207                }
208            } finally {
209                updateUndoState();
210                redoAction.updateRedoState();
211            }
212        }
213
214        public void updateUndoState() {
215            if (undo.canUndo()) {
216                setEnabled(true);
217                putValue(Action.NAME, undo.getUndoPresentationName());
218            } else {
219                setEnabled(false);
220                putValue(Action.NAME, tr("Undo"));
221            }
222        }
223    }
224
225    protected class RedoAction extends AbstractAction {
226
227        /**
228         * Constructs a new {@code RedoAction}.
229         */
230        public RedoAction() {
231            super(tr("Redo"));
232            setEnabled(false);
233        }
234
235        @Override
236        public void actionPerformed(ActionEvent e) {
237            try {
238                undo.redo();
239            } catch (CannotRedoException ex) {
240                if (Main.isTraceEnabled()) {
241                    Main.trace(ex.getMessage());
242                }
243            } finally {
244                updateRedoState();
245                undoAction.updateUndoState();
246            }
247        }
248
249        public void updateRedoState() {
250            if (undo.canRedo()) {
251                setEnabled(true);
252                putValue(Action.NAME, undo.getRedoPresentationName());
253            } else {
254                setEnabled(false);
255                putValue(Action.NAME, tr("Redo"));
256            }
257        }
258    }
259}