使用 JFXListView 显示实例列表 (#5174)

This commit is contained in:
Glavo
2026-01-11 20:55:17 +08:00
committed by GitHub
parent ab5ef6e0ff
commit 3dc1631207
10 changed files with 557 additions and 383 deletions

View File

@@ -0,0 +1,75 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import java.util.List;
// TODO: Replace ToolbarListPageSkin with this class gradually
public abstract class ToolbarListPageSkin2<E, P extends ListPageBase<E>> extends SkinBase<P> {
protected final JFXListView<E> listView;
public ToolbarListPageSkin2(P skinnable) {
super(skinnable);
SpinnerPane spinnerPane = new SpinnerPane();
spinnerPane.loadingProperty().bind(skinnable.loadingProperty());
spinnerPane.failedReasonProperty().bind(skinnable.failedReasonProperty());
spinnerPane.onFailedActionProperty().bind(skinnable.onFailedActionProperty());
spinnerPane.getStyleClass().add("large-spinner-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
StackPane.setMargin(root, new Insets(10));
List<Node> toolbarButtons = initializeToolbar(skinnable);
if (!toolbarButtons.isEmpty()) {
HBox toolbar = new HBox();
toolbar.setAlignment(Pos.CENTER_LEFT);
toolbar.setPickOnBounds(false);
toolbar.getChildren().setAll(toolbarButtons);
root.getContent().add(toolbar);
}
{
this.listView = new JFXListView<>();
this.listView.setPadding(Insets.EMPTY);
ComponentList.setVgrow(listView, Priority.ALWAYS);
Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty());
root.getContent().add(listView);
}
spinnerPane.setContent(root);
getChildren().setAll(spinnerPane);
}
protected abstract List<Node> initializeToolbar(P skinnable);
}

View File

@@ -55,4 +55,8 @@ public class WeakListenerHolder {
public boolean remove(Object obj) {
return refs.remove(obj);
}
public void clear() {
refs.clear();
}
}

View File

@@ -22,14 +22,12 @@ import com.jfoenix.controls.JFXPopup;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
@@ -59,10 +57,10 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.GameItem;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
@@ -73,7 +71,6 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.util.List;
@@ -92,16 +89,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final PopupMenu menu = new PopupMenu();
private final StackPane popupWrapper = new StackPane(menu);
private final JFXPopup popup = new JFXPopup(popupWrapper);
private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame");
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
private final ObservableList<Version> versions = FXCollections.observableArrayList();
private final ObservableList<Node> versionNodes;
private Profile profile;
private TransitionPane announcementPane;
@@ -273,19 +264,6 @@ public final class MainPage extends StackPane implements DecoratorPage {
getChildren().addAll(updatePane, launchPane);
menu.setMaxHeight(365);
menu.setMaxWidth(545);
menu.setAlwaysShowingVBar(false);
FXUtils.onClicked(menu, popup::hide);
versionNodes = MappedObservableList.create(versions, version -> {
Node node = PopupMenu.wrapPopupMenuItem(new GameItem(profile, version.getId()));
FXUtils.onClicked(node, () -> {
profile.setSelectedVersion(version.getId());
popup.hide();
});
return node;
});
Bindings.bindContent(menu.getContent(), versionNodes);
}
private void showUpdate(boolean show) {
@@ -365,20 +343,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
private void onMenu() {
Node contentNode;
if (menu.getContent().isEmpty()) {
Label placeholder = new Label(i18n("version.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
contentNode = placeholder;
} else {
contentNode = menu;
}
popupWrapper.getChildren().setAll(contentNode);
if (popup.isShowing()) {
popup.hide();
}
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,

View File

@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,107 +17,125 @@
*/
package org.jackhuang.hmcl.ui.versions;
import com.google.gson.JsonParseException;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.beans.property.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
import static org.jackhuang.hmcl.util.Lang.handleUncaught;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameItem extends Control {
public class GameItem {
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 1, TimeUnit.SECONDS);
protected final Profile profile;
protected final String id;
private final Profile profile;
private final String version;
private final StringProperty title = new SimpleStringProperty();
private final StringProperty tag = new SimpleStringProperty();
private final StringProperty subtitle = new SimpleStringProperty();
private final ObjectProperty<Image> image = new SimpleObjectProperty<>();
private boolean initialized = false;
private StringProperty title;
private StringProperty tag;
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(Profile profile, String id) {
this.profile = profile;
this.version = id;
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
CompletableFuture.supplyAsync(() -> profile.getRepository().getGameVersion(id), POOL_VERSION_RESOLVE)
.thenAcceptAsync(game -> {
StringBuilder libraries = new StringBuilder(game.orElse(i18n("message.unknown")));
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), game.orElse(null));
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
if (I18n.hasKey("install.installer." + libraryId)) {
libraries.append(", ").append(i18n("install.installer." + libraryId));
if (libraryVersion != null)
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
}
}
subtitle.set(libraries.toString());
}, Platform::runLater)
.exceptionally(handleUncaught);
CompletableFuture.runAsync(() -> {
try {
ModpackConfiguration<?> config = profile.getRepository().readModpackConfiguration(version);
if (config == null) return;
tag.set(config.getVersion());
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to read modpack configuration from " + version, e);
}
}, Platform::runLater)
.exceptionally(handleUncaught);
title.set(id);
image.set(profile.getRepository().getVersionIconImage(version));
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameItemSkin(this);
this.id = id;
}
public Profile getProfile() {
return profile;
}
public String getVersion() {
return version;
public String getId() {
return id;
}
public StringProperty titleProperty() {
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(() -> {
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
Optional<String> gameVersion = profile.getRepository().getGameVersion(id);
String modPackVersion = null;
try {
ModpackConfiguration<?> config = profile.getRepository().readModpackConfiguration(id);
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + id, e);
}
return new Result(gameVersion.orElse(null), modPackVersion);
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
if (exception == null) {
if (result.gameVersion != null) {
title.set(result.gameVersion);
}
if (result.tag != null) {
tag.set(result.tag);
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), result.gameVersion);
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
if (I18n.hasKey("install.installer." + libraryId)) {
libraries.append(", ").append(i18n("install.installer." + libraryId));
if (libraryVersion != null)
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
}
}
subtitle.set(libraries.toString());
} else {
LOG.warning("Failed to read version info from " + id, exception);
}
}, Schedulers.javafx());
title.set(id);
image.set(profile.getRepository().getVersionIconImage(id));
}
public ReadOnlyStringProperty titleProperty() {
init();
return title;
}
public StringProperty tagProperty() {
public ReadOnlyStringProperty tagProperty() {
init();
return tag;
}
public StringProperty subtitleProperty() {
public ReadOnlyStringProperty subtitleProperty() {
init();
return subtitle;
}
public ObjectProperty<Image> imageProperty() {
public ReadOnlyObjectProperty<Image> imageProperty() {
init();
return image;
}
}

View File

@@ -1,59 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
public class GameItemSkin extends SkinBase<GameItem> {
public GameItemSkin(GameItem skinnable) {
super(skinnable);
HBox center = new HBox();
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, 32);
FXUtils.setLimitHeight(imageViewContainer, 32);
ImageView imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
imageView.imageProperty().bind(skinnable.imageProperty());
imageViewContainer.getChildren().setAll(imageView);
TwoLineListItem item = new TwoLineListItem();
item.titleProperty().bind(skinnable.titleProperty());
FXUtils.onChangeAndOperate(skinnable.tagProperty(), tag -> {
item.getTags().clear();
if (StringUtils.isNotBlank(tag))
item.addTag(tag);
});
item.subtitleProperty().bind(skinnable.subtitleProperty());
BorderPane.setAlignment(item, Pos.CENTER);
center.getChildren().setAll(imageView, item);
getChildren().setAll(center);
}
}

View File

@@ -0,0 +1,212 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
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.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
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 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 Region graphic;
private final ImageView imageView;
private final TwoLineListItem content;
private final JFXRadioButton chkSelected;
private final JFXButton btnUpgrade;
private final JFXButton btnLaunch;
private final JFXButton btnManage;
private final HBox right;
private final StringProperty tag = new SimpleStringProperty();
public GameListCell() {
BorderPane root = new BorderPane();
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() {
@Override
public void fire() {
if (!isDisable() && !isSelected()) {
fireEvent(new ActionEvent());
GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getProfile().setSelectedVersion(item.getId());
}
}
}
};
root.setLeft(chkSelected);
BorderPane.setAlignment(chkSelected, Pos.CENTER);
}
{
HBox center = new HBox();
root.setCenter(center);
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
this.imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
this.content = new TwoLineListItem();
BorderPane.setAlignment(content, Pos.CENTER);
FXUtils.onChangeAndOperate(tag, tag -> {
content.getTags().clear();
if (StringUtils.isNotBlank(tag))
content.addTag(tag);
});
center.getChildren().setAll(imageView, content);
}
{
this.right = new HBox();
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
this.btnUpgrade = new JFXButton();
btnUpgrade.setOnAction(e -> {
GameListItem item = this.getItem();
if (item != null)
item.update();
});
btnUpgrade.getStyleClass().add("toggle-icon4");
btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
right.getChildren().add(btnUpgrade);
this.btnLaunch = new JFXButton();
btnLaunch.setOnAction(e -> {
GameListItem item = this.getItem();
if (item != null)
item.testGame();
});
btnLaunch.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
right.getChildren().add(btnLaunch);
this.btnManage = new JFXButton();
btnManage.setOnAction(e -> {
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());
});
btnManage.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnManage, Pos.CENTER);
btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
GameListItem item = getItem();
if (item == null)
return;
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
item.modifyGameSettings();
}
} 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) {
super.updateItem(item, empty);
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(btnLaunch, 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("version.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), item::modifyGameSettings, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), item::remove, popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
}
}

View File

@@ -18,87 +18,58 @@
package org.jackhuang.hmcl.ui.versions;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.control.ToggleGroup;
import org.jackhuang.hmcl.setting.Profile;
public class GameListItem extends Control {
private final Profile profile;
private final String version;
public class GameListItem extends GameItem {
private final boolean isModpack;
private final ToggleGroup toggleGroup;
private final BooleanProperty selected = new SimpleBooleanProperty();
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(ToggleGroup toggleGroup, Profile profile, String id) {
this.profile = profile;
this.version = id;
this.toggleGroup = toggleGroup;
public GameListItem(Profile profile, String id) {
super(profile, id);
this.isModpack = profile.getRepository().isModpack(id);
selected.set(id.equals(profile.getSelectedVersion()));
selected.bind(profile.selectedVersionProperty().isEqualTo(id));
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameListItemSkin(this);
}
public ToggleGroup getToggleGroup() {
return toggleGroup;
}
public Profile getProfile() {
return profile;
}
public String getVersion() {
return version;
}
public BooleanProperty selectedProperty() {
public ReadOnlyBooleanProperty selectedProperty() {
return selected;
}
public void checkSelection() {
selected.set(version.equals(profile.getSelectedVersion()));
}
public void rename() {
Versions.renameVersion(profile, version);
Versions.renameVersion(profile, id);
}
public void duplicate() {
Versions.duplicateVersion(profile, version);
Versions.duplicateVersion(profile, id);
}
public void remove() {
Versions.deleteVersion(profile, version);
Versions.deleteVersion(profile, id);
}
public void export() {
Versions.exportVersion(profile, version);
Versions.exportVersion(profile, id);
}
public void browse() {
Versions.openFolder(profile, version);
Versions.openFolder(profile, id);
}
public void testGame() {
Versions.testGame(profile, version);
Versions.testGame(profile, id);
}
public void launch() {
Versions.launch(profile, version);
Versions.launch(profile, id);
}
public void modifyGameSettings() {
Versions.modifyGameSettings(profile, version);
Versions.modifyGameSettings(profile, id);
}
public void generateLaunchScript() {
Versions.generateLaunchScript(profile, version);
Versions.generateLaunchScript(profile, id);
}
public boolean canUpdate() {
@@ -106,6 +77,6 @@ public class GameListItem extends Control {
}
public void update() {
Versions.updateVersion(profile, version);
Versions.updateVersion(profile, id);
}
}

View File

@@ -1,135 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXRadioButton;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.Lazy;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListItemSkin extends SkinBase<GameListItem> {
private static GameListItem currentSkinnable;
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), () -> currentSkinnable.testGame(), popup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> currentSkinnable.generateLaunchScript(), popup),
new MenuSeparator(),
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), () -> currentSkinnable.modifyGameSettings(), popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), () -> currentSkinnable.rename(), popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), () -> currentSkinnable.duplicate(), popup),
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), () -> currentSkinnable.remove(), popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), () -> currentSkinnable.export(), popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), () -> currentSkinnable.browse(), popup));
return popup;
});
public GameListItemSkin(GameListItem skinnable) {
super(skinnable);
BorderPane root = new BorderPane();
JFXRadioButton chkSelected = new JFXRadioButton();
BorderPane.setAlignment(chkSelected, Pos.CENTER);
chkSelected.setUserData(skinnable);
chkSelected.selectedProperty().bindBidirectional(skinnable.selectedProperty());
chkSelected.setToggleGroup(skinnable.getToggleGroup());
root.setLeft(chkSelected);
GameItem gameItem = new GameItem(skinnable.getProfile(), skinnable.getVersion());
gameItem.setMouseTransparent(true);
root.setCenter(gameItem);
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
if (skinnable.canUpdate()) {
JFXButton btnUpgrade = new JFXButton();
btnUpgrade.setOnAction(e -> skinnable.update());
btnUpgrade.getStyleClass().add("toggle-icon4");
btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
right.getChildren().add(btnUpgrade);
}
{
JFXButton btnLaunch = new JFXButton();
btnLaunch.setOnAction(e -> skinnable.testGame());
btnLaunch.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
right.getChildren().add(btnLaunch);
}
{
JFXButton btnManage = new JFXButton();
btnManage.setOnAction(e -> {
currentSkinnable = skinnable;
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get());
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
btnManage.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnManage, Pos.CENTER);
btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setRight(right);
root.getStyleClass().add("md-list-cell");
root.setStyle("-fx-padding: 8 8 8 0");
RipplerContainer container = new RipplerContainer(root);
getChildren().setAll(container);
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
skinnable.modifyGameSettings();
}
} else if (e.getButton() == MouseButton.SECONDARY) {
currentSkinnable = skinnable;
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get());
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
}

