openjfx / openjfx-docs

Getting started guide for JavaFX 11
BSD 3-Clause "New" or "Revised" License
95 stars 25 forks source link

Which repository can I submit pull requests to? #253

Open lk101 opened 2 months ago

lk101 commented 2 months ago
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;
    }
}
danielpeintner commented 2 months ago

Note: I am not affiliated with this repository which contains documentation for "Getting Started with JavaFX".

Anyhow, this is definitely not the right repository for your control/extension. I am not judging the code (I did not even look at it) but an "AutoCompleteTextField" might be part of the JavaFX core part (definitely very high barrier) or part of a supporting library like: