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}