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;
}
}
}