diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java deleted file mode 100644 index efcf04596..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java +++ /dev/null @@ -1,80 +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.scene.control.Control; -import javafx.scene.control.Skin; -import org.jackhuang.hmcl.game.World; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; - -import java.nio.file.Path; - -public final class WorldListItem extends Control { - private final World world; - private final Path backupsDir; - private final WorldListPage parent; - private final Profile profile; - private final String id; - - public WorldListItem(WorldListPage parent, World world, Path backupsDir, Profile profile, String id) { - this.world = world; - this.backupsDir = backupsDir; - this.parent = parent; - this.profile = profile; - this.id = id; - } - - @Override - protected Skin createDefaultSkin() { - return new WorldListItemSkin(this); - } - - public World getWorld() { - return world; - } - - public void export() { - WorldManageUIUtils.export(world); - } - - public void delete() { - WorldManageUIUtils.delete(world, () -> parent.remove(this)); - } - - public void copy() { - WorldManageUIUtils.copyWorld(world, parent::refresh); - } - - public void reveal() { - FXUtils.openFolder(world.getFile()); - } - - public void showManagePage() { - Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id)); - } - - public void launch() { - Versions.launchAndEnterWorld(profile, id, world.getFileName()); - } - - public void generateLaunchScript() { - Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName()); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java deleted file mode 100644 index 45a3a3bdd..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java +++ /dev/null @@ -1,170 +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 javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.SkinBase; -import javafx.scene.image.ImageView; -import javafx.scene.input.MouseButton; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.game.World; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.ChunkBaseApp; -import org.jackhuang.hmcl.util.i18n.I18n; - -import java.time.Instant; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; -import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; -import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public final class WorldListItemSkin extends SkinBase { - - private final BorderPane root; - - public WorldListItemSkin(WorldListItem skinnable) { - super(skinnable); - - World world = skinnable.getWorld(); - - root = new BorderPane(); - root.getStyleClass().add("md-list-cell"); - root.setPadding(new Insets(8)); - - { - StackPane left = new StackPane(); - FXUtils.installSlowTooltip(left, world.getFile().toString()); - root.setLeft(left); - left.setPadding(new Insets(0, 8, 0, 0)); - - ImageView imageView = new ImageView(); - left.getChildren().add(imageView); - FXUtils.limitSize(imageView, 32, 32); - imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); - } - - { - TwoLineListItem item = new TwoLineListItem(); - root.setCenter(item); - item.setMouseTransparent(true); - if (world.getWorldName() != null) - item.setTitle(parseColorEscapes(world.getWorldName())); - item.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())))); - - if (world.getGameVersion() != null) - item.addTag(I18n.getDisplayVersion(world.getGameVersion())); - if (world.isLocked()) - item.addTag(i18n("world.locked")); - } - - { - HBox right = new HBox(8); - root.setRight(right); - right.setAlignment(Pos.CENTER_RIGHT); - - JFXButton btnMore = new JFXButton(); - right.getChildren().add(btnMore); - btnMore.getStyleClass().add("toggle-icon4"); - btnMore.setGraphic(SVG.MORE_VERT.createIcon()); - btnMore.setOnAction(event -> showPopupMenu(JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight())); - } - - RipplerContainer container = new RipplerContainer(root); - container.setOnMouseClicked(event -> { - if (event.getClickCount() != 1) - return; - - if (event.getButton() == MouseButton.PRIMARY) - skinnable.showManagePage(); - else if (event.getButton() == MouseButton.SECONDARY) - showPopupMenu(JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY()); - }); - - getChildren().setAll(container); - } - - // Popup Menu - - public void showPopupMenu(JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { - PopupMenu popupMenu = new PopupMenu(); - JFXPopup popup = new JFXPopup(popupMenu); - - WorldListItem item = getSkinnable(); - World world = item.getWorld(); - - if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { - - IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), item::launch, popup); - launchItem.setDisable(world.isLocked()); - popupMenu.getContent().add(launchItem); - - popupMenu.getContent().addAll( - new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup), - new MenuSeparator() - ); - } - - popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), item::showManagePage, popup)); - - if (ChunkBaseApp.isSupported(world)) { - popupMenu.getContent().addAll( - new MenuSeparator(), - new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup), - new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup), - new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup) - ); - - if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) { - popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), - () -> ChunkBaseApp.openEndCityFinder(world), popup)); - } - } - - IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), item::export, popup); - IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), item::delete, popup); - IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), item::copy, popup); - boolean worldLocked = world.isLocked(); - Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem) - .forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked)); - - popupMenu.getContent().addAll( - new MenuSeparator(), - exportMenuItem, - deleteMenuItem, - duplicateMenuItem - ); - - popupMenu.getContent().addAll( - new MenuSeparator(), - new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup) - ); - - JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup); - - popup.show(root, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index 31cf0faa9..3d3f74240 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -17,18 +17,32 @@ */ package org.jackhuang.hmcl.ui.versions; +import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXListView; +import com.jfoenix.controls.JFXPopup; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.Node; +import javafx.scene.control.ListCell; import javafx.scene.control.Skin; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; -import org.jackhuang.hmcl.ui.construct.Validator; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.util.ChunkBaseApp; +import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -36,15 +50,18 @@ import java.io.IOException; import java.nio.file.FileAlreadyExistsException; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; +import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; +import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class WorldListPage extends ListPageBase implements VersionPage.VersionLoadable { +public final class WorldListPage extends ListPageBase implements VersionPage.VersionLoadable { private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); private Path savesDir; @@ -52,19 +69,15 @@ public final class WorldListPage extends ListPageBase implements private List worlds; private Profile profile; private String id; - private GameVersionNumber gameVersion; + + private int refreshCount = 0; public WorldListPage() { FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { installWorld(modpacks.get(0)); }); - showAll.addListener(e -> { - if (worlds != null) - itemsProperty().setAll(worlds.stream() - .filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) - .map(world -> new WorldListItem(this, world, backupsDir, profile, id)).toList()); - }); + showAll.addListener(e -> updateWorldList()); } @Override @@ -81,33 +94,46 @@ public final class WorldListPage extends ListPageBase implements refresh(); } - public void remove(WorldListItem item) { - itemsProperty().remove(item); + private void updateWorldList() { + if (worlds == null) { + getItems().clear(); + } else if (showAll.get()) { + getItems().setAll(worlds); + } else { + GameVersionNumber gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null); + getItems().setAll(worlds.stream() + .filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) + .toList()); + } } public void refresh() { if (profile == null || id == null) return; + int currentRefresh = ++refreshCount; + setLoading(true); - Task.runAsync(() -> gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null)) - .thenApplyAsync(unused -> { - try (Stream stream = World.getWorlds(savesDir)) { - return stream.parallel().collect(Collectors.toList()); - } - }) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - worlds = result; - setLoading(false); - if (exception == null) { - itemsProperty().setAll(result.stream() - .filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) - .map(world -> new WorldListItem(this, world, backupsDir, profile, id)) - .collect(Collectors.toList())); - } else { - LOG.warning("Failed to load world list page", exception); - } - }).start(); + Task.supplyAsync(Schedulers.io(), () -> { + // Ensure the game version number is parsed + profile.getRepository().getGameVersion(id); + try (Stream stream = World.getWorlds(savesDir)) { + return stream.toList(); + } + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (refreshCount != currentRefresh) { + // A newer refresh task is running, discard this result + return; + } + + worlds = result; + updateWorldList(); + + if (exception != null) + LOG.warning("Failed to load world list page", exception); + + setLoading(false); + }).start(); } public void add() { @@ -133,8 +159,8 @@ public final class WorldListPage extends ListPageBase implements Controllers.prompt(i18n("world.name.enter"), (name, resolve, reject) -> { Task.runAsync(() -> world.install(savesDir, name)) .whenComplete(Schedulers.javafx(), () -> { - itemsProperty().add(new WorldListItem(this, new World(savesDir.resolve(name)), backupsDir, profile, id)); resolve.run(); + refresh(); }, e -> { if (e instanceof FileAlreadyExistsException) reject.accept(i18n("world.import.failed", i18n("world.import.already_exists"))); @@ -150,19 +176,39 @@ public final class WorldListPage extends ListPageBase implements }).start(); } - public boolean isShowAll() { - return showAll.get(); + private void showManagePage(World world) { + Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id)); + } + + public void export(World world) { + WorldManageUIUtils.export(world); + } + + public void delete(World world) { + WorldManageUIUtils.delete(world, this::refresh); + } + + public void copy(World world) { + WorldManageUIUtils.copyWorld(world, this::refresh); + } + + public void reveal(World world) { + FXUtils.openFolder(world.getFile()); + } + + public void launch(World world) { + Versions.launchAndEnterWorld(profile, id, world.getFileName()); + } + + public void generateLaunchScript(World world) { + Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName()); } public BooleanProperty showAllProperty() { return showAll; } - public void setShowAll(boolean showAll) { - this.showAll.set(showAll); - } - - private final class WorldListPageSkin extends ToolbarListPageSkin { + private final class WorldListPageSkin extends ToolbarListPageSkin { WorldListPageSkin() { super(WorldListPage.this); @@ -179,5 +225,162 @@ public final class WorldListPage extends ListPageBase implements createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add), createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download)); } + + @Override + protected ListCell createListCell(JFXListView listView) { + return new WorldListCell(getSkinnable()); + } + } + + private static final class WorldListCell extends ListCell { + + private final WorldListPage page; + + private final RipplerContainer graphic; + private final ImageView imageView; + private final Tooltip leftTooltip; + private final TwoLineListItem content; + + public WorldListCell(WorldListPage page) { + this.page = page; + + var root = new BorderPane(); + root.getStyleClass().add("md-list-cell"); + root.setPadding(new Insets(8)); + + { + StackPane left = new StackPane(); + this.leftTooltip = new Tooltip(); + FXUtils.installSlowTooltip(left, leftTooltip); + root.setLeft(left); + left.setPadding(new Insets(0, 8, 0, 0)); + + this.imageView = new ImageView(); + left.getChildren().add(imageView); + FXUtils.limitSize(imageView, 32, 32); + } + + { + this.content = new TwoLineListItem(); + root.setCenter(content); + content.setMouseTransparent(true); + } + + { + HBox right = new HBox(8); + root.setRight(right); + right.setAlignment(Pos.CENTER_RIGHT); + + JFXButton btnMore = new JFXButton(); + right.getChildren().add(btnMore); + btnMore.getStyleClass().add("toggle-icon4"); + btnMore.setGraphic(SVG.MORE_VERT.createIcon()); + btnMore.setOnAction(event -> { + World world = getItem(); + if (world != null) + showPopupMenu(world, JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight()); + }); + } + + this.graphic = new RipplerContainer(root); + graphic.setOnMouseClicked(event -> { + if (event.getClickCount() != 1) + return; + + World world = getItem(); + if (world == null) + return; + + if (event.getButton() == MouseButton.PRIMARY) + page.showManagePage(world); + else if (event.getButton() == MouseButton.SECONDARY) + showPopupMenu(world, JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY()); + }); + } + + @Override + protected void updateItem(World world, boolean empty) { + super.updateItem(world, empty); + + this.content.getTags().clear(); + + if (empty || world == null) { + setGraphic(null); + imageView.setImage(null); + leftTooltip.setText(""); + content.setTitle(""); + content.setSubtitle(""); + } else { + imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); + leftTooltip.setText(world.getFile().toString()); + content.setTitle(world.getWorldName() != null ? parseColorEscapes(world.getWorldName()) : ""); + + if (world.getGameVersion() != null) + content.addTag(I18n.getDisplayVersion(world.getGameVersion())); + if (world.isLocked()) + content.addTag(i18n("world.locked")); + + content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())))); + + setGraphic(graphic); + } + } + + // Popup Menu + + public void showPopupMenu(World world, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { + PopupMenu popupMenu = new PopupMenu(); + JFXPopup popup = new JFXPopup(popupMenu); + + if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { + + IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), () -> page.launch(world), popup); + launchItem.setDisable(world.isLocked()); + popupMenu.getContent().add(launchItem); + + popupMenu.getContent().addAll( + new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> page.generateLaunchScript(world), popup), + new MenuSeparator() + ); + } + + popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), () -> page.showManagePage(world), popup)); + + if (ChunkBaseApp.isSupported(world)) { + popupMenu.getContent().addAll( + new MenuSeparator(), + new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup), + new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup), + new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup) + ); + + if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) { + popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), + () -> ChunkBaseApp.openEndCityFinder(world), popup)); + } + } + + IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup); + IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> page.delete(world), popup); + IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup); + boolean worldLocked = world.isLocked(); + Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem) + .forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked)); + + popupMenu.getContent().addAll( + new MenuSeparator(), + exportMenuItem, + deleteMenuItem, + duplicateMenuItem + ); + + popupMenu.getContent().addAll( + new MenuSeparator(), + new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup) + ); + + JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup); + popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY); + } } }