package com.wilutions.fx.acpl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import com.wilutions.itol.db.DefaultSuggest;
import com.wilutions.itol.db.Suggest;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.CustomMenuItem;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.Skin;
import javafx.scene.control.TextField;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Window;
import javafx.util.Duration;
import javafx.util.StringConverter;
public class AutoCompletions {
/**
* Display at most 10 suggestions.
*/
public final static int NB_OF_SUGGESTIONS = 10;
/**
* Display at most 3 recent items.
*/
public static final int NB_OF_RECENT_ITEMS = 3;
/**
* Prepare the given ComboBox for auto completion. This function uses the
* {@link DefaultSuggest}.
*
* @param cbox
* ComboBox
* @param allItems
* All items.
*
* @return AutoCompletionBinding
*/
public static <T> AutoCompletionBinding<T> bindAutoCompletion(ExtractImage<T> extractImage, ComboBox<T> cbox,
String recentCaption, String suggestionsCaption, List<T> recentItems, Collection<T> allItems) {
return bindAutoCompletion(extractImage, cbox, recentCaption, suggestionsCaption, recentItems,
new DefaultSuggest<T>(allItems));
}
public static <T> AutoCompletionBinding<T> bindAutoCompletion(ExtractImage<T> extractImage, ComboBox<T> cbox,
String recentCaption, String suggestionsCaption, final List<T> recentItems, final Suggest<T> suggest) {
AutoCompletionControl<T> control = createAutoCompletionControl(cbox, extractImage);
AutoCompletionBinding<T> binding = createAutoCompletionBinding(extractImage, recentCaption, suggestionsCaption,
recentItems, suggest, control);
ContextMenu popup = createPopup(cbox);
bindComboBox(cbox, binding, popup);
internalBindTextField(cbox.getEditor(), binding, popup);
return binding;
}
public static <T> AutoCompletionComboBox<T> createAutoCompletionNode(
ExtractImage<T> extractImage,
String recentCaption,
String suggestionsCaption,
List<T> recentItems,
Suggest<T> suggest) {
AutoCompletionComboBox<T> cbox = new AutoCompletionComboBox<T>();
AutoCompletionBinding<T> binding = bindAutoCompletion(extractImage, cbox, recentCaption, suggestionsCaption, recentItems, suggest);
cbox.setBinding(binding);
return cbox;
}
private static <T> void bindComboBox(ComboBox<T> cbox, AutoCompletionBinding<T> binding, ContextMenu popup) {
cbox.setEditable(true);
TextField ed = cbox.getEditor();
//
if (binding.getExtractImage() != null) {
ed.setSkin(new TextFieldSkinWithImage(ed));
}
// Set String converter.
// Without a string converter, I receive a ClassCastException
// inside ComboBox code when it internally tries to set the selection
// from the edit field text.
// This happens after pressing return in the selection list
// (MenuItem.onAction...). ComboBoxPopupControl catches ENTER
// and tries to set the selection from the edit text.
// It is sufficient to return an Exception in fromString.
cbox.setConverter(new StringConverter<T>() {
@Override
public String toString(T object) {
return object != null ? object.toString() : null;
}
@Override
public T fromString(String string) {
throw new UnsupportedOperationException();
}
});
// Show suggestion list, if combobox button is pressed
cbox.setOnShowing(new EventHandler<Event>() {
@Override
public void handle(Event event) {
Platform.runLater(() -> {
cbox.hide();
showList(popup, binding, SHOW_ALWAYS_IGNORE_EDIT_TEXT);
});
}
});
}
private static <T> AutoCompletionBinding<T> createAutoCompletionBinding(ExtractImage<T> extractImage,
String recentCaption, String suggestionsCaption, final List<T> recentItems, final Suggest<T> suggest,
AutoCompletionControl<T> control) {
AutoCompletionBinding<T> binding = new AutoCompletionBinding<T>();
binding.setControl(control);
binding.setRecentCaption(recentCaption);
binding.setSuggestionsCaption(suggestionsCaption);
binding.setRecentItems(recentItems);
binding.setSuggest(suggest);
binding.setExtractImage(extractImage);
return binding;
}
private static <T> ContextMenu createPopup(Node node) {
ContextMenu popup = new ContextMenu();
popup.addEventFilter(KeyEvent.ANY, new EventHandler<KeyEvent>() {
public void handle(KeyEvent event) {
KeyCode kc = event.getCode();
if (kc == KeyCode.TAB) {
event.consume();
if (event.getEventType() == KeyEvent.KEY_PRESSED) {
node.fireEvent(new KeyEvent(event.getSource(), event.getTarget(), KeyEvent.KEY_PRESSED, "\r",
"", KeyCode.ENTER, false, false, false, false));
// Wenn ein Men�-Eintrag ausgew�hlt ist, dann soll bei
// TAB
// der Auswahl im Auto-Editfeld eingetragen werden und
// anschlie�end
// der Fokus zum n�chsten Control weitergegeben werden.
// Ist aber kein Men�-Eintrag ausgew�hlt, dann soll der
// Fokus nicht
// weiter wandern. Ich kann hier aber nicht feststellen,
// ob ein
// Men�eintrag gew�hlt ist. Das f�hrt dazu, dass das
// Men� nicht
// geschlossen wird und die Tab-Key-Ereignisse gepuffert
// werden.
// Erfolgt dann eine Selektion z. B. mit Cursortasten,
// geht das
// Men� unerwartet zu und der Fokus zum n�chsten Control
// Deshal ist der Block hier auskommentiert.
// Platform.runLater(() -> {
// node.fireEvent(new KeyEvent(event.getSource(),
// event.getTarget(), KeyEvent.KEY_PRESSED,
// "\t", "", KeyCode.TAB, event.isShiftDown(),
// event.isControlDown(),
// event.isAltDown(), event.isMetaDown()));
// node.fireEvent(new KeyEvent(event.getSource(),
// event.getTarget(), KeyEvent.KEY_RELEASED,
// "\t", "", KeyCode.TAB, event.isShiftDown(),
// event.isControlDown(),
// event.isAltDown(), event.isMetaDown()));
// });
}
// System.out.println("TAB consumed");
}
if (kc == KeyCode.ENTER) {
// System.out.println("ENTER " + event.getEventType());
}
}
});
return popup;
}
private static <T> void internalBindTextField(TextField textField, AutoCompletionBinding<T> binding,
ContextMenu popup) {
final Suggest<T> suggest = binding.getSuggest();
// Show suggestion list, if ALT+DOWN is pressed
textField.setOnKeyPressed(new EventHandler<KeyEvent>() {
public void handle(KeyEvent event) {
KeyCode kc = event.getCode();
if (kc == KeyCode.DOWN && event.isAltDown()) {
Platform.runLater(() -> {
showList(popup, binding, SHOW_ALWAYS_IGNORE_EDIT_TEXT);
});
}
}
});
// Show suggestion list if edit text changes.
textField.textProperty().addListener(new ChangeListener<String>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (!binding.isLockChangeEvent()) {
Platform.runLater(() -> {
showList(popup, binding, SHOW_IF_EDIT_TEXT_DOES_NOT_MATCH | DISABLE_EDIT_ON_SELECT);
});
}
}
});
// Select all if the editor receives input focus.
// if focus is lost and no item is selected, take the first suggestion.
textField.focusedProperty().addListener(new ChangeListener<Boolean>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
if (newValue != null) {
// Received focus?
if (newValue) {
// Select edit text, show list
Platform.runLater(() -> {
textField.selectAll();
T selectedItem = binding.getControl().getSelectedItem();
if (selectedItem == null) {
showList(popup, binding, SHOW_IF_EDIT_TEXT_DOES_NOT_MATCH);
}
});
}
else {
// Lost focus: check for selected item.
T item = binding.getControl().getSelectedItem();
String editText = textField.getText();
// Is an item selected?
if (item != null) {
// Does edit box display selected item?
if (item.toString().equals(editText)) {
// OK, the user sees the correct selection
}
else {
// User sees something different.
// Replace the selection in the following
// statements
item = null;
}
}
// No selection or wrong selection?
if (item == null) {
// Try to select the first suggested item
if (editText.length() != 0) {
Collection<T> suggestedItems = suggest.find(textField.getText(), NB_OF_SUGGESTIONS, null);
if (suggestedItems.size() > 0) {
item = suggestedItems.iterator().next();
}
}
// No suggestion fits, take a recently used item.
List<T> recentItems = binding.getRecentItems();
if (item == null && recentItems != null && recentItems.size() != 0) {
item = recentItems.get(0);
}
}
// Select item
binding.getControl().select(item);
}
}
}
});
}
/**
* Show select list regardless of edit content.
*/
private final static int SHOW_ALWAYS_IGNORE_EDIT_TEXT = 1;
/**
* Show select list only if the TextField does not match any item.
*/
private final static int SHOW_IF_EDIT_TEXT_DOES_NOT_MATCH = 2;
/**
* Disable the TextField if it matches an item.
*/
private final static int DISABLE_EDIT_ON_SELECT = 4;
private static <T> void showList(ContextMenu popup, AutoCompletionBinding<T> binding, int ctrl) {
AutoCompletionControl<T> control = binding.getControl();
String recentCaption = binding.getRecentCaption();
String suggestionsCaption = binding.getSuggestionsCaption();
List<T> recentItems = binding.getRecentItems();
Suggest<T> suggest = binding.getSuggest();
if ((ctrl & DISABLE_EDIT_ON_SELECT) == 0) {
control.setEditable(true);
}
boolean showAlways = (ctrl & SHOW_ALWAYS_IGNORE_EDIT_TEXT) != 0;
String editText = showAlways ? "" : control.getEditText();
// find suggestions
Collection<T> suggestedItems = suggest.find(editText, NB_OF_SUGGESTIONS, null);
// if only one suggestion is found...
if (!showAlways && suggestedItems.size() == 1) {
T thisItem = suggestedItems.iterator().next();
selectItem(binding, thisItem, ctrl);
popup.hide();
return;
}
List<MenuItem> items = new ArrayList<MenuItem>();
double menuWidth = control.getNode().getBoundsInParent().getWidth();
boolean isRecentListAvailable = recentItems != null;
if (isRecentListAvailable) {
// Menu header "Recent"
items.add(makeHeaderMenuItem(recentCaption, menuWidth));
// Items from recent list
for (T item : recentItems) {
addMenuItem(items, binding, item);
}
// Separator
SeparatorMenuItem sep = new SeparatorMenuItem();
items.add(sep);
// Menu header "Suggestions"
items.add(makeHeaderMenuItem(suggestionsCaption, menuWidth));
}
for (T item : suggestedItems) {
addMenuItem(items, binding, item);
}
popup.getItems().clear();
popup.getItems().addAll(FXCollections.observableArrayList(items));
// cm.setMaxHeight(200);
if (!popup.isShowing()) {
showPopupBelowNode(control.getNode(), popup);
// Eigentlich ist daf�r diese Methode vorgesehen:
// cm.show(cbox, javafx.geometry.Side.TOP,0,0);
// Aber dann wird das Men� an der falschen Stelle angezeigt,
// wenn es zum zweiten mal dargestellt wird.
}
}
private static <T> void addMenuItem(List<MenuItem> items, AutoCompletionBinding<T> binding, T item) {
ExtractImage<T> extractImage = binding.getExtractImage();
ImageView imageView = null;
if (extractImage != null) {
Image image = extractImage.getImage(item);
imageView = makeImageView(image);
}
MenuItem cmItem1 = new MenuItem(item.toString(), imageView);
cmItem1.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent e) {
selectItem(binding, item, DISABLE_EDIT_ON_SELECT);
}
});
items.add(cmItem1);
}
private static ImageView makeImageView(Image image) {
if (image == null) return null;
ImageView imageView = new ImageView();
imageView.setImage(image);
imageView.setFitWidth(16);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
imageView.setCache(true);
return imageView;
}
private static <T> void selectItem(AutoCompletionBinding<T> binding, T item, int ctrl) {
binding.setLockChangeEvent(true);
// Select item in control
AutoCompletionControl<T> control = binding.getControl();
control.select(item);
// Disable TextField for 1 second
if ((ctrl & DISABLE_EDIT_ON_SELECT) != 0) {
control.setEditable(false);
Timeline timeline = new Timeline(new KeyFrame(Duration.millis(1000), ae -> control.setEditable(true)));
timeline.play();
}
// Add item to recent list
List<T> recentItems = binding.getRecentItems();
if (recentItems != null && !recentItems.contains(item)) {
if (recentItems.size() >= NB_OF_RECENT_ITEMS) {
recentItems.remove(recentItems.size() - 1);
}
recentItems.add(0, item);
}
binding.setLockChangeEvent(false);
}
private static MenuItem makeHeaderMenuItem(String text, double wd) {
Label label = new Label(text);
label.setStyle("-fx-font-weight: bold;");
label.setPrefWidth(wd);
label.setMinWidth(Label.USE_PREF_SIZE);
// label.setMaxWidth(wd);
CustomMenuItem mi = new CustomMenuItem(label);
mi.setHideOnClick(false);
return mi;
}
private static void showPopupBelowNode(final Node node, final ContextMenu popup) {
final Window window = node.getScene().getWindow();
double x = window.getX() + node.localToScene(0, 0).getX() + node.getScene().getX();
double y = window.getY() + node.localToScene(0, 0).getY() + node.getScene().getY()
+ node.getBoundsInLocal().getHeight();
// + node.getBoundsInParent().getHeight();
popup.show(window, x, y);
// double ht = popup.getHeight();
// double wd = popup.getWidth();
// System.out.println("popup.height=" + ht + ", width=" + wd + ", #items=" + popup.getItems().size());
}
private static <T> AutoCompletionControl<T> createAutoCompletionControl(final ComboBox<T> cbox,
final ExtractImage<T> extractImage) {
AutoCompletionControl<T> control = new AutoCompletionControl<T>() {
public void select(T item) {
final TextField ed = cbox.getEditor();
if (item != null) {
cbox.getSelectionModel().select(item);
if (extractImage != null) {
Image image = extractImage.getImage(item);
ImageView imageView = makeImageView(image);
((TextFieldSkinWithImage) ed.getSkin()).setImageView(imageView);
}
ed.setText(item.toString());
ed.selectAll();
}
else {
cbox.getSelectionModel().select(-1);
Skin<?> tfs = ed.getSkin();
if (tfs != null) {
if (tfs instanceof TextFieldSkinWithImage) {
TextFieldSkinWithImage tfswi = ((TextFieldSkinWithImage) ed.getSkin());
tfswi.setImageView(null);
}
}
ed.setText("");
}
}
public T getSelectedItem() {
return cbox.getSelectionModel().getSelectedItem();
}
public Node getNode() {
return cbox;
}
public void setEditable(boolean en) {
cbox.getEditor().setEditable(en);
}
public boolean isEditable() {
return cbox.getEditor().isEditable();
}
public String getEditText() {
return cbox.getEditor().getText();
}
};
return control;
}
}