refactor: remove multi-instance list pages and unused wizard pages
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 3m52s
Check Codes / build (pull_request) Failing after 2m45s
Java CI / build (push) Canceled after 1h0m4s
Java CI / build (pull_request) Canceled after 56m22s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
layue13andClaude Sonnet 5.5 committed 2026-11-08 14:57:06 +08:00
1 parent 2c8446fa33
commit 3a6e80da0e
9 files changed
+1 -1017

No files matched your search

@@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.instances.GameListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
@@ -128,28 +127,6 @@ public final class Controllers {
return gameInstancePage;
}
/// The cached game-list page.
private static @Nullable GameListPage gameListPage;
/// Creates and attaches the game-list page for a layout pulse unless already cached.
@FXThread
public static void prepareGameListPage() {
if (gameListPage == null) {
LOG.info("Preparing game list page");
gameListPage = getDecorator().prewarmPage(new GameListPage());
}
}
/// Returns the cached game-list page, creating it if necessary.
@FXThread
public static GameListPage getGameListPage() {
if (gameListPage == null) {
gameListPage = new GameListPage();
}
return gameListPage;
}
/// The cached root page.
private static @Nullable RootPage rootPage;
@@ -694,7 +671,6 @@ public final class Controllers {
onApplicationStop();
rootPage = null;
gameInstancePage = null;
gameListPage = null;
accountListPage = null;
settingsPage = null;
decorator = null;
@@ -1,139 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InstallersPage extends AbstractInstallersPage {
private boolean isNameModifiedByUser = false;
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
txtName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty()));
txtName.textProperty().addListener((obs, oldText, newText) -> isNameModifiedByUser = true);
}
@Override
public String getTitle() {
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
}
private String getVersion(String id) {
return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id));
}
protected void reload() {
for (InstallerItem component : group.getComponents()) {
String libraryId = component.getComponentType().getPatchId();
if (controller.getSettings().containsKey(libraryId)) {
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
} else {
component.versionProperty().set(null);
}
}
if (!isNameModifiedByUser) {
setTxtNameWithLoaders();
}
}
@Override
public void cleanup(SettingsMap settings) {
}
private static boolean checkName(String name) {
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (!(c >= '0' && c <= '9')
&& !(c >= 'a' && c <= 'z')
&& !(c >= 'A' && c <= 'Z')
&& c != '-' && c != '_' && c != '.'
)
return false;
}
return true;
}
protected void onInstall() {
GameInstanceID instanceId = new GameInstanceID(txtName.getText());
if (!checkName(instanceId.id())) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}, () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}
}
private void setTxtNameWithLoaders() {
StringBuilder nameBuilder = new StringBuilder(getTitle());
for (InstallerItem component : group.getComponents()) {
if (component.getComponentType() == GameComponentType.GAME
|| !controller.getSettings().containsKey(component.getComponentType().getPatchId()))
continue;
String loaderName = switch (component.getComponentType()) {
case FORGE -> "Forge";
case NEO_FORGE -> "NeoForge";
case CLEANROOM -> "Cleanroom";
case LEGACY_FABRIC -> "LegacyFabric";
case FABRIC -> "Fabric";
case LITELOADER -> "LiteLoader";
case QUILT -> "Quilt";
case OPTIFINE -> "OptiFine";
default -> null;
};
if (loaderName != null)
nameBuilder.append('-').append(loaderName);
}
txtName.setText(nameBuilder.toString());
isNameModifiedByUser = false;
}
@Override
protected boolean showExtendPane() {
return true;
}
@Override
protected void resetDefaultName() {
isNameModifiedByUser = false;
setTxtNameWithLoaders();
}
}
@@ -1,132 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameItem {
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
protected final HMCLGameInstance gameInstance;
private boolean initialized = false;
private StringProperty title;
private StringProperty tag;
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(HMCLGameInstance gameInstance) {
this.gameInstance = gameInstance;
}
public GameDirectory getGameDirectory() {
return gameInstance.getRepository().getGameDirectory();
}
public HMCLGameRepository getRepository() {
return gameInstance.getRepository();
}
public GameInstanceID getInstanceId() {
return gameInstance.getId();
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
public String getId() {
return gameInstance.getId().toString();
}
private void init() {
if (initialized)
return;
initialized = true;
title = new SimpleStringProperty();
tag = new SimpleStringProperty();
subtitle = new SimpleStringProperty();
image = new SimpleObjectProperty<>();
record Result(@Nullable String gameVersion, @Nullable String tag) {
}
CompletableFuture.supplyAsync(() -> {
// Version detection performs blocking JAR reads and may hash the entire file.
GameVersionNumber version = gameInstance.getVersion();
@Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString();
@Nullable String modPackVersion = null;
try {
@Nullable ModpackConfiguration<?> config = gameInstance.readModpackConfiguration();
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + getId(), e);
}
return new Result(gameVersion, modPackVersion);
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
if (exception == null) {
if (result.tag != null) {
tag.set(result.tag);
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
GameComponentAnalyzer analyzer = gameInstance.getAnalyzer();
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) continue;
if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) {
libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId()));
if (mark.version() != null)
libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), ""));
}
}
subtitle.set(libraries.toString());
} else {
LOG.warning("Failed to read version info from " + getId(), exception);
}
}, Schedulers.javafx());
title.set(getId());
image.set(gameInstance.getIconImage());
}
public ReadOnlyStringProperty titleProperty() {
init();
return title;
}
public ReadOnlyStringProperty tagProperty() {
init();
return tag;
}
public ReadOnlyStringProperty subtitleProperty() {
init();
return subtitle;
}
public ReadOnlyObjectProperty<Image> imageProperty() {
init();
return image;
}
}
@@ -1,191 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class GameListCell extends ListCell<GameListItem> {
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final TwoLineListItem content;
private final JFXRadioButton chkSelected;
private final JFXButton btnUpgrade;
/// Opens the settings for this cell's game instance.
private final JFXButton btnEditInstance;
private final JFXButton btnManage;
private final HBox right;
private final StringProperty tag = new SimpleStringProperty();
/// Creates a game instance cell constrained to the supplied list's viewport width.
public GameListCell(ListView<GameListItem> listView) {
FXUtils.limitCellWidth(listView, this);
TwoLineListItem root = this.content = new TwoLineListItem();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8, 8, 8, 0));
RipplerContainer container = new RipplerContainer(root);
this.graphic = container;
{
this.chkSelected = new JFXRadioButton() {
/// Selects this cell's game instance when the radio button is enabled and unselected.
@Override
public void fire() {
if (!isDisable() && !isSelected()) {
fireEvent(new ActionEvent());
@Nullable GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getGameInstance());
}
}
}
};
}
{
this.imageView = new ImageContainer(32);
imageView.setMouseTransparent(true);
HBox leading = new HBox(8, chkSelected, imageView);
leading.setAlignment(Pos.CENTER_LEFT);
leading.setPickOnBounds(false);
TwoLineListItem.setMargin(leading, new Insets(0, 8, 0, 0));
content.setLeading(leading);
FXUtils.onChangeAndOperate(tag, tag -> {
if (StringUtils.isNotBlank(tag))
content.getTags().setAll(new Tag(tag));
else
content.getTags().clear();
});
}
{
this.right = new HBox();
content.setTrailing(right);
right.setAlignment(Pos.CENTER_RIGHT);
right.setPickOnBounds(false);
this.btnUpgrade = FXUtils.newToggleButton4(SVG.UPDATE);
btnUpgrade.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item != null)
item.update();
});
FXUtils.installFastTooltip(btnUpgrade, i18n("instance.update"));
right.getChildren().add(btnUpgrade);
this.btnEditInstance = FXUtils.newToggleButton4(SVG.SETTINGS);
btnEditInstance.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item != null)
item.modifyGameSettings();
});
FXUtils.installFastTooltip(btnEditInstance, i18n("instance.manage.manage"));
right.getChildren().add(btnEditInstance);
this.btnManage = FXUtils.newToggleButton4(SVG.MORE_VERT);
btnManage.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item == null)
return;
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
@Nullable GameListItem item = getItem();
if (item == null)
return;
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
item.getRepository().setSelectedInstance(item.getGameInstance());
}
} else if (e.getButton() == MouseButton.SECONDARY) {
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
@Override
public void updateItem(GameListItem item, boolean empty) {
GameListItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.resetFeedback();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
this.right.getChildren().clear();
this.chkSelected.selectedProperty().unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.chkSelected.selectedProperty().bind(item.selectedProperty());
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
if (item.canUpdate())
this.right.getChildren().add(btnUpgrade);
this.right.getChildren().addAll(btnEditInstance, btnManage);
}
}
private static JFXPopup getPopup(GameListItem item) {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
}
}
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jetbrains.annotations.Nullable;
public class GameListItem extends GameItem {
private final boolean isModpack;
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(HMCLGameInstance gameInstance) {
super(gameInstance);
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
this.isModpack = gameInstance.isModpack();
selected.bind(Bindings.createBooleanBinding(
() -> {
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
@Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance();
return selectedInstance != null && selectedInstance.getId().equals(instanceId);
},
GameDirectoryManager.selectedGameDirectoryProperty(),
GameDirectoryManager.selectedInstanceProperty()));
}
public ReadOnlyBooleanProperty selectedProperty() {
return selected;
}
public void rename() {
Instances.renameInstance(gameInstance);
}
public void duplicate() {
Instances.duplicateInstance(gameInstance);
}
public void remove() {
Instances.deleteInstance(gameInstance);
}
public void browse() {
Instances.openFolder(gameInstance);
}
public void testGame() {
Instances.testGame(gameInstance);
}
public void launch() {
Instances.launch(gameInstance);
}
public void modifyGameSettings() {
Instances.modifyGameSettings(gameInstance);
}
public void generateLaunchScript() {
Instances.generateLaunchScript(gameInstance);
}
public boolean canUpdate() {
return isModpack;
}
public void update() {
Instances.updateInstance(gameInstance);
}
}
@@ -1,248 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("instance.manage")));
/// Creates the fixed-directory instance list.
public GameListPage() {
getSidebar()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
setContent(new GameList());
FXUtils.applyDragListener(this, file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getNameWithoutExtension(file)), files -> {
Path file = files.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file), i18n("install.modpack"));
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
});
}
public void modifyGlobalGameSettings() {
Instances.modifyGlobalSettings(GameDirectoryManager.getSelectedRepository());
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
private static class GameList extends ListPageBase<GameListItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private final ObservableList<GameListItem> sourceList = FXCollections.observableArrayList();
private final FilteredList<GameListItem> filteredList = new FilteredList<>(sourceList);
public GameList() {
setItems(filteredList);
GameDirectoryManager.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Instances.installServerModpack());
}
@FXThread
private void loadVersions(HMCLGameRepository repository) {
listenerHolder.clear();
setLoading(true);
setFailedReason(null);
List<GameListItem> instanceItems = repository.getDisplayInstances()
.map(GameListItem::new)
.toList();
sourceList.setAll(instanceItems);
if (instanceItems.isEmpty()) {
setFailedReason(i18n("instance.empty.hint"));
}
setLoading(false);
}
private Predicate<GameListItem> createPredicate(String searchText) {
if (searchText == null || searchText.isEmpty()) {
return item -> true;
}
if (searchText.startsWith("regex:")) {
String regex = searchText.substring("regex:".length());
try {
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
return item -> pattern.matcher(item.getId()).find();
} catch (PatternSyntaxException e) {
return item -> false;
}
} else {
return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
}
}
public void refreshList() {
GameDirectoryManager.getSelectedRepository().refreshAsync().start();
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameListSkin(this);
}
private static class GameListSkin extends SkinBase<GameList> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Creates the instance list with search controls and viewport-constrained rows.
public GameListSkin(GameList skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
JFXListView<GameListItem> listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::installServerModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
center.failedReasonProperty().bind(skinnable.failedReasonProperty());
listView.setCellFactory(GameListCell::new);
listView.setItems(skinnable.getItems());
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
}
}
}
@@ -1,187 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.Tag;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Displays game instances in a popup selection list.
///
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
private static final String KEY = GameListPopupMenu.class.getName() + ".popup";
public static boolean hideShowing(Node owner) {
JFXPopup popup = (JFXPopup) owner.getProperties().get(KEY);
if (popup != null && popup.isShowing()) {
popup.hide();
return true;
} else {
return false;
}
}
/// Shows an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
List<HMCLGameInstance> instances) {
if (!hideShowing(owner)) {
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances);
}
}
/// Shows and returns an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
/// @return the shown popup
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
List<HMCLGameInstance> instances) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(instances.stream()
.map(GameItem::new)
.toList());
JFXPopup popup = new JFXPopup(menu);
owner.getProperties().put(KEY, popup);
popup.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> owner.getProperties().remove(KEY, popup));
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY, true);
return popup;
}
private final JFXListView<GameItem> listView = new JFXListView<>();
private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems());
public GameListPopupMenu() {
this.setMaxHeight(365);
this.getStyleClass().add("popup-menu-content");
listView.setCellFactory(Cell::new);
listView.setFixedCellSize(50);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
Label placeholder = new Label(i18n("instance.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> getChildren().setAll(empty ? placeholder : listView));
}
public ObservableList<GameItem> getItems() {
return listView.getItems();
}
private static final class Cell extends ListCell<GameItem> {
private final Region graphic;
private final RipplerContainer ripplerContainer;
private final ImageContainer imageView;
private final TwoLineListItem content;
private final StringProperty tag = new SimpleStringProperty();
/// Creates a popup row with a leading icon and the shared advanced-item text styling.
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
this.imageView = new ImageContainer(32);
this.imageView.setMouseTransparent(true);
this.content = new TwoLineListItem();
content.getStyleClass().add("container");
content.setLeading(imageView);
FXUtils.onChangeAndOperate(tag, tag -> {
if (StringUtils.isNotBlank(tag))
content.getTags().setAll(new Tag(tag));
else
content.getTags().clear();
});
this.ripplerContainer = new RipplerContainer(content);
StackPane rootPane = new StackPane();
rootPane.getStyleClass().add("advanced-list-item");
rootPane.getChildren().setAll(ripplerContainer);
rootPane.maxWidthProperty().bind(listView.widthProperty().subtract(5));
FXUtils.onClicked(rootPane, () -> {
@Nullable GameItem item = getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getGameInstance());
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
});
this.graphic = rootPane;
}
@Override
protected void updateItem(GameItem item, boolean empty) {
GameItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.ripplerContainer.resetFeedback();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
}
}
}
}
@@ -35,7 +35,6 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.Lang;
@@ -21,7 +21,6 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -53,17 +52,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
gameInstanceItem.setOnAction(e -> {
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Controllers.navigate(Controllers.getGameListPage());
Instances.installServerModpack();
} else {
Instances.modifyGameSettings(instance);
}
});
FXUtils.onScroll(gameInstanceItem, getMainPage().getInstances(), list -> {
@Nullable HMCLGameInstance currentGame = getMainPage().getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
if (AnimationUtils.isAnimationEnabled()) {
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
}
@@ -116,14 +109,4 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
}
return mainPage;
}
/// Opens the instance selection menu beside the sidebar entry.
private void showGameListPopupMenu(Region gameListItem) {
GameListPopupMenu.show(gameListItem,
JFXPopup.PopupVPosition.TOP,
JFXPopup.PopupHPosition.LEFT,
gameListItem.getWidth(),
0,
getMainPage().getInstances());
}
}