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.spi.preferences.Config;
025import org.openstreetmap.josm.tools.ImageProvider;
026import org.openstreetmap.josm.tools.Logging;
027import org.openstreetmap.josm.tools.PlatformManager;
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        updateUndoRedoState();
055    };
056
057    protected final transient PropertyChangeListener propertyChangeListener = evt -> {
058        if (EDITABLE.equals(evt.getPropertyName())) {
059            removeAll();
060            addMenuEntries();
061        }
062    };
063
064    /**
065     * Creates a new {@link TextContextualPopupMenu}.
066     */
067    protected TextContextualPopupMenu() {
068        // Restricts visibility
069    }
070
071    private void updateUndoRedoState() {
072        undoAction.updateUndoState();
073        redoAction.updateRedoState();
074    }
075
076    /**
077     * Attaches this contextual menu to the given text component.
078     * A menu can only be attached to a single component.
079     * @param component The text component that will display the menu and handle its actions.
080     * @param undoRedo {@code true} if undo/redo must be supported
081     * @return {@code this}
082     * @see #detach()
083     */
084    protected TextContextualPopupMenu attach(JTextComponent component, boolean undoRedo) {
085        if (component != null && !isAttached()) {
086            this.component = component;
087            if (undoRedo && component.isEditable()) {
088                enableUndoRedo();
089            }
090            addMenuEntries();
091            component.addPropertyChangeListener(EDITABLE, propertyChangeListener);
092        }
093        return this;
094    }
095
096    private void enableUndoRedo() {
097        if (!undoRedo) {
098            component.getDocument().addUndoableEditListener(undoEditListener);
099            if (!GraphicsEnvironment.isHeadless()) {
100                component.getInputMap().put(
101                        KeyStroke.getKeyStroke(KeyEvent.VK_Z, PlatformManager.getPlatform().getMenuShortcutKeyMaskEx()), undoAction);
102                component.getInputMap().put(
103                        KeyStroke.getKeyStroke(KeyEvent.VK_Y, PlatformManager.getPlatform().getMenuShortcutKeyMaskEx()), redoAction);
104            }
105            undoRedo = true;
106        }
107    }
108
109    private void disableUndoRedo() {
110        if (undoRedo) {
111            if (!GraphicsEnvironment.isHeadless()) {
112                component.getInputMap().remove(
113                        KeyStroke.getKeyStroke(KeyEvent.VK_Z, PlatformManager.getPlatform().getMenuShortcutKeyMaskEx()));
114                component.getInputMap().remove(
115                        KeyStroke.getKeyStroke(KeyEvent.VK_Y, PlatformManager.getPlatform().getMenuShortcutKeyMaskEx()));
116            }
117            component.getDocument().removeUndoableEditListener(undoEditListener);
118            undoRedo = false;
119        }
120    }
121
122    private void addMenuEntries() {
123        if (component.isEditable()) {
124            if (undoRedo) {
125                addMenuEntry(new JMenuItem(undoAction), "undo");
126                addMenuEntry(new JMenuItem(redoAction), "redo");
127                addSeparator();
128            }
129            addMenuEntry(component, tr("Cut"), DefaultEditorKit.cutAction, /* ICON */ "cut");
130        }
131        addMenuEntry(component, tr("Copy"), DefaultEditorKit.copyAction, /* ICON */ "copy");
132        if (component.isEditable()) {
133            addMenuEntry(component, tr("Paste"), DefaultEditorKit.pasteAction, /* ICON */ "paste");
134            addMenuEntry(component, tr("Delete"), DefaultEditorKit.deleteNextCharAction, /* ICON */ "dialogs/delete");
135        }
136        addSeparator();
137        addMenuEntry(component, tr("Select All"), DefaultEditorKit.selectAllAction, /* ICON */ "dialogs/select");
138    }
139
140    /**
141     * Detaches this contextual menu from its text component.
142     * @return {@code this}
143     * @see #attach(JTextComponent, boolean)
144     */
145    protected TextContextualPopupMenu detach() {
146        if (isAttached()) {
147            component.removePropertyChangeListener(EDITABLE, propertyChangeListener);
148            removeAll();
149            if (undoRedo) {
150                disableUndoRedo();
151            }
152            component = null;
153        }
154        return this;
155    }
156
157    /**
158     * Creates a new {@link TextContextualPopupMenu} and enables it for the given text component.
159     * @param component The component that will display the menu and handle its actions.
160     * @param undoRedo Enables or not Undo/Redo feature. Not recommended for table cell editors, unless each cell provides its own editor
161     * @return The {@link PopupMenuLauncher} responsible of displaying the popup menu.
162     *         Call {@link #disableMenuFor} with this object if you want to disable the menu later.
163     * @see #disableMenuFor
164     */
165    public static PopupMenuLauncher enableMenuFor(JTextComponent component, boolean undoRedo) {
166        PopupMenuLauncher launcher = new PopupMenuLauncher(new TextContextualPopupMenu().attach(component, undoRedo), true);
167        component.addMouseListener(launcher);
168        return launcher;
169    }
170
171    /**
172     * Disables the {@link TextContextualPopupMenu} attached to the given popup menu launcher and text component.
173     * @param component The component that currently displays the menu and handles its actions.
174     * @param launcher The {@link PopupMenuLauncher} obtained via {@link #enableMenuFor}.
175     * @see #enableMenuFor
176     */
177    public static void disableMenuFor(JTextComponent component, PopupMenuLauncher launcher) {
178        if (launcher.getMenu() instanceof TextContextualPopupMenu) {
179            ((TextContextualPopupMenu) launcher.getMenu()).detach();
180            component.removeMouseListener(launcher);
181        }
182    }
183
184    /**
185     * Empties the internal undo manager.
186     * @since 14977
187     */
188    public void discardAllUndoableEdits() {
189        undo.discardAllEdits();
190        updateUndoRedoState();
191    }
192
193    /**
194     * Determines if this popup is currently attached to a component.
195     * @return {@code true} if this popup is currently attached to a component, {@code false} otherwise.
196     */
197    public final boolean isAttached() {
198        return component != null;
199    }
200
201    protected void addMenuEntry(JTextComponent component, String label, String actionName, String iconName) {
202        Action action = component.getActionMap().get(actionName);
203        if (action != null) {
204            JMenuItem mi = new JMenuItem(action);
205            mi.setText(label);
206            addMenuEntry(mi, iconName);
207        }
208    }
209
210    protected void addMenuEntry(JMenuItem mi, String iconName) {
211        if (iconName != null && Config.getPref().getBoolean("text.popupmenu.useicons", true)) {
212            ImageIcon icon = new ImageProvider(iconName).setSize(ImageProvider.ImageSizes.SMALLICON).get();
213            mi.setIcon(icon);
214        }
215        add(mi);
216    }
217
218    protected class UndoAction extends AbstractAction {
219
220        /**
221         * Constructs a new {@code UndoAction}.
222         */
223        public UndoAction() {
224            super(tr("Undo"));
225            setEnabled(false);
226        }
227
228        @Override
229        public void actionPerformed(ActionEvent e) {
230            try {
231                undo.undo();
232            } catch (CannotUndoException ex) {
233                Logging.trace(ex);
234            } finally {
235                updateUndoState();
236                redoAction.updateRedoState();
237            }
238        }
239
240        public void updateUndoState() {
241            if (undo.canUndo()) {
242                setEnabled(true);
243                putValue(Action.NAME, undo.getUndoPresentationName());
244            } else {
245                setEnabled(false);
246                putValue(Action.NAME, tr("Undo"));
247            }
248        }
249    }
250
251    protected class RedoAction extends AbstractAction {
252
253        /**
254         * Constructs a new {@code RedoAction}.
255         */
256        public RedoAction() {
257            super(tr("Redo"));
258            new ImageProvider("redo").getResource().attachImageIcon(this);
259            setEnabled(false);
260        }
261
262        @Override
263        public void actionPerformed(ActionEvent e) {
264            try {
265                undo.redo();
266            } catch (CannotRedoException ex) {
267                Logging.trace(ex);
268            } finally {
269                updateRedoState();
270                undoAction.updateUndoState();
271            }
272        }
273
274        public void updateRedoState() {
275            if (undo.canRedo()) {
276                setEnabled(true);
277                putValue(Action.NAME, undo.getRedoPresentationName());
278            } else {
279                setEnabled(false);
280                putValue(Action.NAME, tr("Redo"));
281            }
282        }
283    }
284}