package org.jabref.gui.autocompleter; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.awt.event.InputEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.util.List; import javax.swing.text.BadLocationException; import javax.swing.text.JTextComponent; import org.jabref.logic.autocompleter.AutoCompleter; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; public class AutoCompleteListener extends KeyAdapter implements FocusListener { //TODO: The logging behavior in this class is probably too fine-grained and only understandable to its original author private static final Log LOGGER = LogFactory.getLog(AutoCompleteListener.class); private final AutoCompleter<String> completer; // These variables keep track of the situation from time to time. private String toSetIn; // null indicates that there are no completions available private String lastBeginning; // the letters, the user has typed until know private int lastCaretPosition = -1; private List<String> lastCompletions; private int lastShownCompletion; private boolean consumeEnterKey = true; // This field is set if the focus listener should call another focus listener // after finishing. This is needed because the autocomplete listener must // run before the focus listener responsible for storing the current edit. private FocusListener nextFocusListener; public AutoCompleteListener(AutoCompleter<String> completer) { // if (logger.getHandlers().length == 0) { // logger.setLevel(Level.FINEST); // ConsoleHandler ch = new ConsoleHandler(); // ch.setLevel(Level.FINEST); // logger.addHandler(ch); // } this.completer = completer; } /** * This method is used if the focus listener should call another focus listener after finishing. This is needed * because the autocomplete listener must run before the focus listener responsible for storing the current edit. * * @param listener The listener to call. */ public void setNextFocusListener(FocusListener listener) { this.nextFocusListener = listener; } /** * This setting determines whether the autocomplete listener should consume the Enter key stroke when it leads to * accepting a completion. If set to false, the JTextComponent will receive the Enter key press after the completion * is done. The default value if true. * * @param t true to indicate that the Enter key should be consumed, false that it should be forwarded */ public void setConsumeEnterKey(boolean t) { this.consumeEnterKey = t; } @Override public void keyPressed(KeyEvent e) { if ((toSetIn != null) && (e.getKeyCode() == KeyEvent.VK_ENTER)) { JTextComponent comp = (JTextComponent) e.getSource(); // replace typed characters by characters from completion lastBeginning = lastCompletions.get(lastShownCompletion); int end = comp.getSelectionEnd(); comp.select(end, end); toSetIn = null; if (consumeEnterKey) { e.consume(); } } // Cycle through alternative completions when user presses PGUP/PGDN: else if ((e.getKeyCode() == KeyEvent.VK_PAGE_DOWN) && (toSetIn != null)) { cycle((JTextComponent) e.getSource(), 1); e.consume(); } else if ((e.getKeyCode() == KeyEvent.VK_PAGE_UP) && (toSetIn != null)) { cycle((JTextComponent) e.getSource(), -1); e.consume(); } // else if ((e.getKeyCode() == KeyEvent.VK_BACK_SPACE)) { // StringBuffer currentword = getCurrentWord((JTextComponent) e.getSource()); // // delete last char to obey semantics of back space // currentword.deleteCharAt(currentword.length()-1); // doCompletion(currentword, e); // } else if (e.getKeyChar() == KeyEvent.CHAR_UNDEFINED) { if (e.getKeyCode() == KeyEvent.VK_SHIFT) { // shift is OK, everything else leads to a reset LOGGER.debug("Special case: shift pressed. No action."); } else { resetAutoCompletion(); } } else { LOGGER.debug("Special case: defined character, but not caught above"); } } private void cycle(JTextComponent comp, int increment) { assert (lastCompletions != null); assert (!lastCompletions.isEmpty()); lastShownCompletion += increment; if (lastShownCompletion >= lastCompletions.size()) { lastShownCompletion = 0; } else if (lastShownCompletion < 0) { lastShownCompletion = lastCompletions.size() - 1; } String sno = lastCompletions.get(lastShownCompletion); toSetIn = sno.substring(lastBeginning.length() - 1); StringBuilder alltext = new StringBuilder(comp.getText()); int oldSelectionStart = comp.getSelectionStart(); int oldSelectionEnd = comp.getSelectionEnd(); // replace prefix with new prefix int startPos = comp.getSelectionStart() - lastBeginning.length(); alltext.delete(startPos, oldSelectionStart); alltext.insert(startPos, sno.subSequence(0, lastBeginning.length())); // replace suffix with new suffix alltext.delete(oldSelectionStart, oldSelectionEnd); //int cp = oldSelectionEnd - deletedChars; alltext.insert(oldSelectionStart, toSetIn.substring(1)); LOGGER.debug(alltext.toString()); comp.setText(alltext.toString()); //comp.setCaretPosition(cp+toSetIn.length()-1); comp.select(oldSelectionStart, (oldSelectionStart + toSetIn.length()) - 1); lastCaretPosition = comp.getCaretPosition(); LOGGER.debug("ToSetIn: '" + toSetIn + "'"); } private boolean atEndOfWord(JTextComponent textField) { int nextCharPosition = textField.getCaretPosition(); // position not at the end of input if (nextCharPosition < textField.getText().length()) { char nextChar = textField.getText().charAt(nextCharPosition); if (!Character.isWhitespace(nextChar)) { return false; } } return true; } /** * If user cancels autocompletion by a) entering another letter than the completed word (and there is no other auto * completion) b) space the casing of the letters has to be kept * * Global variable "lastBeginning" keeps track of typed letters. We rely on this variable to reconstruct the text * * @param wordSeperatorTyped indicates whether the user has typed a white space character or a */ private void setUnmodifiedTypedLetters(JTextComponent comp, boolean lastBeginningContainsTypedCharacter, boolean wordSeperatorTyped) { if (lastBeginning == null) { LOGGER.debug("No last beginning found"); // There was no previous input (if the user typed a word, where no autocompletion is available) // Thus, there is nothing to replace return; } LOGGER.debug("lastBeginning: >" + lastBeginning + '<'); if (comp.getSelectedText() == null) { // if there is no selection // the user has typed the complete word, but possibly with a different casing // we need a replacement if (wordSeperatorTyped) { LOGGER.debug("Replacing complete word"); } else { // if user did not press a white space character (space, ...), // then we do not do anything return; } } else { LOGGER.debug("Selected text " + comp.getSelectedText() + " will be removed"); // remove completion suggestion comp.replaceSelection(""); } lastCaretPosition = comp.getCaretPosition(); int endIndex = lastCaretPosition - lastBeginning.length(); if (lastBeginningContainsTypedCharacter) { // the current letter is NOT contained in comp.getText(), but in lastBeginning // thus lastBeginning.length() is one too large endIndex++; } String text = comp.getText(); comp.setText(text.substring(0, endIndex).concat(lastBeginning).concat(text.substring(lastCaretPosition))); if (lastBeginningContainsTypedCharacter) { // the current letter is NOT contained in comp.getText() // Thus, cursor position also did not get updated lastCaretPosition++; } comp.setCaretPosition(lastCaretPosition); lastBeginning = null; } /** * Start a new completion attempt (instead of treating a continuation of an existing word or an interrupt of the * current word) */ private void startCompletion(StringBuffer currentword, KeyEvent e) { JTextComponent comp = (JTextComponent) e.getSource(); List<String> completed = findCompletions(currentword.toString()); String prefix = completer.getPrefix(); String cWord = (prefix != null) && (!prefix.isEmpty()) ? currentword.toString() .substring(prefix.length()) : currentword.toString(); LOGGER.debug("StartCompletion currentword: >" + currentword + "'<' prefix: >" + prefix + "'<' cword: >" + cWord + '<'); int no = 0; // We use the first word in the array of completions. if ((completed != null) && (!completed.isEmpty())) { lastShownCompletion = 0; lastCompletions = completed; String sno = completed.get(no); // these two lines obey the user's input //toSetIn = Character.toString(ch); //toSetIn = toSetIn.concat(sno.substring(cWord.length())); // BUT we obey the completion toSetIn = sno.substring(cWord.length() - 1); LOGGER.debug("toSetIn: >" + toSetIn + '<'); StringBuilder alltext = new StringBuilder(comp.getText()); int cp = comp.getCaretPosition(); alltext.insert(cp, toSetIn); comp.setText(alltext.toString()); comp.setCaretPosition(cp); comp.select(cp + 1, (cp + 1 + sno.length()) - cWord.length()); e.consume(); lastCaretPosition = comp.getCaretPosition(); char ch = e.getKeyChar(); LOGGER.debug("Appending >" + ch + '<'); if (cWord.length() <= 1) { lastBeginning = Character.toString(ch); } else { lastBeginning = cWord.substring(0, cWord.length() - 1).concat(Character.toString(ch)); } } } @Override public void keyTyped(KeyEvent e) { LOGGER.debug("key typed event caught " + e.getKeyCode()); char ch = e.getKeyChar(); if (ch == '\n') { // this case is handled at keyPressed(e) return; } // don't do auto completion inside words if (!(e.getSource() instanceof JTextComponent) || !atEndOfWord((JTextComponent) e.getSource())) { return; } if ((e.getModifiers() | InputEvent.SHIFT_MASK) == InputEvent.SHIFT_MASK) { // plain key or SHIFT + key is pressed, no handling of CTRL+key, META+key, ... if (Character.isLetter(ch) || Character.isDigit(ch) || (Character.isWhitespace(ch) && completer.isSingleUnitField())) { JTextComponent comp = (JTextComponent) e.getSource(); if (toSetIn == null) { LOGGER.debug("toSetIn is null"); } else { LOGGER.debug("toSetIn: >" + toSetIn + '<'); } // The case-insensitive system is a bit tricky here // If keyword is "TODO" and user types "tO", then this is treated as "continue" as the "O" matches the "O" // If keyword is "TODO" and user types "To", then this is treated as "discont" as the "o" does NOT match the "O". if ((toSetIn != null) && (toSetIn.length() > 1) && (ch == toSetIn.charAt(1))) { // User continues on the word that was suggested. LOGGER.debug("cont"); toSetIn = toSetIn.substring(1); if (!toSetIn.isEmpty()) { int cp = comp.getCaretPosition(); //comp.setCaretPosition(cp+1-toSetIn.); comp.select((cp + 1) - toSetIn.length(), cp); lastBeginning = lastBeginning + ch; e.consume(); lastCaretPosition = comp.getCaretPosition(); lastCompletions = findCompletions(lastBeginning); lastShownCompletion = 0; for (int i = 0; i < lastCompletions.size(); i++) { String lastCompletion = lastCompletions.get(i); if (lastCompletion.endsWith(toSetIn)) { lastShownCompletion = i; break; } } if (toSetIn.length() < 2) { // User typed the last character of the autocompleted word // We have to replace the automcompletion word by the typed word. // This helps if the user presses "space" after the completion // "space" indicates that the user does NOT want the autocompletion, // but the typed word String text = comp.getText(); comp.setText(text.substring(0, lastCaretPosition - lastBeginning.length()) + lastBeginning + text.substring(lastCaretPosition)); // there is no selected text, therefore we are not updating the selection toSetIn = null; } return; } } if ((toSetIn != null) && ((toSetIn.length() <= 1) || (ch != toSetIn.charAt(1)))) { // User discontinues the word that was suggested. lastBeginning = lastBeginning + ch; LOGGER.debug("discont toSetIn: >" + toSetIn + "'<' lastBeginning: >" + lastBeginning + '<'); List<String> completed = findCompletions(lastBeginning); if ((completed != null) && (!completed.isEmpty())) { lastShownCompletion = 0; lastCompletions = completed; String sno = completed.get(0); // toSetIn = string used for autocompletion last time // this string has to be removed // lastCaretPosition is the position of the caret after toSetIn. int lastLen = toSetIn.length() - 1; toSetIn = sno.substring(lastBeginning.length() - 1); String text = comp.getText(); //we do not use toSetIn as we want to obey the casing of "sno" comp.setText(text.substring(0, (lastCaretPosition - lastLen - lastBeginning.length()) + 1) + sno + text.substring(lastCaretPosition)); int startSelect = (lastCaretPosition + 1) - lastLen; int endSelect = (lastCaretPosition + toSetIn.length()) - lastLen; comp.select(startSelect, endSelect); lastCaretPosition = comp.getCaretPosition(); e.consume(); return; } else { setUnmodifiedTypedLetters(comp, true, false); e.consume(); toSetIn = null; return; } } LOGGER.debug("case else"); comp.replaceSelection(""); StringBuffer currentword = getCurrentWord(comp); // only "real characters" end up here assert (!Character.isISOControl(ch)); currentword.append(ch); startCompletion(currentword, e); return; } else { if (Character.isWhitespace(ch)) { assert (!completer.isSingleUnitField()); LOGGER.debug("whitespace && !singleUnitField"); // start a new search if end-of-field is reached // replace displayed letters with typed letters setUnmodifiedTypedLetters((JTextComponent) e.getSource(), false, true); resetAutoCompletion(); return; } LOGGER.debug("No letter/digit/whitespace or CHAR_UNDEFINED"); // replace displayed letters with typed letters setUnmodifiedTypedLetters((JTextComponent) e.getSource(), false, !Character.isISOControl(ch)); resetAutoCompletion(); return; } } resetAutoCompletion(); } /** * Resets the auto completion data in a way that no leftovers are there */ private void resetAutoCompletion() { LOGGER.debug("Resetting autocompletion"); toSetIn = null; lastBeginning = null; } private List<String> findCompletions(String beginning) { return completer.complete(beginning); } private StringBuffer getCurrentWord(JTextComponent comp) { StringBuffer res = new StringBuffer(); String upToCaret; try { upToCaret = comp.getText(0, comp.getCaretPosition()); // We now have the text from the start of the field up to the caret position. // In most fields, we are only interested in the currently edited word, so we // seek from the caret backward to the closest space: if (!completer.isSingleUnitField()) { if ((comp.getCaretPosition() < comp.getText().length()) && Character.isWhitespace(comp.getText().charAt(comp.getCaretPosition()))) { // caret is in the middle of the text AND current character is a whitespace // that means: a new word is started and there is no current word return new StringBuffer(); } int piv = upToCaret.length() - 1; while ((piv >= 0) && !Character.isWhitespace(upToCaret.charAt(piv))) { piv--; } // piv points to whitespace char or piv is -1 // copy everything from the next char up to the end of "upToCaret" res.append(upToCaret.substring(piv + 1)); } else { // For fields such as "journal" it is more reasonable to try to complete on the entire // text field content, so we skip the searching and keep the entire part up to the caret: res.append(upToCaret); } LOGGER.debug("AutoCompListener: " + res); } catch (BadLocationException ignore) { // Ignored } return res; } @Override public void focusGained(FocusEvent event) { if (nextFocusListener != null) { nextFocusListener.focusGained(event); } } @Override public void focusLost(FocusEvent event) { if (toSetIn != null) { JTextComponent comp = (JTextComponent) event.getSource(); clearCurrentSuggestion(comp); } if (nextFocusListener != null) { nextFocusListener.focusLost(event); } } public void clearCurrentSuggestion(JTextComponent comp) { if (toSetIn != null) { int selStart = comp.getSelectionStart(); String text = comp.getText(); comp.setText(text.substring(0, selStart) + text.substring(comp.getSelectionEnd())); comp.setCaretPosition(selStart); lastCompletions = null; lastShownCompletion = 0; lastCaretPosition = -1; toSetIn = null; } } }