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