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