package org.obo.app.swing; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.util.ArrayList; import java.util.List; import javax.accessibility.Accessible; import javax.accessibility.AccessibleContext; import javax.swing.Action; import javax.swing.ComboBoxModel; import javax.swing.InputMap; import javax.swing.InputVerifier; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JList; import javax.swing.JTextField; import javax.swing.SwingUtilities; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.plaf.basic.BasicComboBoxEditor; import javax.swing.plaf.basic.BasicComboBoxRenderer; import javax.swing.plaf.basic.ComboPopup; import org.apache.log4j.Logger; import ca.odell.glazedlists.BasicEventList; import ca.odell.glazedlists.EventList; import ca.odell.glazedlists.swing.EventComboBoxModel; public class AutocompleteField<T> extends JComponent { private final AutocompleteComboBox comboBox = new AutocompleteComboBox(); private final List<ActionListener> actionListeners = new ArrayList<ActionListener>(); private AutocompleteSearcher<T> searcher; private final EventList<SearchHit<T>> completeList = new BasicEventList<SearchHit<T>>(); private final ComboBoxModel comboBoxModel = new EventComboBoxModel<SearchHit<T>>(completeList); private Action action; private T currentValue; private boolean changingCompletionList = false; private boolean externallySettingText = false; private boolean hasBeenEdited = false; private Object lastHighlightedItem = null; public AutocompleteField(AutocompleteSearcher<T> searcher) { this.searcher = searcher; this.comboBox.setEditable(true); this.comboBox.setEditor(new AutocompleteEditor()); this.comboBox.setModel(this.comboBoxModel); final TextFieldListener textFieldListener = new TextFieldListener(); this.getEditorField().getDocument().addDocumentListener(textFieldListener); this.comboBox.addActionListener(new AutocompleteActionListener()); this.comboBox.setRenderer(new AutocompleteRenderer()); this.getListComponent().addListSelectionListener(new CompletionListListener()); this.getListComponent().setVerifyInputWhenFocusTarget(false); this.setLayout(new BorderLayout()); this.add(this.comboBox, BorderLayout.CENTER); } public AutocompleteSearcher<T> getSearcher() { return this.searcher; } public void setSearcher(AutocompleteSearcher<T> newSearcher) { this.searcher = newSearcher; } public void setValue(T value) { this.hasBeenEdited = false; lastHighlightedItem = null; this.internallySetValue(value); } private void internallySetValue(T value) { this.currentValue = value; this.externallySettingText = true; this.comboBox.setSelectedItem(null); getEditorField().setText(this.getSearcher().toString(value)); } public T getValue() { return this.currentValue; } public void addActionListener(ActionListener l) { this.actionListeners.add(l); } public void removeActionListener(ActionListener l) { this.actionListeners.remove(l); } public void setAction(Action a) { if (this.action != null) { this.removeActionListener(this.action); } this.action = a; this.addActionListener(this.action); } public Action getAction() { return this.action; } public JList getListComponent() { final ComboPopup popup = this.getComboPopup(this.getComboBox()); if (popup != null) { return popup.getList(); } return null; } protected void fireActionPerformed() { if (!this.hasBeenEdited) return; for (ActionListener l : this.actionListeners) { l.actionPerformed(new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "value changed")); } } @Override public void setInputVerifier(InputVerifier inputVerifier) { this.getEditorField().setInputVerifier(inputVerifier); } protected JComboBox getComboBox() { return this.comboBox; } private void queryWithInput() { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { getSearcher().setSearch(getEditorField().getText()); final List<SearchHit<T>> matches = getSearcher().getMatches(); if (!matches.isEmpty()) { //TODO maybe autofill textbox? only if starts with input? } changingCompletionList = true; completeList.clear(); completeList.addAll(matches.size() > 50 ? matches.subList(0, 50) : matches); //TODO there is a magic number here changingCompletionList = false; comboBox.setPopupVisible(false); comboBox.showPopup(); } }); } private void setValueWithInput(SearchHit<T> hit) { this.internallySetValue(hit.getHit()); this.fireActionPerformed(); } private void setValueWithTextInput(String text) { final SearchHit<T> hit; // if there is more than one exact hit for the text we want the one we had before if (this.getSearcher().isSame(text, this.currentValue)) { hit = this.getSearcher().getAsHit(this.currentValue); } else { hit = this.getSearcher().getExactHit(text); } if (hit != null) { comboBox.setSelectedItem(hit); this.setValueWithInput(hit); } else if (text.trim().equals("")) { this.internallySetValue(null); this.fireActionPerformed(); } else { this.comboBox.setForeground(Color.RED); this.getEditorField().setForeground(Color.RED); } } private class TextFieldListener implements DocumentListener { @Override public void changedUpdate(DocumentEvent e) { this.textChanged(); } @Override public void insertUpdate(DocumentEvent e) { this.textChanged(); } @Override public void removeUpdate(DocumentEvent e) { this.textChanged(); } private void textChanged() { comboBox.setForeground(Color.BLACK); getEditorField().setForeground(Color.BLACK); if (externallySettingText) { return; } hasBeenEdited = true; comboBox.setPotentialValue(null); queryWithInput(); } } private JTextField getEditorField() { final Component component = this.comboBox.getEditor().getEditorComponent(); if (component instanceof JTextField) { return (JTextField)component; } else { log().fatal("Combobox doesn't use a JTextField for editor."); return null; } } protected ComboPopup getComboPopup(JComboBox comboBox) { final AccessibleContext ac = comboBox.getAccessibleContext(); for (int i = 0; i < ac.getAccessibleChildrenCount(); i++) { final Accessible a = ac.getAccessibleChild(i); if (a instanceof ComboPopup) { return (ComboPopup)a; } } log().error("Can't retrieve popup from combobox; can't do mouse overs"); return null; } private class AutocompleteComboBox extends JComboBox { private Object potentialValue = null; public AutocompleteComboBox() { super(); } public Object getPotentialValue() { return this.potentialValue; } public void setPotentialValue(Object object) { this.potentialValue = object; } @Override public void setSelectedItem(Object anObject) { this.potentialValue = anObject; super.setSelectedItem(anObject); } @Override public void firePopupMenuWillBecomeVisible() { hasBeenEdited = true; super.firePopupMenuWillBecomeVisible(); } @Override public void setInputVerifier(InputVerifier inputVerifier) { getEditorField().setInputVerifier(inputVerifier); } } private class AutocompleteEditor extends BasicComboBoxEditor { public AutocompleteEditor() { super(); this.editor = new AutocompleteTextField(); this.correctInputMapForEditorField(this.editor); } @Override public void setItem(Object anObject) { return; } /** * This method replaces the AutoTextFieldEditor's InputMap with the same one * the current look and feel would use. This was a problem for keyboard selection * when using the Quaqua look and feel. */ private void correctInputMapForEditorField(JTextField field) { final Component systemComboBoxEditor = new JComboBox().getEditor().getEditorComponent(); if (systemComboBoxEditor instanceof JComponent) { final InputMap map = ((JComponent)systemComboBoxEditor).getInputMap(); SwingUtilities.replaceUIInputMap(field, JComponent.WHEN_FOCUSED, map); } } } private class AutocompleteTextField extends JTextField { @Override public void setText(String t) { if (changingCompletionList) return; super.setText(t); externallySettingText = false; } } private class AutocompleteActionListener implements ActionListener { @Override @SuppressWarnings("unchecked") public void actionPerformed(ActionEvent e) { if (e.getActionCommand().equals("comboBoxEdited") || e.getModifiers() != 0) { final Object selectedItem = comboBox.getSelectedItem(); final Object potentialValue = lastHighlightedItem; if (potentialValue instanceof SearchHit) { //TODO need to check for null pv!!! final SearchHit<T> hit = (SearchHit<T>)potentialValue; comboBox.setSelectedItem(hit); setValueWithInput(hit); } else if ((potentialValue == null) && (selectedItem instanceof String)){ setValueWithTextInput((String)selectedItem); } } } } private class CompletionListListener implements ListSelectionListener { @Override public void valueChanged(ListSelectionEvent event) { final Object source = event.getSource(); if (source instanceof JList) { final JList menu = (JList)source; try { final Object item = menu.getSelectedValue(); if (item != null) { lastHighlightedItem = item; } } catch (IndexOutOfBoundsException e) { // for some reason sometimes the menu selection is not valid } } else { log().error("Source of combobox mouse over event is not JList"); } } } private static class AutocompleteRenderer extends BasicComboBoxRenderer { @Override public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { final SearchHit<?> hit = (SearchHit<?>)value; final String displayValue = "<html>" + hit.getMatchText() + " <i><font color=\"gray\" size=\"small\">" + hit.getMatchType().getName() + "</font></i></html>"; return super.getListCellRendererComponent(list, displayValue, index, isSelected,cellHasFocus); } } private Logger log() { return Logger.getLogger(this.getClass()); } }