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 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, PlatformManager.getPlatform().getMenuShortcutKeyMaskEx()), undoAction); 098 component.getInputMap().put( 099 KeyStroke.getKeyStroke(KeyEvent.VK_Y, PlatformManager.getPlatform().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}