From 9a3b8545cab58740f4962f12e3e85bcfe3fce2d0 Mon Sep 17 00:00:00 2001 From: Cyenoch <1759761439@qq.com> Date: Mon, 6 Feb 2023 22:49:22 +0800 Subject: [PATCH] close #2043: Make ModListPage searchable (#2044) * close #2043: Make ModListPage searchable * Optimize layout --------- Co-authored-by: Cyenoch --- .../hmcl/ui/versions/ModListPage.java | 27 +++++++ .../hmcl/ui/versions/ModListPageSkin.java | 33 ++++++++- .../org/jackhuang/hmcl/util/Debouncer.java | 74 +++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/util/Debouncer.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index e72eb2c43..0d1e7f821 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -20,7 +20,9 @@ package org.jackhuang.hmcl.ui.versions; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.StringProperty; import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; import javafx.scene.control.Skin; import javafx.stage.FileChooser; import org.jackhuang.hmcl.download.LibraryAnalyzer; @@ -34,6 +36,7 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageAware; +import org.jackhuang.hmcl.util.Debouncer; import org.jackhuang.hmcl.util.Logging; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.FileUtils; @@ -113,6 +116,30 @@ public final class ModListPage extends ListPageBase getFilteredItems(StringProperty queryStringProperty) { + FilteredList filteredList = new FilteredList<>(getItems()); + Debouncer searchFieldDebouncer = new Debouncer<>((key) -> runInFX(() -> { + String searchText = queryStringProperty.get(); + if (searchText.isEmpty()) + filteredList.setPredicate((item) -> true); + else + filteredList.setPredicate((item) -> { + LocalModFile modInfo = item.getModInfo(); + if (searchText.startsWith("$:")) { + // Use regular matching pattern. + try { + return modInfo.getFileName().matches(searchText.substring(2)); + } catch (Exception exception) { + return true; + } + } + return modInfo.getFileName().toLowerCase().contains(searchText.toLowerCase()); + }); + }), 400); + FXUtils.onChangeAndOperate(queryStringProperty, (text) -> searchFieldDebouncer.call(1)); + return filteredList; + } + public void add() { FileChooser chooser = new FileChooser(); chooser.setTitle(i18n("mods.choose_mod")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 9f5015628..921b58251 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -23,6 +23,7 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.SelectionMode; import javafx.scene.control.SkinBase; @@ -59,7 +60,7 @@ import java.nio.file.Path; import java.util.Locale; import java.util.stream.Collectors; -import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.ui.FXUtils.*; import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; @@ -78,6 +79,31 @@ class ModListPageSkin extends SkinBase { ComponentList root = new ComponentList(); root.getStyleClass().add("no-padding"); JFXListView listView = new JFXListView<>(); + JFXTextField searchField = new JFXTextField(); + + { + HBox searchBar = new HBox(); + searchBar.setAlignment(Pos.BASELINE_CENTER); + searchBar.setPadding(new Insets(8, 8, 8, 8)); + + HBox.setHgrow(searchField, Priority.ALWAYS); + searchField.setPromptText(i18n("search")); + + JFXButton clearBtn = new JFXButton(); + clearBtn.setGraphic(SVG.close(Theme.blackFillBinding(), -1, -1)); + clearBtn.setOnMouseClicked((event) -> { + searchField.textProperty().set(""); + }); + Node clearBtnWrapped = wrapMargin(clearBtn, new Insets(0, 0, 0, 9)); + FXUtils.onChangeAndOperate(searchField.textProperty(), (text) -> { + if (text.isEmpty() && searchBar.getChildren().contains(clearBtnWrapped)) + searchBar.getChildren().remove(clearBtnWrapped); + else if (!searchBar.getChildren().contains(clearBtnWrapped)) + searchBar.getChildren().add(clearBtnWrapped); + }); + searchBar.getChildren().setAll(searchField); + root.getContent().add(searchBar); + } { TransitionPane toolBarPane = new TransitionPane(); @@ -87,7 +113,8 @@ class ModListPageSkin extends SkinBase { createToolbarButton2(i18n("mods.add"), SVG::plus, skinnable::add), createToolbarButton2(i18n("folder.mod"), SVG::folderOpen, skinnable::openModFolder), createToolbarButton2(i18n("mods.check_updates"), SVG::update, skinnable::checkUpdates), - createToolbarButton2(i18n("download"), SVG::downloadOutline, skinnable::download)); + createToolbarButton2(i18n("download"), SVG::downloadOutline, skinnable::download) + ); HBox toolbarSelecting = new HBox(); toolbarSelecting.getChildren().setAll( createToolbarButton2(i18n("button.remove"), SVG::delete, () -> { @@ -122,7 +149,7 @@ class ModListPageSkin extends SkinBase { MutableObject lastCell = new MutableObject<>(); listView.setCellFactory(x -> new ModInfoListCell(listView, lastCell)); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); - Bindings.bindContent(listView.getItems(), skinnable.getItems()); + Bindings.bindContent(listView.getItems(), skinnable.getFilteredItems(searchField.textProperty())); center.setContent(listView); root.getContent().add(center); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/Debouncer.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/Debouncer.java new file mode 100644 index 000000000..da023b6cf --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/Debouncer.java @@ -0,0 +1,74 @@ +package org.jackhuang.hmcl.util; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class Debouncer { + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final ConcurrentHashMap delayedMap = new ConcurrentHashMap<>(); + private final Callback callback; + private final int interval; + + public Debouncer(Callback c, int interval) { + this.callback = c; + this.interval = interval; + } + + public void call(T key) { + TimerTask task = new TimerTask(key); + + TimerTask prev; + do { + prev = delayedMap.putIfAbsent(key, task); + if (prev == null) scheduler.schedule(task, interval, TimeUnit.MILLISECONDS); + } while (prev != null && !prev.extend()); // Exit only if new task was added to map, or existing task was extended successfully + } + + public void terminate() { + scheduler.shutdownNow(); + } + + // The task that wakes up when the wait time elapses + private class TimerTask implements Runnable { + private final T key; + private long dueTime; + private final Object lock = new Object(); + + public TimerTask(T key) { + this.key = key; + extend(); + } + + public boolean extend() { + synchronized (lock) { + if (dueTime < 0) // Task has been shutdown + return false; + dueTime = System.currentTimeMillis() + interval; + return true; + } + } + + public void run() { + synchronized (lock) { + long remaining = dueTime - System.currentTimeMillis(); + if (remaining > 0) { // Re-schedule task + scheduler.schedule(this, remaining, TimeUnit.MILLISECONDS); + } else { // Mark as terminated and invoke callback + dueTime = -1; + try { + callback.call(key); + } finally { + delayedMap.remove(key); + } + } + } + } + } + + public interface Callback { + void call(T t); + } +} +