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