close #2043: Make ModListPage searchable (#2044)

* close #2043: Make ModListPage searchable

* Optimize layout

---------

Co-authored-by: Cyenoch <cyenoch@qq.com>
This commit is contained in:
Cyenoch 2023-02-06 22:49:22 +08:00 committed by GitHub
parent bb66be7c3a
commit 9a3b8545ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 131 additions and 3 deletions

View File

@ -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<ModListPageSkin.ModInfoObjec
}, Platform::runLater);
}
public FilteredList<ModListPageSkin.ModInfoObject> getFilteredItems(StringProperty queryStringProperty) {
FilteredList<ModListPageSkin.ModInfoObject> filteredList = new FilteredList<>(getItems());
Debouncer<Integer> 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"));

View File

@ -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<ModListPage> {
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
JFXListView<ModInfoObject> 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<ModListPage> {
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<ModListPage> {
MutableObject<Object> 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);

View File

@ -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<T> {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final ConcurrentHashMap<T, TimerTask> delayedMap = new ConcurrentHashMap<>();
private final Callback<T> callback;
private final int interval;
public Debouncer(Callback<T> 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<T> {
void call(T t);
}
}