diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin2.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin2.java new file mode 100644 index 0000000000..c0cd486a5e --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin2.java @@ -0,0 +1,75 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui 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 . + */ +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> extends SkinBase

{ + + protected final JFXListView 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 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 initializeToolbar(P skinnable); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java index 4a7032d38b..c045160743 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java @@ -55,4 +55,8 @@ public void add(Object obj) { public boolean remove(Object obj) { return refs.remove(obj); } + + public void clear() { + refs.clear(); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 00aedc61b0..10ebffdc0a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -22,14 +22,12 @@ 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.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.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 = 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 latestVersion = new SimpleObjectProperty<>(this, "latestVersion"); private final ObservableList versions = FXCollections.observableArrayList(); - private final ObservableList versionNodes; private Profile profile; private TransitionPane announcementPane; @@ -273,19 +264,6 @@ public void accept(String currentGame) { 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 @@ private void launchNoGame() { } 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, diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java index c34d1ce020..b9758888c1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui 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 = new SimpleObjectProperty<>(); + private boolean initialized = false; + private StringProperty title; + private StringProperty tag; + private StringProperty subtitle; + private ObjectProperty 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; + } + + 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 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 StringProperty titleProperty() { + 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 imageProperty() { + public ReadOnlyObjectProperty imageProperty() { + init(); return image; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java deleted file mode 100644 index b7b79f6213..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui 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 . - */ -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 { - 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); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java new file mode 100644 index 0000000000..28fa36979d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java @@ -0,0 +1,212 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +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 { + + 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java index bd67514519..8151215a5a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java @@ -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())); - } - - @Override - protected Skin createDefaultSkin() { - return new GameListItemSkin(this); + selected.bind(profile.selectedVersionProperty().isEqualTo(id)); } - 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 boolean canUpdate() { } public void update() { - Versions.updateVersion(profile, version); + Versions.updateVersion(profile, id); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java deleted file mode 100644 index 70b6e8c01b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui 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 . - */ -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 { - private static GameListItem currentSkinnable; - private static final Lazy 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()); - } - }); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java index 402fb64095..1d94e24a36 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java @@ -23,10 +23,8 @@ 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.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 profileListItems; private final ObjectProperty selectedProfile; - private ToggleGroup toggleGroup; - public GameListPage() { profileListItems = MappedObservableList.create(profilesProperty(), profile -> { ProfileListItem item = new ProfileListItem(profile); @@ -123,50 +118,31 @@ public ReadOnlyObjectProperty stateProperty() { return state.getReadOnlyProperty(); } - private class GameList extends ListPageBase { - public GameList() { - super(); + private static class GameList extends ListPageBase { + 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 children = repository.getDisplayVersions() - .map(version -> new GameListItem(toggleGroup, profile, version.getId())) - .collect(Collectors.toList()); - itemsProperty().setAll(children); - children.forEach(GameListItem::checkSelection); - - 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()); - }); - }); + if (profile != Profiles.getSelectedProfile()) + return; + + ObservableList 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 @@ protected GameListSkin createDefaultSkin() { return new GameListSkin(); } - private class GameListSkin extends ToolbarListPageSkin { + private class GameListSkin extends ToolbarListPageSkin2 { public GameListSkin() { super(GameList.this); + this.listView.setCellFactory(listView -> new GameListCell()); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java new file mode 100644 index 0000000000..8210d86992 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java @@ -0,0 +1,144 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +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 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 getItems() { + return listView.getItems(); + } + + private static final class Cell extends ListCell { + + private final Region graphic; + + private final ImageView imageView; + private final TwoLineListItem content; + + private final StringProperty tag = new SimpleStringProperty(); + + public Cell(ListView 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()); + } + } + } +}