View File

@@ -23,10 +23,8 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.ui.*;
@@ -36,13 +34,12 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.profile.ProfileListItem;
import org.jackhuang.hmcl.ui.profile.ProfilePage;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
@@ -53,8 +50,6 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
private final ObservableList<ProfileListItem> profileListItems;
private final ObjectProperty<Profile> selectedProfile;
private ToggleGroup toggleGroup;
public GameListPage() {
profileListItems = MappedObservableList.create(profilesProperty(), profile -> {
ProfileListItem item = new ProfileListItem(profile);
@@ -123,50 +118,31 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
return state.getReadOnlyProperty();
}
private class GameList extends ListPageBase<GameListItem> {
public GameList() {
super();
private static class GameList extends ListPageBase<GameListItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
public GameList() {
Profiles.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
}
@FXThread
private void loadVersions(Profile profile) {
listenerHolder.clear();
setLoading(true);
setFailedReason(null);
HMCLGameRepository repository = profile.getRepository();
toggleGroup = new ToggleGroup();
WeakListenerHolder listenerHolder = new WeakListenerHolder();
toggleGroup.getProperties().put("ReferenceHolder", listenerHolder);
runInFX(() -> {
if (profile == Profiles.getSelectedProfile()) {
setLoading(false);
List<GameListItem> children = repository.getDisplayVersions()
.map(version -> new GameListItem(toggleGroup, profile, version.getId()))
.collect(Collectors.toList());
itemsProperty().setAll(children);
children.forEach(GameListItem::checkSelection);
if (profile != Profiles.getSelectedProfile())
return;
if (children.isEmpty()) {
setFailedReason(i18n("version.empty.hint"));
}
profile.selectedVersionProperty().addListener(listenerHolder.weak((a, b, newValue) -> {
FXUtils.checkFxUserThread();
children.forEach(it -> it.selectedProperty().set(false));
children.stream()
.filter(it -> it.getVersion().equals(newValue))
.findFirst()
.ifPresent(it -> it.selectedProperty().set(true));
}));
}
toggleGroup.selectedToggleProperty().addListener((o, a, toggle) -> {
if (toggle == null) return;
GameListItem model = (GameListItem) toggle.getUserData();
model.getProfile().setSelectedVersion(model.getVersion());
});
});
ObservableList<GameListItem> children = FXCollections.observableList(profile.getRepository().getDisplayVersions()
.map(instance -> new GameListItem(profile, instance.getId()))
.toList());
setItems(children);
if (children.isEmpty()) {
setFailedReason(i18n("version.empty.hint"));
}
setLoading(false);
}
public void refreshList() {
@@ -178,10 +154,11 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
return new GameListSkin();
}
private class GameListSkin extends ToolbarListPageSkin<GameList> {
private class GameListSkin extends ToolbarListPageSkin2<GameListItem, GameList> {
public GameListSkin() {
super(GameList.this);
this.listView.setCellFactory(listView -> new GameListCell());
}
@Override

View File

@@ -0,0 +1,144 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
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.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
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(60);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(60).add(2));
Label placeholder = new Label(i18n("version.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 ImageView imageView;
private final TwoLineListItem content;
private final StringProperty tag = new SimpleStringProperty();
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
HBox root = new HBox();
root.setSpacing(8);
root.setAlignment(Pos.CENTER_LEFT);
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, 32);
FXUtils.setLimitHeight(imageViewContainer, 32);
this.imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
imageViewContainer.getChildren().setAll(imageView);
this.content = new TwoLineListItem();
FXUtils.onChangeAndOperate(tag, tag -> {
content.getTags().clear();
if (StringUtils.isNotBlank(tag)) {
content.addTag(tag);
}
});
BorderPane.setAlignment(content, Pos.CENTER);
root.getChildren().setAll(imageView, content);
StackPane pane = new StackPane();
pane.getChildren().setAll(root);
pane.getStyleClass().add("menu-container");
root.setMouseTransparent(true);
RipplerContainer ripplerContainer = new RipplerContainer(pane);
FXUtils.onClicked(ripplerContainer, () -> {
GameItem item = getItem();
if (item != null) {
item.getProfile().setSelectedVersion(item.getId());
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
});
this.graphic = ripplerContainer;
ripplerContainer.maxWidthProperty().bind(listView.widthProperty().subtract(5));
}
@Override
protected void updateItem(GameItem item, boolean empty) {
super.updateItem(item, empty);
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());
}
}
}
}