package org.jabref.gui.autocompleter;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.KeyEvent;
import java.util.List;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JPopupMenu;
import javax.swing.KeyStroke;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import javax.swing.text.JTextComponent;
import org.jabref.logic.autocompleter.AutoCompleter;
/**
* Endows a textbox with the ability to autocomplete the input. Based on code by Santhosh Kumar
* (http://www.jroller.com/santhosh/date/20050620) James Lemieux (Glazed Lists AutoCompleteSupport)
*
* @param <E> type of items displayed in the autocomplete popup
*/
public class AutoCompleteSupport<E> {
private final AutoCompleteRenderer<E> renderer;
private AutoCompleter<E> autoCompleter;
private final JTextComponent textComp;
private final JPopupMenu popup = new JPopupMenu();
private boolean selectsTextOnFocusGain = true;
/**
* Constructs a new AutoCompleteSupport for the textbox using the autocompleter and a renderer.
*
* @param textComp the textbox component for which autocompletion should be enabled
* @param autoCompleter the autocompleter providing the data
* @param renderer the renderer displaying the popup
*/
public AutoCompleteSupport(JTextComponent textComp, AutoCompleter<E> autoCompleter,
AutoCompleteRenderer<E> renderer) {
this.renderer = renderer;
this.textComp = textComp;
this.autoCompleter = autoCompleter;
}
/**
* Constructs a new AutoCompleteSupport for the textbox. The possible autocomplete items are displayed as a simple
* list. The autocompletion items are provided by an AutoCompleter which has to be specified later using
* {@link setAutoCompleter}.
*
* @param textComp the textbox component for which autocompletion should be enabled
*/
public AutoCompleteSupport(JTextComponent textComp) {
this(textComp, null, new ListAutoCompleteRenderer<>());
}
/**
* Constructs a new AutoCompleteSupport for the textbox using the autocompleter and a renderer. The possible
* autocomplete items are displayed as a simple list.
*
* @param textComp the textbox component for which autocompletion should be enabled
* @param autoCompleter the autocompleter providing the data
*/
public AutoCompleteSupport(JTextComponent textComp, AutoCompleter<E> autoCompleter) {
this(textComp, autoCompleter, new ListAutoCompleteRenderer<>());
}
/**
* Inits the autocompletion popup. After this method is called, further input in the specified textbox will be
* autocompleted.
*/
public void install() {
// ActionListeners for navigating the suggested autocomplete items with the arrow keys
final ActionListener upAction = new MoveAction(-1);
final ActionListener downAction = new MoveAction(1);
// ActionListener hiding the autocomplete popup
final ActionListener hidePopupAction = e -> popup.setVisible(false);
// ActionListener accepting the currently selected item as the autocompletion
final ActionListener acceptAction = e -> {
E itemToInsert = renderer.getSelectedItem();
if (itemToInsert == null) {
return;
}
String toInsert = autoCompleter.getAutoCompleteText(itemToInsert);
// TODO: The following should be refactored. For example, the autocompleter shouldn't know whether we want to complete one word or multiple.
// In most fields, we are only interested in the currently edited word, so we
// seek from the caret backward to the closest space:
if (!autoCompleter.isSingleUnitField()) {
// Get position of last word separator (whitespace or comma)
int priv = textComp.getText().length() - 1;
while ((priv >= 0) && !Character.isWhitespace(textComp.getText().charAt(priv))
&& (textComp.getText().charAt(priv) != ',')) {
priv--;
}
// priv points to whitespace char or priv is -1
// copy everything from the next char up to the end of "upToCaret"
textComp.setText(textComp.getText().substring(0, priv + 1) + toInsert);
} 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:
textComp.setText(toInsert);
}
textComp.setCaretPosition(textComp.getText().length());
popup.setVisible(false);
};
// Create popup
popup.setBorder(BorderFactory.createMatteBorder(1, 1, 1, 1, Color.LIGHT_GRAY));
popup.setPopupSize(textComp.getWidth(), 200);
popup.setLayout(new BorderLayout());
popup.setFocusable(false);
popup.setRequestFocusEnabled(false);
popup.add(renderer.init(acceptAction));
// Listen for changes to the text -> update autocomplete suggestions
textComp.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
postProcessTextChange();
}
@Override
public void removeUpdate(DocumentEvent e) {
postProcessTextChange();
}
@Override
public void changedUpdate(DocumentEvent e) {
// Do nothing
}
});
// Listen for up/down arrow keys -> move currently selected item up or down
// We have to reimplement this function here since we cannot be sure that a simple list will be used to display the items
// So better let the renderer decide what to do.
// (Moreover, the list does not have the focus so probably would not recognize the keystrokes in the first place.)
textComp.registerKeyboardAction(downAction, KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
JComponent.WHEN_FOCUSED);
textComp.registerKeyboardAction(upAction, KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), JComponent.WHEN_FOCUSED);
// Listen for ESC key -> hide popup
textComp.registerKeyboardAction(hidePopupAction, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Listen to focus events -> select all the text on gaining the focus
this.textComp.addFocusListener(new ComboBoxEditorFocusHandler());
// Listen for ENTER key if popup is visible -> accept current autocomplete suggestion
popup.addPopupMenuListener(new PopupMenuListener() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
textComp.registerKeyboardAction(acceptAction, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED);
}
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
textComp.unregisterKeyboardAction(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0));
}
@Override
public void popupMenuCanceled(PopupMenuEvent e) {
// Do nothing
}
});
}
/**
* Returns whether the text in the textbox is selected when the textbox gains focus. Defaults to true.
*
* @return
*/
public boolean isSelectsTextOnFocusGain() {
return selectsTextOnFocusGain;
}
/**
* Sets whether the text in the textbox is selected when the textbox gains focus. Default is true.
*
* @param selectsTextOnFocusGain new value
*/
public void setSelectsTextOnFocusGain(boolean selectsTextOnFocusGain) {
this.selectsTextOnFocusGain = selectsTextOnFocusGain;
}
/**
* The text changed so update autocomplete suggestions accordingly.
*/
private void postProcessTextChange() {
if (autoCompleter == null) {
popup.setVisible(false);
return;
}
String text = textComp.getText();
List<E> candidates = autoCompleter.complete(text);
renderer.update(candidates);
if (textComp.isEnabled() && (!candidates.isEmpty())) {
renderer.selectItem(0);
popup.setPopupSize(textComp.getWidth(), 200);
popup.show(textComp, 0, textComp.getHeight());
} else {
popup.setVisible(false);
}
if (!textComp.hasFocus()) {
textComp.requestFocusInWindow();
}
}
/**
* The action invoked by hitting the up or down arrow key. If the popup is currently shown, that the action is
* relayed to it. Otherwise the arrow keys trigger the popup.
*/
private class MoveAction extends AbstractAction {
private final int offset;
public MoveAction(int offset) {
this.offset = offset;
}
@Override
public void actionPerformed(ActionEvent e) {
if (popup.isVisible()) {
renderer.selectItemRelative(offset);
} else {
popup.show(textComp, 0, textComp.getHeight());
}
}
}
/**
* Selects all text when the textbox gains focus. The behavior is controlled by the value returned from
* {@link AutoCompleteSupport#isSelectsTextOnFocusGain()}.
*/
private class ComboBoxEditorFocusHandler extends FocusAdapter {
@Override
public void focusGained(FocusEvent e) {
if (isSelectsTextOnFocusGain() && !e.isTemporary()) {
textComp.selectAll();
}
}
@Override
public void focusLost(FocusEvent e) {
// Do nothing
}
}
/**
* Sets the autocompleter used to present autocomplete suggestions.
*
* @param autoCompleter the autocompleter providing the data
*/
public void setAutoCompleter(AutoCompleter<E> autoCompleter) {
this.autoCompleter = autoCompleter;
}
public void setVisible(boolean visible) {
popup.setVisible(visible);
}
public boolean isVisible() {
return popup.isVisible();
}
}