package org.jabref.gui.keyboard; import java.awt.event.ActionEvent; import java.awt.event.InputEvent; import java.awt.event.KeyEvent; import java.util.Collections; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Locale; import javax.swing.Action; import javax.swing.JEditorPane; import javax.swing.JTextArea; import javax.swing.JTextField; import javax.swing.JTextPane; import javax.swing.KeyStroke; import javax.swing.text.BadLocationException; import javax.swing.text.DefaultEditorKit; import javax.swing.text.Document; import javax.swing.text.JTextComponent; import javax.swing.text.Keymap; import javax.swing.text.TextAction; import javax.swing.text.Utilities; import org.jabref.preferences.JabRefPreferences; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; /** * Generic class which activates Emacs keybindings for java input {@link * JTextComponent}s. * * The inner class actions can also be used independently. */ public class EmacsKeyBindings { private static final Log LOGGER = LogFactory.getLog(EmacsKeyBindings.class); private static final String KILL_LINE_ACTION = "emacs-kill-line"; private static final String KILL_RING_SAVE_ACTION = "emacs-kill-ring-save"; private static final String KILL_REGION_ACTION = "emacs-kill-region"; private static final String BACKWARD_KILL_WORD_ACTION = "emacs-backward-kill-word"; private static final String CAPITALIZE_WORD_ACTION = "emacs-capitalize-word"; private static final String DOWNCASE_WORD_ACTION = "emacs-downcase-word"; private static final String KILL_WORD_ACTION = "emacs-kill-word"; private static final String SET_MARK_COMMAND_ACTION = "emacs-set-mark-command"; private static final String YANK_ACTION = "emacs-yank"; private static final String YANK_POP_ACTION = "emacs-yank-pop"; private static final String UPCASE_WORD_ACTION = "emacs-upcase-word"; private static final JTextComponent.KeyBinding[] EMACS_KEY_BINDINGS_BASE = { new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.CTRL_MASK), DefaultEditorKit.endLineAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_D, InputEvent.CTRL_MASK), DefaultEditorKit.deleteNextCharAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_MASK), DefaultEditorKit.downAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_P, InputEvent.CTRL_MASK), DefaultEditorKit.upAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_B, InputEvent.ALT_MASK), DefaultEditorKit.previousWordAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_LESS, InputEvent.ALT_MASK), DefaultEditorKit.beginAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_LESS, InputEvent.ALT_MASK + InputEvent.SHIFT_MASK), DefaultEditorKit.endAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.ALT_MASK), DefaultEditorKit.nextWordAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_B, InputEvent.CTRL_MASK), DefaultEditorKit.backwardAction), // CTRL+V and ALT+V are disabled as CTRL+V is also "paste" // new JTextComponent. // KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_V, // InputEvent.CTRL_MASK), // DefaultEditorKit.pageDownAction), // new JTextComponent. // KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_V, // InputEvent.ALT_MASK), // DefaultEditorKit.pageUpAction), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_D, InputEvent.ALT_MASK), EmacsKeyBindings.KILL_WORD_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_BACK_SPACE, InputEvent.ALT_MASK), EmacsKeyBindings.BACKWARD_KILL_WORD_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.CTRL_MASK), EmacsKeyBindings.SET_MARK_COMMAND_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_W, InputEvent.ALT_MASK), EmacsKeyBindings.KILL_RING_SAVE_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_W, InputEvent.CTRL_MASK), EmacsKeyBindings.KILL_REGION_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_K, InputEvent.CTRL_MASK), EmacsKeyBindings.KILL_LINE_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_Y, InputEvent.CTRL_MASK), EmacsKeyBindings.YANK_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_Y, InputEvent.ALT_MASK), EmacsKeyBindings.YANK_POP_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.ALT_MASK), EmacsKeyBindings.CAPITALIZE_WORD_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_L, InputEvent.ALT_MASK), EmacsKeyBindings.DOWNCASE_WORD_ACTION), new JTextComponent. KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_U, InputEvent.ALT_MASK), EmacsKeyBindings.UPCASE_WORD_ACTION), }; private static final JTextComponent.KeyBinding EMACS_KEY_BINDING_C_A = new JTextComponent.KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.CTRL_MASK), DefaultEditorKit.beginLineAction); private static final JTextComponent.KeyBinding EMACS_KEY_BINDING_C_F = new JTextComponent.KeyBinding(KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_MASK), DefaultEditorKit.forwardAction); private static final TextAction[] EMACS_ACTIONS = { new KillWordAction(EmacsKeyBindings.KILL_WORD_ACTION), new BackwardKillWordAction(EmacsKeyBindings.BACKWARD_KILL_WORD_ACTION), new SetMarkCommandAction(EmacsKeyBindings.SET_MARK_COMMAND_ACTION), new KillRingSaveAction(EmacsKeyBindings.KILL_RING_SAVE_ACTION), new KillRegionAction(EmacsKeyBindings.KILL_REGION_ACTION), new KillLineAction(EmacsKeyBindings.KILL_LINE_ACTION), new YankAction(EmacsKeyBindings.YANK_ACTION), new YankPopAction(EmacsKeyBindings.YANK_POP_ACTION), new CapitalizeWordAction(EmacsKeyBindings.CAPITALIZE_WORD_ACTION), new DowncaseWordAction(EmacsKeyBindings.DOWNCASE_WORD_ACTION), new UpcaseWordAction(EmacsKeyBindings.UPCASE_WORD_ACTION) }; // components to modify private static final JTextComponent[] JTCS = new JTextComponent[]{ new JTextArea(), new JTextPane(), new JTextField(), new JEditorPane(), }; private EmacsKeyBindings() { } /** * Loads the emacs keybindings for all common <code>JTextComponent</code>s. * * The shared keymap instances of the concrete subclasses of * {@link JTextComponent} are fed with the keybindings. * * The original keybindings are stored in a backup array. */ public static void load() { EmacsKeyBindings.createBackup(); EmacsKeyBindings.loadEmacsKeyBindings(); } private static void createBackup() { Keymap oldBackup = JTextComponent.getKeymap(EmacsKeyBindings.JTCS[0].getClass().getName()); if (oldBackup != null) { // if there is already a backup, do not create a new backup return; } for (JTextComponent jtc : EmacsKeyBindings.JTCS) { Keymap orig = jtc.getKeymap(); Keymap backup = JTextComponent.addKeymap (jtc.getClass().getName(), null); Action[] bound = orig.getBoundActions(); for (Action aBound : bound) { KeyStroke[] strokes = orig.getKeyStrokesForAction(aBound); for (KeyStroke stroke : strokes) { backup.addActionForKeyStroke(stroke, aBound); } } backup.setDefaultAction(orig.getDefaultAction()); } } /** * Restores the original keybindings for the concrete subclasses of * {@link JTextComponent}. */ public static void unload() { for (int i = 0; i < EmacsKeyBindings.JTCS.length; i++) { Keymap backup = JTextComponent.getKeymap (EmacsKeyBindings.JTCS[i].getClass().getName()); if (backup != null) { Keymap current = EmacsKeyBindings.JTCS[i].getKeymap(); current.removeBindings(); Action[] bound = backup.getBoundActions(); for (Action aBound : bound) { KeyStroke[] strokes = backup.getKeyStrokesForAction(bound[i]); for (KeyStroke stroke : strokes) { current.addActionForKeyStroke(stroke, aBound); } } current.setDefaultAction(backup.getDefaultAction()); } } } /** * Activates Emacs keybindings for all text components extending {@link * JTextComponent}. */ private static void loadEmacsKeyBindings() { EmacsKeyBindings.LOGGER.debug("Loading emacs keybindings"); for (JTextComponent jtc : EmacsKeyBindings.JTCS) { Action[] origActions = jtc.getActions(); Action[] actions = new Action[origActions.length + EmacsKeyBindings.EMACS_ACTIONS.length]; System.arraycopy(origActions, 0, actions, 0, origActions.length); System.arraycopy(EmacsKeyBindings.EMACS_ACTIONS, 0, actions, origActions.length, EmacsKeyBindings.EMACS_ACTIONS.length); Keymap k = jtc.getKeymap(); JTextComponent.KeyBinding[] keybindings; boolean rebindCA = JabRefPreferences.getInstance().getBoolean(JabRefPreferences.EDITOR_EMACS_KEYBINDINGS_REBIND_CA); boolean rebindCF = JabRefPreferences.getInstance().getBoolean(JabRefPreferences.EDITOR_EMACS_KEYBINDINGS_REBIND_CF); if (rebindCA || rebindCF) { // if we additionally rebind C-a or C-f, we have to add the shortcuts to EmacsKeyBindings.EMACS_KEY_BINDINGS_BASE // determine size of new array and position of the new key bindings in the array int size = EmacsKeyBindings.EMACS_KEY_BINDINGS_BASE.length; int posCA = -1; int posCF = -1; if (rebindCA) { posCA = size; size++; } if (rebindCF) { posCF = size; size++; } // generate new array keybindings = new JTextComponent.KeyBinding[size]; System.arraycopy(EmacsKeyBindings.EMACS_KEY_BINDINGS_BASE, 0, keybindings, 0, EmacsKeyBindings.EMACS_KEY_BINDINGS_BASE.length); if (rebindCA) { keybindings[posCA] = EmacsKeyBindings.EMACS_KEY_BINDING_C_A; } if (rebindCF) { keybindings[posCF] = EmacsKeyBindings.EMACS_KEY_BINDING_C_F; } } else { keybindings = EmacsKeyBindings.EMACS_KEY_BINDINGS_BASE; } JTextComponent.loadKeymap(k, keybindings, actions); } } /** * This action kills the next word. * * It removes the next word on the right side of the cursor from the active * text component and adds it to the clipboard. */ @SuppressWarnings("serial") public static class KillWordAction extends TextAction { public KillWordAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent e) { JTextComponent jtc = getTextComponent(e); if (jtc != null) { try { int offs = jtc.getCaretPosition(); jtc.setSelectionStart(offs); offs = EmacsKeyBindings.getWordEnd(jtc, offs); jtc.setSelectionEnd(offs); String selectedText = jtc.getSelectedText(); if (selectedText != null) { KillRing.getInstance().add(selectedText); } jtc.cut(); } catch (BadLocationException ble) { jtc.getToolkit().beep(); } } } } /** * This action kills the previous word. * * It removes the previous word on the left side of the cursor from the * active text component and adds it to the clipboard. */ @SuppressWarnings("serial") public static class BackwardKillWordAction extends TextAction { public BackwardKillWordAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent e) { JTextComponent jtc = getTextComponent(e); if (jtc != null) { try { int offs = jtc.getCaretPosition(); jtc.setSelectionEnd(offs); offs = Utilities.getPreviousWord(jtc, offs); jtc.setSelectionStart(offs); String selectedText = jtc.getSelectedText(); if (selectedText != null) { KillRing.getInstance().add(selectedText); } jtc.cut(); } catch (BadLocationException ble) { jtc.getToolkit().beep(); } } } } /** * This action copies the marked region and stores it in the killring. */ @SuppressWarnings("serial") public static class KillRingSaveAction extends TextAction { public KillRingSaveAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent e) { JTextComponent jtc = getTextComponent(e); EmacsKeyBindings.doCopyOrCut(jtc, true); } } /** * This action Kills the marked region and stores it in the killring. */ @SuppressWarnings("serial") public static class KillRegionAction extends TextAction { public KillRegionAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent e) { JTextComponent jtc = getTextComponent(e); EmacsKeyBindings.doCopyOrCut(jtc, false); } } private static void doCopyOrCut(JTextComponent jtc, boolean copy) { if (jtc != null) { int caretPosition = jtc.getCaretPosition(); String text = jtc.getSelectedText(); if (text != null) { // user has manually marked a text without using CTRL+W // we obey that selection and copy it. } else if (SetMarkCommandAction.isMarked(jtc)) { int beginPos = caretPosition; int endPos = SetMarkCommandAction.getCaretPosition(); if (beginPos > endPos) { int tmp = endPos; endPos = beginPos; beginPos = tmp; } jtc.select(beginPos, endPos); SetMarkCommandAction.reset(); } text = jtc.getSelectedText(); if (text == null) { jtc.getToolkit().beep(); } else { if (copy) { jtc.copy(); // clear the selection jtc.select(caretPosition, caretPosition); } else { int newCaretPos = jtc.getSelectionStart(); jtc.cut(); // put the cursor to the beginning of the text to cut jtc.setCaretPosition(newCaretPos); } KillRing.getInstance().add(text); } } } /** * This actin kills text up to the end of the current line and stores it in * the killring. */ @SuppressWarnings("serial") public static class KillLineAction extends TextAction { public KillLineAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent e) { JTextComponent jtc = getTextComponent(e); if (jtc != null) { try { int start = jtc.getCaretPosition(); int end = Utilities.getRowEnd(jtc, start); if ((start == end) && jtc.isEditable()) { Document doc = jtc.getDocument(); doc.remove(end, 1); } else { jtc.setSelectionStart(start); jtc.setSelectionEnd(end); String selectedText = jtc.getSelectedText(); if (selectedText != null) { KillRing.getInstance().add(selectedText); } jtc.cut(); // jtc.replaceSelection(""); } } catch (BadLocationException ble) { jtc.getToolkit().beep(); } } } } /** * This action matchers a beginning mark for a selection. */ @SuppressWarnings("serial") public static class SetMarkCommandAction extends TextAction { private static int position = -1; private static JTextComponent jtc; public SetMarkCommandAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent e) { SetMarkCommandAction.jtc = getTextComponent(e); if (SetMarkCommandAction.jtc != null) { SetMarkCommandAction.position = SetMarkCommandAction.jtc.getCaretPosition(); } } public static boolean isMarked(JTextComponent jt) { return (SetMarkCommandAction.jtc == jt) && (SetMarkCommandAction.position != -1); } public static void reset() { SetMarkCommandAction.jtc = null; SetMarkCommandAction.position = -1; } public static int getCaretPosition() { return SetMarkCommandAction.position; } } /** * This action pastes text from the killring. */ @SuppressWarnings("serial") public static class YankAction extends TextAction { public static int start = -1; public static int end = -1; public YankAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent event) { JTextComponent jtc = getTextComponent(event); if (jtc != null) { try { YankAction.start = jtc.getCaretPosition(); jtc.paste(); YankAction.end = jtc.getCaretPosition(); KillRing.getInstance().add(jtc.getText(YankAction.start, YankAction.end)); KillRing.getInstance().setCurrentTextComponent(jtc); } catch (BadLocationException e) { LOGGER.info("Bad location when yanking", e); } } } } /** * This action pastes an element from the killring cycling through it. */ @SuppressWarnings("serial") public static class YankPopAction extends TextAction { public YankPopAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent event) { JTextComponent jtc = getTextComponent(event); boolean jtcNotNull = jtc != null; boolean jtcIsCurrentTextComponent = KillRing.getInstance().getCurrentTextComponent() == jtc; boolean caretPositionIsEndOfLastYank = jtcNotNull && (jtc.getCaretPosition() == YankAction.end); boolean killRingNotEmpty = !KillRing.getInstance().isEmpty(); if (jtcNotNull && jtcIsCurrentTextComponent && caretPositionIsEndOfLastYank && killRingNotEmpty) { jtc.setSelectionStart(YankAction.start); jtc.setSelectionEnd(YankAction.end); String toYank = KillRing.getInstance().next(); if (toYank == null) { jtc.getToolkit().beep(); } else { jtc.replaceSelection(toYank); YankAction.end = jtc.getCaretPosition(); } } } } public static class KillRing { /** * Manages all killed (cut) text pieces in a ring which is accessible * through {@link YankPopAction}. * <p> * Also provides an unmodifiable copy of all cut pieces. */ private static final KillRing INSTANCE = new KillRing(); private JTextComponent jtc; private final LinkedList<String> ring = new LinkedList<>(); private Iterator<String> iter = ring.iterator(); public static KillRing getInstance() { return KillRing.INSTANCE; } public void setCurrentTextComponent(JTextComponent jtc) { this.jtc = jtc; } public JTextComponent getCurrentTextComponent() { return jtc; } /** * Adds text to the front of the kill ring. * <p> * Deviating from the Emacs implementation we make sure the * exact same text is not somewhere else in the ring. */ public void add(String text) { if (text.isEmpty()) { return; } ring.remove(text); ring.addFirst(text); while (ring.size() > 60) { ring.removeLast(); } iter = ring.iterator(); // skip first entry, the one we just added iter.next(); } /** * Returns an unmodifiable version of the ring list which contains * the killed texts. * * @return the content of the kill ring */ public List<String> getRing() { return Collections.unmodifiableList(ring); } public boolean isEmpty() { return ring.isEmpty(); } /** * Returns the next text element which is to be yank-popped. * * @return <code>null</code> if the ring is empty */ public String next() { if (ring.isEmpty()) { return null; } else if (iter.hasNext()) { return iter.next(); } else { iter = ring.iterator(); // guaranteed to not throw an exception, since ring is not empty return iter.next(); } } } /** * This action capitalizes the next word on the right side of the caret. */ @SuppressWarnings("serial") public static class CapitalizeWordAction extends TextAction { public CapitalizeWordAction(String nm) { super(nm); } /** * At first the same code as in {@link * EmacsKeyBindings.DowncaseWordAction} is performed, to ensure the * word is in lower case, then the first letter is capialized. */ @Override public void actionPerformed(ActionEvent event) { JTextComponent jtc = getTextComponent(event); if (jtc != null) { try { /* downcase code */ int start = jtc.getCaretPosition(); int end = EmacsKeyBindings.getWordEnd(jtc, start); jtc.setSelectionStart(start); jtc.setSelectionEnd(end); String word = jtc.getText(start, end - start); jtc.replaceSelection(word.toLowerCase(Locale.ROOT)); /* actual capitalize code */ int offs = Utilities.getWordStart(jtc, start); // get first letter String c = jtc.getText(offs, 1); // we're at the end of the previous word if (" ".equals(c)) { /* ugly java workaround to get the beginning of the word. */ offs = Utilities.getWordStart(jtc, ++offs); c = jtc.getText(offs, 1); } if (Character.isLetter(c.charAt(0))) { jtc.setSelectionStart(offs); jtc.setSelectionEnd(offs + 1); jtc.replaceSelection(c.toUpperCase(Locale.ROOT)); } end = Utilities.getWordEnd(jtc, offs); jtc.setCaretPosition(end); } catch (BadLocationException ble) { jtc.getToolkit().beep(); } } } } /** * This action renders all characters of the next word to lowercase. */ @SuppressWarnings("serial") public static class DowncaseWordAction extends TextAction { public DowncaseWordAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent event) { JTextComponent jtc = getTextComponent(event); if (jtc != null) { try { int start = jtc.getCaretPosition(); int end = EmacsKeyBindings.getWordEnd(jtc, start); jtc.setSelectionStart(start); jtc.setSelectionEnd(end); String word = jtc.getText(start, end - start); jtc.replaceSelection(word.toLowerCase(Locale.ROOT)); jtc.setCaretPosition(end); } catch (BadLocationException ble) { jtc.getToolkit().beep(); } } } } /** * This action renders all characters of the next word to upppercase. */ @SuppressWarnings("serial") public static class UpcaseWordAction extends TextAction { public UpcaseWordAction(String nm) { super(nm); } @Override public void actionPerformed(ActionEvent event) { JTextComponent jtc = getTextComponent(event); if (jtc != null) { try { int start = jtc.getCaretPosition(); int end = EmacsKeyBindings.getWordEnd(jtc, start); jtc.setSelectionStart(start); jtc.setSelectionEnd(end); String word = jtc.getText(start, end - start); jtc.replaceSelection(word.toUpperCase(Locale.ROOT)); jtc.setCaretPosition(end); } catch (BadLocationException ble) { jtc.getToolkit().beep(); } } } } private static int getWordEnd(JTextComponent jtc, int start) throws BadLocationException { try { return Utilities.getNextWord(jtc, start); } catch (BadLocationException ble) { int end = jtc.getText().length(); if (start < end) { return end; } else { throw ble; } } } }