openjfx-docs
openjfx-docs copied to clipboard
Which repository can I submit pull requests to?
import javafx.application.Platform;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Side;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.CustomMenuItem;
import javafx.scene.control.TextField;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
/**
* This class is a TextField which implements an "autocomplete"
* functionality, based on a supplied list of entries.<br/>
* <br/>
* ATTENTION: You must call the {@code AutoCompleteTextField#cleanup()}
* method while the application is shutting down.
*
* @author Kai Liu
*/
public class AutoCompleteTextField extends TextField {
private static final Font DEFAULT_FONT = Font.getDefault();
private static final Font BOLD_FONT = Font.font(null, FontWeight.BOLD, -1);
/**
* The candidate autocomplete suggestions.
*/
private final Set<String> candidates;
/**
* The popup used to select a suggestion.
*/
private final ContextMenu suggestions;
/**
* The debounce delay.
*/
private final LongProperty debounce;
/**
* Whether the user has selected a suggestion.
*/
private volatile boolean selected;
/**
* Construct a new AutoCompleteTextField.
*/
public AutoCompleteTextField() {
this(10, 300);
}
/**
* Construct a new AutoCompleteTextField.
*
* @param limit The maximum number of entries to display.
* @param delay The debounce delay when typing, in milliseconds.
*/
public AutoCompleteTextField(int limit, long delay) {
this(limit, delay, AutoCompleteTextField::matcher);
}
/**
* Generate a matcher of the given query.
*
* @param query query to match
* @return matcher of the given query
*/
private static Function<String, MatchResult> matcher(String query) {
if (query == null || query.isEmpty()) {
return (text) -> new MatchResult(text, singletonList(text), 6);
}
return (text) -> {
int queryLength = query.length();
if (text == null || text.length() < queryLength) {
return new MatchResult(text, singletonList(text), 0);
}
MatchResult result = completelyMatch(text, query, queryLength);
return result != null ? result : partlyMatch(text, query);
};
}
/**
* Try to completely match the text.
*
* @param text text to be matched
* @param query query to match
* @param length length of query
* @return match result if completely match, null otherwise
*/
private static MatchResult completelyMatch(String text, String query, int length) {
int index = text.indexOf(query);
if (index >= 0) {
return new MatchResult(text, asList(
text.substring(0, index),
query,
text.substring(index + length)
), index == 0 ? 6 : 4);
}
Locale locale = Locale.getDefault();
String icText = text.toUpperCase(locale).toLowerCase(locale);
String icQuery = query.toUpperCase(locale).toLowerCase(locale);
index = icText.indexOf(icQuery);
return index < 0 ? null : new MatchResult(text, asList(
text.substring(0, index),
text.substring(index, index + length),
text.substring(index + length)
), index == 0 ? 5 : 3);
}
/**
* Try to partly match the text.
*
* @param text text to be matched
* @param query query to match
* @return match result
*/
private static MatchResult partlyMatch(String text, String query) {
Locale locale = Locale.getDefault();
String icText = text.toUpperCase(locale).toLowerCase(locale);
String icQuery = query.toUpperCase(locale).toLowerCase(locale);
List<Map.Entry<Integer, Integer>> indexes = new ArrayList<>();
return containsAll(icText, icQuery, indexes)
? new MatchResult(text, splitByIndexes(text, indexes), indexes.getFirst().getKey() == 0 ? 2 : 1)
: new MatchResult(text, singletonList(text), 0);
}
/**
* Tests if the text contains all the characters in the query.
*
* @param text text to be matched
* @param query query to match
* @param indexes index information of the matched chunks,
* key is the start index of the chunk,
* value is the length of the chunk.
* @return true only if the text contains all the characters in the query
*/
private static boolean containsAll(String text, String query, List<Map.Entry<Integer, Integer>> indexes) {
int from = 0;
for (int i = 0; i < query.length(); i++) {
int index = text.indexOf(query.charAt(i), from);
if (index < 0) return false;
Map.Entry<Integer, Integer> lastMatched = indexes.isEmpty() ? null : indexes.getLast();
if (lastMatched != null && lastMatched.getKey() + lastMatched.getValue() == index) {
lastMatched.setValue(lastMatched.getValue() + 1);
} else {
indexes.add(new AbstractMap.SimpleEntry<>(index, 1));
}
from = index + 1;
}
return true;
}
/**
* Split the text by the index information of the matched chunks.
*
* @param text text to be matched
* @param indexes index information of the matched chunks,
* key is the start index of the chunk,
* value is the length of the chunk.
* @return the split result as non-matched chunk, matched chunk, non-matched chunk, matched chunk, ...
*/
private static List<String> splitByIndexes(String text, List<Map.Entry<Integer, Integer>> indexes) {
List<String> result = new ArrayList<>();
int lastIndex = 0;
for (Map.Entry<Integer, Integer> index : indexes) {
result.add(text.substring(lastIndex, index.getKey()));
result.add(text.substring(index.getKey(), lastIndex = index.getKey() + index.getValue()));
}
result.add(text.substring(lastIndex));
return result;
}
/**
* Construct a new AutoCompleteTextField.
*
* @param limit The maximum number of entries to display.
* @param delay The debounce delay when typing, in milliseconds.
* @param generator The matcher generator of the special usage.
*/
public AutoCompleteTextField(int limit, long delay, Function<String, Function<String, MatchResult>> generator) {
super();
candidates = new HashSet<>();
suggestions = new ContextMenu();
debounce = new SimpleLongProperty(delay);
restartService();
textProperty().addListener(listenAndSearch(limit, generator));
focusedProperty().addListener((observable, oldValue, newValue) -> suggestions.hide());
}
/**
* Get the value of debounce delay property.
*
* @return the debounce delay
*/
public long getDebounce() {
return debounce.get();
}
/**
* Set the value of debounce delay property.
*
* @param delay the debounce delay
*/
public void setDebounce(long delay) {
debounce.set(delay);
}
/**
* Get the debounce delay property.
*
* @return the debounce delay property
*/
public LongProperty debounceProperty() {
return debounce;
}
/**
* The current search text task reference.
*/
private final AtomicReference<ScheduledFuture<?>> task = new AtomicReference<>();
/**
* The scheduled executor to execute the text property listener.
*/
private static ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
/**
* Restart the scheduled executor service if necessary.
*/
private void restartService() {
synchronized (AutoCompleteTextField.class) {
if (service.isShutdown()) {
service = Executors.newSingleThreadScheduledExecutor();
}
}
}
/**
* Perform a cleanup operation and close the thread pool.<br/>
* When AutoCompleteTextField is no longer in use, this method should be called;
* Otherwise, the thread pool will not close and memory leaks may occur.
*/
public static void cleanup() {
service.shutdown();
}
/**
* Create a listener to auto complete the text change, the popup will be shown after debounce delay.
*
* @param limit The maximum number of entries to display.
* @param generator The matcher generator of the special usage.
* @return the listener which will be added to the text property.
*/
private ChangeListener<String> listenAndSearch(int limit, Function<String, Function<String, MatchResult>> generator) {
return (observable, oldValue, newValue) -> {
ScheduledFuture<?> oldTask = task.get();
if (oldTask != null) {
oldTask.cancel(false);
}
task.set(service.schedule(() -> {
List<List<String>> result = oldValue != null && (newValue == null || oldValue.length() > newValue.length())
? null : search(candidates, newValue, limit, generator);
if (!noneSelectable(newValue, result)) {
selected = false;
}
Platform.runLater(() -> {
if (result == null || result.isEmpty() || selected) {
selected = false;
suggestions.hide();
} else {
renderSuggestions(result);
if (!suggestions.isShowing()) {
suggestions.show(AutoCompleteTextField.this, Side.BOTTOM, 0, 0);
}
}
});
}, debounce.get(), TimeUnit.MILLISECONDS));
};
}
/**
* Search for entries matching the specified query.
*
* @param candidates candidate text set.
* @param query query to match
* @param limit the maximum number of entries to return
* @param generator The matcher generator of the special usage.
* @return a list of matching entries
*/
private List<List<String>> search(Collection<String> candidates, String query, int limit,
Function<String, Function<String, MatchResult>> generator) {
return candidates.parallelStream()
.map(generator.apply(query))
.filter(MatchResult::matched)
.sorted(MatchResult::compareTo)
.limit(limit)
.map(MatchResult::chunks)
.toList();
}
/**
* Tests if none selectable to change the value.
*
* @param value new value of text field
* @param candidates candidate values of the text field
* @return true if there is more than one candidate values or the
* value is not same as the first candidate value, false otherwise.
*/
private static boolean noneSelectable(String value, List<List<String>> candidates) {
return candidates == null || candidates.isEmpty()
|| candidates.size() == 1 && value.equals(getText(candidates.getFirst()));
}
/**
* A match result.
*
* @author Kai Liu
*/
public record MatchResult(String value, List<String> chunks, double score) implements Comparable<MatchResult> {
/**
* Whether the match was successful.
*
* @return ture only the score is positive.
*/
public boolean matched() {
return score > 0;
}
/**
* Compare this match result to another in descending score order.
*
* @param o the object to be compared.
* @return a negative integer, zero, or a positive integer as this object is less than,
* equal to, or greater than the specified object.
*/
@Override
public int compareTo(MatchResult o) {
int differ = (int) Math.signum(o.score - score);
if (differ != 0) return differ;
differ = value.length() - o.value.length();
if (differ != 0) return differ;
Locale locale = Locale.getDefault();
String icValue = value.toUpperCase(locale).toLowerCase(locale);
String icOtherValue = o.value.toUpperCase(locale).toLowerCase(locale);
differ = icValue.compareTo(icOtherValue);
if (differ != 0) return differ;
return value.compareTo(o.value);
}
}
/**
* Get the existing set of autocomplete entries.
*
* @return The existing autocomplete entries.
*/
public Set<String> getCandidates() {
return candidates;
}
/**
* Render the suggestions.
*
* @param matches The set of matching strings.
*/
private void renderSuggestions(List<List<String>> matches) {
List<CustomMenuItem> menuItems = new LinkedList<>();
for (List<String> chunks : matches) {
TextFlow flow = getTextFlow(chunks);
CustomMenuItem item = new CustomMenuItem(flow, true);
item.setOnAction(actionEvent -> {
selected = true;
setText(getText(flow));
suggestions.hide();
});
menuItems.add(item);
}
suggestions.getItems().clear();
suggestions.getItems().addAll(menuItems);
}
/**
* Get the text from chunks.
*
* @param chunks text chunks
* @return the text from chunks
*/
private static String getText(List<String> chunks) {
StringBuilder sb = new StringBuilder();
chunks.forEach(sb::append);
return sb.toString();
}
/**
* Get the text from a TextFlow.
*
* @param flow a TextFlow instance
* @return the text from the TextFlow
*/
private static String getText(TextFlow flow) {
StringBuilder sb = new StringBuilder();
flow.getChildren().forEach((child) -> sb.append(((Text) child).getText()));
return sb.toString();
}
/**
* Render a TextFlow by highlighting the matching portion of each chunk.
* The first chunk is always rendered in the default font, from the second chunk,
* each chunk is rendered in bold, normal, bold, normal, bold, etc.
*
* @param chunks chunks of a search result
* @return the rendered TextFlow
*/
private static TextFlow getTextFlow(List<String> chunks) {
List<Text> labels = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
String chunk = chunks.get(i);
if (chunk == null || chunk.isEmpty()) continue;
Text label = new Text(chunk);
label.setFont((i & 1) != 0 ? BOLD_FONT : DEFAULT_FONT);
labels.add(label);
}
TextFlow flow = new TextFlow();
flow.getChildren().addAll(labels);
return flow;
}
}