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.Main;
027import org.openstreetmap.josm.spi.preferences.Config;
028import org.openstreetmap.josm.tools.ImageProvider;
029import org.openstreetmap.josm.tools.Logging;
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        undoAction.updateUndoState();
064        redoAction.updateRedoState();
065    };
066
067    protected final transient PropertyChangeListener propertyChangeListener = evt -> {
068        if (EDITABLE.equals(evt.getPropertyName())) {
069            removeAll();
070            addMenuEntries();
071        }
072    };
073
074    /**
075     * Creates a new {@link TextContextualPopupMenu}.
076     */
077    protected TextContextualPopupMenu() {
078        // Restricts visibility
079    }
080
081    /**
082     * Attaches this contextual menu to the given text component.
083     * A menu can only be attached to a single component.
084     * @param component The text component that will display the menu and handle its actions.
085     * @param undoRedo {@code true} if undo/redo must be supported
086     * @return {@code this}
087     * @see #detach()
088     */
089    protected TextContextualPopupMenu attach(JTextComponent component, boolean undoRedo) {
090        if (component != null && !isAttached()) {
091            this.component = component;
092            this.undoRedo = undoRedo;
093            if (undoRedo && component.isEditable()) {
094                component.getDocument().addUndoableEditListener(undoEditListener);
095                if (!GraphicsEnvironment.isHeadless()) {
096                    component.getInputMap().put(
097                            KeyStroke.getKeyStroke(KeyEvent.VK_Z, Main.platform.getMenuShortcutKeyMaskEx()), undoAction);
098                    component.getInputMap().put(
099                            KeyStroke.getKeyStroke(KeyEvent.VK_Y, Main.platform.getMenuShortcutKeyMaskEx()), redoAction);
100                }
101            }
102            addMenuEntries();
103            component.addPropertyChangeListener(EDITABLE, propertyChangeListener);
104        }
105        return this;
106    }
107
108    private void addMenuEntries() {
109        if (component.isEditable()) {
110            if (undoRedo) {
111                add(new JMenuItem(undoAction));
112                add(new JMenuItem(redoAction));
113                addSeparator();
114            }
115            addMenuEntry(component, tr("Cut"), DefaultEditorKit.cutAction, null);
116        }
117        addMenuEntry(component, tr("Copy"), DefaultEditorKit.copyAction, "copy");
118        if (component.isEditable()) {
119            addMenuEntry(component, tr("Paste"), DefaultEditorKit.pasteAction, "paste");
120            addMenuEntry(component, tr("Delete"), DefaultEditorKit.deleteNextCharAction, null);
121        }
122        addSeparator();
123        addMenuEntry(component, tr("Select All"), DefaultEditorKit.selectAllAction, null);
124    }
125
126    /**
127     * Detaches this contextual menu from its text component.
128     * @return {@code this}
129     * @see #attach(JTextComponent, boolean)
130     */
131    protected TextContextualPopupMenu detach() {
132        if (isAttached()) {
133            component.removePropertyChangeListener(EDITABLE, propertyChangeListener);
134            removeAll();
135            if (undoRedo) {
136                component.getDocument().removeUndoableEditListener(undoEditListener);
137            }
138            component = null;
139        }
140        return this;
141    }
142
143    /**
144     * Creates a new {@link TextContextualPopupMenu} and enables it for the given text component.
145     * @param component The component that will display the menu and handle its actions.
146     * @param undoRedo Enables or not Undo/Redo feature. Not recommended for table cell editors, unless each cell provides its own editor
147     * @return The {@link PopupMenuLauncher} responsible of displaying the popup menu.
148     *         Call {@link #disableMenuFor} with this object if you want to disable the menu later.
149     * @see #disableMenuFor
150     */
151    public static PopupMenuLauncher enableMenuFor(JTextComponent component, boolean undoRedo) {
152        PopupMenuLauncher launcher = new PopupMenuLauncher(new TextContextualPopupMenu().attach(component, undoRedo), true);
153        component.addMouseListener(launcher);
154        return launcher;
155    }
156
157    /**
158     * Disables the {@link TextContextualPopupMenu} attached to the given popup menu launcher and text component.
159     * @param component The component that currently displays the menu and handles its actions.
160     * @param launcher The {@link PopupMenuLauncher} obtained via {@link #enableMenuFor}.
161     * @see #enableMenuFor
162     */
163    public static void disableMenuFor(JTextComponent component, PopupMenuLauncher launcher) {
164        if (launcher.getMenu() instanceof TextContextualPopupMenu) {
165            ((TextContextualPopupMenu) launcher.getMenu()).detach();
166            component.removeMouseListener(launcher);
167        }
168    }
169
170    /**
171     * Determines if this popup is currently attached to a component.
172     * @return {@code true} if this popup is currently attached to a component, {@code false} otherwise.
173     */
174    public final boolean isAttached() {
175        return component != null;
176    }
177
178    protected void addMenuEntry(JTextComponent component, String label, String actionName, String iconName) {
179        Action action = component.getActionMap().get(actionName);
180        if (action != null) {
181            JMenuItem mi = new JMenuItem(action);
182            mi.setText(label);
183            if (iconName != null && Config.getPref().getBoolean("text.popupmenu.useicons", true)) {
184                ImageIcon icon = loadIcon(iconName);
185                if (icon != null) {
186                    mi.setIcon(icon);
187                }
188            }
189            add(mi);
190        }
191    }
192
193    protected class UndoAction extends AbstractAction {
194
195        /**
196         * Constructs a new {@code UndoAction}.
197         */
198        public UndoAction() {
199            super(tr("Undo"));
200            setEnabled(false);
201        }
202
203        @Override
204        public void actionPerformed(ActionEvent e) {
205            try {
206                undo.undo();
207            } catch (CannotUndoException ex) {
208                Logging.trace(ex);
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                Logging.trace(ex);
242            } finally {
243                updateRedoState();
244                undoAction.updateUndoState();
245            }
246        }
247
248        public void updateRedoState() {
249            if (undo.canRedo()) {
250                setEnabled(true);
251                putValue(Action.NAME, undo.getRedoPresentationName());
252            } else {
253                setEnabled(false);
254                putValue(Action.NAME, tr("Redo"));
255            }
256        }
257    }
258}