refactor: remove mod, resource pack, world, data pack and installer pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
layue13andClaude Sonnet 5.5 committed 2026-11-08 05:27:38 +08:00
1 parent 3f67b6bda0
commit a2fa7ed5ab
19 files changed
+1 -6236

No files matched your search

@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Helpers for downloading a remote addon file into an instance subdirectory.
///
/// The former free-install / CurseForge-Modrinth browse hub was removed for the
/// server-client launcher; only install-into-folder callbacks remain for update flows.
public final class DownloadPage {
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
private DownloadPage() {
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
@Nullable HMCLGameInstance instance = instanceId != null
? repository.findInstance(instanceId)
: repository.getSelectedInstance();
Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory();
var targetPath = runDirectory.resolve(subdirectoryName);
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(targetPath, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + targetPath, e);
existingPaths = new FileNameSet(false);
}
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
Path dest = targetPath.resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
task.setName(file.name());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
if (!(exception instanceof CancellationException)) {
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
}
} else {
Controllers.showToast(i18n("install.success"));
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
handler.resolve();
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
}
}
@@ -1,515 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.addon.update.AddonUpdate;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.addon.update.AddonUpdateTask;
import org.jackhuang.hmcl.addon.update.AddonUpdates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CSVTable;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import org.jackhuang.hmcl.util.javafx.Subscription;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update")));
private final DownloadProvider downloadProvider;
private final Path localAddonDirectory;
private final ObservableList<AddonUpdateObject> objects;
private final JFXListView<AddonUpdateObject> listView;
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
this.downloadProvider = downloadProvider;
this.localAddonDirectory = localAddonDirectory;
getStyleClass().add("gray-background");
var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList());
var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList());
objects = FXCollections.observableArrayList();
{
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("addon.check_update.only_preview"));
placeholderContainer.getChildren().add(placeholderLabel);
listView = new JFXListView<>();
listView.setItems(objects);
listView.getStyleClass().add("jfx-list-view-float");
listView.getStyleClass().add("no-horizontal-scrollbar");
listView.setPlaceholder(placeholderContainer);
setMargin(listView, new Insets(0, 10, 0, 10));
VBox.setVgrow(listView, Priority.ALWAYS);
listView.setCellFactory(AddonUpdateListCell::new);
ComponentList.setVgrow(listView, Priority.ALWAYS);
centrePane.getContent().setAll(listView);
centerWrapper.getChildren().setAll(centrePane);
setCenter(centerWrapper);
}
{
HBox actions = new HBox(8);
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXCheckBox allEnabledBox = new JFXCheckBox();
allEnabledBox.setText(i18n("button.select_all"));
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
{
List<Subscription> subscriptions = new ArrayList<>();
FXUtils.onChangeAndOperate(showPreviewCheck.selectedProperty(), preview -> {
if (preview) objects.setAll(common.get());
else objects.setAll(release.get());
subscriptions.forEach(Subscription::unsubscribe);
subscriptions.clear();
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
});
}
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
exportListButton.setOnAction(e -> exportList());
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
nextButton.setOnAction(e -> updateFiles());
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
onEscPressed(listView, cancelButton::fire);
actions.getChildren().setAll(allEnabledBox, showPreviewCheck, spacer, exportListButton, nextButton, cancelButton);
setBottom(actions);
}
}
private void updateFiles() {
AddonUpdateTask task = new AddonUpdateTask(
downloadProvider,
localAddonDirectory,
objects.stream()
.filter(AddonUpdateObject::isEnabled)
.map(AddonUpdateObject::getData)
.toList()
);
Controllers.taskDialog(
task.whenComplete(Schedulers.javafx(), exception -> {
fireEvent(new PageCloseEvent());
if (!task.getFailedAddons().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.failed_download") + "\n" +
task.getFailedAddons().stream().map(LocalAddonFile::getFileName).collect(Collectors.joining("\n")),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
if (exception == null) {
Controllers.dialog(i18n("install.success"));
}
}),
i18n("addon.check_update"),
TaskCancellationAction.NORMAL);
}
private void exportList() {
Path path = Paths.get("hmcl-mod-update-list-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".csv").toAbsolutePath();
Controllers.taskDialog(Task.runAsync(() -> {
CSVTable csvTable = new CSVTable();
csvTable.set(0, 0, "Source File Name");
csvTable.set(1, 0, "Current Version");
csvTable.set(2, 0, "Target Version");
csvTable.set(3, 0, "Update Source");
for (int i = 0; i < objects.size(); i++) {
csvTable.set(0, i + 1, objects.get(i).getFileName());
csvTable.set(1, i + 1, objects.get(i).getCurrentVersion());
csvTable.set(2, i + 1, objects.get(i).getTargetVersion());
csvTable.set(3, i + 1, objects.get(i).getSource());
}
csvTable.write(path);
FXUtils.showFileInExplorer(path);
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception == null) {
Controllers.dialog(path.toString(), i18n("message.success"));
} else {
Controllers.dialog("", i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}), i18n("button.export"), TaskCancellationAction.NORMAL);
}
@Override
public ReadOnlyObjectWrapper<State> stateProperty() {
return state;
}
private static final class AddonUpdateObject {
final AddonUpdate data;
final String targetVersionType;
final String source;
String changelog = null;
final BooleanProperty enabled = new SimpleBooleanProperty();
final ItemPropertyAsyncCache<Image, AddonUpdateObject> iconCache;
public AddonUpdateObject(AddonUpdate data) {
this.data = data;
this.targetVersionType = switch (data.targetVersion().versionType()) {
case Release -> i18n("addon.channel.release");
case Beta -> i18n("addon.channel.beta");
case Alpha -> i18n("addon.channel.alpha");
};
this.source = switch (data.currentVersion().source()) {
case CURSEFORGE -> i18n("addon.curseforge");
case MODRINTH -> i18n("addon.modrinth");
};
this.enabled.set(!data.localAddonFile().isDisabled());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public AddonUpdate getData() {
return data;
}
public boolean isEnabled() {
return enabled.get();
}
public BooleanProperty enabledProperty() {
return enabled;
}
public LocalAddonFile<?> getLocalAddonFile() {
return data.localAddonFile();
}
public String getFileName() {
return data.localAddonFile().getFileName();
}
public String getCurrentVersion() {
return data.currentVersion().version();
}
public String getTargetVersion() {
return data.targetVersion().version();
}
public String getTargetVersionType() {
return targetVersionType;
}
public String getSource() {
return source;
}
private Image getDefaultIcon() {
if (data.localAddonFile() instanceof LocalModFile localModFile) {
return GameInstanceIconType
.getIconType(localModFile.getModLoaderType())
.getIcon();
}
return null;
}
private Image loadIcon() {
if (data.localAddonFile() instanceof LocalModFile localModFile) {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(localModFile.getLogoPath())) {
iconPaths.add(localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(
iconPath,
80,
80,
true,
true
);
if (!image.isError()
&& image.getWidth() > 0
&& image.getHeight() > 0
&& Math.abs(image.getWidth() - image.getHeight()) < 1
) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load addon icons", e);
}
}
if (data.localAddonFile() instanceof ResourcePackFile resourcePackFile) {
Image image = resourcePackFile.loadIcon();
if (image != null
&& !image.isError()
&& image.getWidth() > 0
&& image.getHeight() > 0
&& Math.abs(image.getWidth() - image.getHeight()) < 1
) {
return image;
}
}
return getDefaultIcon();
}
}
private static class AddonUpdateListCell extends ListCell<AddonUpdateObject> {
private final StackPane pane = new StackPane();
private final RipplerContainer ripplerContainer;
private final JFXCheckBox enabledBox = new JFXCheckBox();
private final ImageContainer imageContainer = new ImageContainer(40);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton changelogButton = FXUtils.newToggleButton4(SVG.INFO);
AddonUpdateListCell(ListView<AddonUpdatesPage.AddonUpdateObject> listView) {
HBox hbox = new HBox(16);
HBox.setHgrow(content, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
hbox.getChildren().setAll(enabledBox, imageContainer, content, changelogButton);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
ripplerContainer = new RipplerContainer(hbox);
pane.getChildren().setAll(ripplerContainer);
enabledBox.setMouseTransparent(false);
imageContainer.setMouseTransparent(true);
FXUtils.installFastTooltip(changelogButton, i18n("addon.changelog"));
changelogButton.setOnAction(event -> {
AddonUpdateObject item = getItem();
if (item != null) {
Controllers.dialog(new AddonChangelog(item));
}
});
FXUtils.onClicked(this, () -> {
AddonUpdateObject item = getItem();
if (item != null) {
item.enabled.set(!item.isEnabled());
}
});
FXUtils.limitCellWidth(listView, this);
}
/// Updates addon version information and replaces tags together, reusing their labels.
@Override
public void updateItem(@Nullable AddonUpdateObject item, boolean empty) {
@Nullable AddonUpdateObject oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) {
return;
}
ripplerContainer.resetFeedback();
if (oldItem != null) {
enabledBox.selectedProperty().unbindBidirectional(oldItem.enabledProperty());
}
if (empty || item == null) {
setGraphic(null);
return;
}
enabledBox.selectedProperty().bindBidirectional(item.enabledProperty());
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
LocalAddonFile<?> addonFile = item.getLocalAddonFile();
var tags = new ArrayList<Tag>(3);
if (addonFile instanceof LocalModFile modFile)
tags.add(new Tag(modFile.getId()));
tags.add(new Tag(item.getSource()));
tags.add(new Tag(item.getTargetVersionType()));
content.getTags().setAll(tags);
if (addonFile instanceof LocalModFile modFile) {
content.setTitle(modFile.getName());
} else if (addonFile instanceof ResourcePackFile resourcePackFile) {
content.setTitle(resourcePackFile.getFileName());
}
content.setSubtitle(item.getCurrentVersion() + " → " + item.getTargetVersion());
setGraphic(pane);
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(AddonUpdateObject object) {
RemoteAddon.Version targetVersion = object.data.targetVersion();
this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(object, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(object, versionPageBtn);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(versionPageBtn, closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.targetVersion().source().getRepository();
Task.supplyAsync(() -> {
if (object.changelog != null) return object.changelog;
RemoteAddon.Version version = object.data.targetVersion();
return StringUtils.convertToHtml(
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
"238222".equals(object.data.targetVersion().projectId())
);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() ->
object.data.targetVersion().source().getRepository().getVersionPageUrl(object.data.targetVersion())
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
}).start();
}
}
}
@@ -1,121 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.BooleanProperty;
import javafx.collections.ObservableList;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.DataPackInfoObject> implements WorldManagePage.WorldRefreshable {
private final World world;
private final DataPack dataPack;
final BooleanProperty readOnly;
public DataPackListPage(WorldManagePage worldManagePage) {
world = worldManagePage.getWorld();
dataPack = new DataPack(world.getFile().resolve("datapacks"));
setItems(MappedObservableList.create(dataPack.getPacks(), DataPackListPageSkin.DataPackInfoObject::new));
readOnly = worldManagePage.readOnlyProperty();
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
this::installMultiDataPack, this::refresh);
refresh();
}
private void installMultiDataPack(List<Path> dataPackPath) {
dataPackPath.forEach(this::installSingleDataPack);
if (readOnly.get()) {
Controllers.showToast(i18n("datapack.reload.toast"));
}
}
private void installSingleDataPack(Path dataPack) {
try {
this.dataPack.installPack(dataPack, world.getGameVersion());
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse datapack file " + dataPack, e);
}
}
@Override
protected Skin<?> createDefaultSkin() {
return new DataPackListPageSkin(this);
}
public void refresh() {
setLoading(true);
Task.runAsync(dataPack::loadFromDir)
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
.start();
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("datapack.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.datapack"), "*.zip"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res != null) {
installMultiDataPack(res);
}
dataPack.loadFromDir();
}
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
dataPack.deletePack(pack);
} catch (IOException e) {
failed.add(pack.getPath().toString());
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("datapack.delete.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void setSelectedEnabled(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems, boolean enabled) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
pack.setActive(enabled);
} catch (IOException e) {
failed.add(pack.getPath().toString());
LOG.warning("Failed to enable or disable datapack \"" + pack.getId() + "\"", e);
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("datapack.toggle.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void openDataPackFolder() {
FXUtils.openFolder(dataPack.getPath());
}
}
@@ -1,370 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSwitch;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ListChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
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.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Predicate;
import java.util.regex.PatternSyntaxException;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox normalToolbar;
private final HBox selectingToolbar;
private final JFXListView<DataPackInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
DataPackListPageSkin(DataPackListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAllButton = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> selectAllButton.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
normalToolbar = new HBox();
selectingToolbar = new HBox();
normalToolbar.getChildren().addAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openDataPackFolder),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () ->
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () ->
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()), null));
JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true));
JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false));
removeButton.disableProperty().bind(getSkinnable().readOnly);
enableButton.disableProperty().bind(getSkinnable().readOnly);
disableButton.disableProperty().bind(getSkinnable().readOnly);
listView.getSelectionModel().getSelectedItems().addListener(listener);
selectingToolbar.getChildren().addAll(
removeButton,
enableButton,
disableButton,
selectAllButton,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(normalToolbar);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
});
FXUtils.onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().addAll(searchField, closeSearchBar);
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : normalToolbar);
else
changeToolbar(selectingToolbar);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("datapack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("datapack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super DataPackInfoObject>) change -> {
if (isSearching.get()) {
search();
}
});
listView.getItems().addListener(listener);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
/// Filters pack IDs and descriptions using the shared query syntax; invalid regular expressions leave the list empty.
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (PatternSyntaxException e) {
LOG.warning("Illegal regular expression", e);
return;
}
for (DataPackInfoObject item : getSkinnable().getItems()) {
if (predicate.test(item.getTitle()) || predicate.test(item.getSubtitle())) {
listView.getItems().add(item);
}
}
}
}
private void changeToolbar(HBox newToolbar) {
@Nullable Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
// search button click will get focus while searchField request focus, this cause conflict.
// Defer focus request to next pulse avoids this conflict.
Platform.runLater(searchField::requestFocus);
}
}
}
static class DataPackInfoObject {
private final DataPack.Pack packInfo;
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
DataPackInfoObject(DataPack.Pack packInfo) {
this.packInfo = packInfo;
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
String getTitle() {
return packInfo.getId();
}
String getSubtitle() {
return packInfo.getDescription().toString();
}
DataPack.Pack getPackInfo() {
return packInfo;
}
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image image = null;
Path imagePath;
if (this.getPackInfo().isDirectory()) {
imagePath = getPackInfo().getPath().resolve("pack.png");
try {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
}
} else {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) {
imagePath = fs.getPath("/pack.png");
if (Files.exists(imagePath)) {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
}
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
}
}
if (image != null && !image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
} else {
return getDefaultIcon();
}
}
}
private static final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
private final JFXSwitch activeSwitch = new JFXSwitch();
ImageContainer imageContainer = new ImageContainer(32);
final TwoLineListItem content = new TwoLineListItem();
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getPackInfo().isActive());
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
/// Creates a selectable data pack row whose switch follows the read-only state.
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
super(listView);
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
container.setPadding(new Insets(0, 8, 0, 8));
HBox.setHgrow(content, Priority.ALWAYS);
setSelectable();
activeSwitch.disableProperty().bind(isReadOnlyProperty);
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
StackPane.setMargin(container, new Insets(8));
container.getChildren().setAll(imageContainer, content, activeSwitch);
getContainer().getChildren().setAll(container);
}
@Override
protected void updateControl(@Nullable DataPackInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
if (empty || dataItem == null) return;
content.setTitle(dataItem.getTitle());
content.setSubtitle(dataItem.getSubtitle());
DataPack.Pack packInfo = dataItem.getPackInfo();
(observedActiveProperty = packInfo.activeProperty()).addListener(weakActiveListener);
activeSwitch.setDisableAnimation(true);
activeSwitch.setSelected(packInfo.isActive());
activeSwitch.setOnAction(event -> {
try {
packInfo.setActive(activeSwitch.isSelected());
} catch (IOException e) {
LOG.warning("Unable to change state of datapack " + packInfo.getPath(), e);
Controllers.showToast(i18n("datapack.toggle.failed"));
} finally {
activeSwitch.setSelected(packInfo.isActive());
}
});
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
}
}
}
@@ -1,637 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
public class DownloadListPage extends Control implements DecoratorPage {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final boolean instanceSelection;
private final ObjectProperty<HMCLGameInstance.Optional> instanceReference = new SimpleObjectProperty<>();
private final IntegerProperty pageOffset = new SimpleIntegerProperty(0);
private final IntegerProperty pageCount = new SimpleIntegerProperty(-1);
private final ListProperty<RemoteAddon> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
private final ObservableList<GameInstanceID> instances = FXCollections.observableArrayList();
private final ObjectProperty<GameInstanceID> selectedInstance = new SimpleObjectProperty<>();
private final DownloadPage.DownloadCallback callback;
private boolean searchInitialized = false;
protected final BooleanProperty supportChinese = new SimpleBooleanProperty();
private final ObservableList<Node> actions = FXCollections.observableArrayList();
protected final ListProperty<String> downloadSources = new SimpleListProperty<>(this, "downloadSources", FXCollections.observableArrayList());
protected final StringProperty downloadSource = new SimpleStringProperty();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private int searchID = 0;
protected final RemoteAddonRepository repository;
protected final @NotNull RemoteAddon.Type type;
private final DownloadProvider downloadProvider;
private Runnable retrySearch;
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
this(type, repository, null, false);
}
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
this.type = Objects.requireNonNull(type);
this.repository = repository;
this.callback = callback;
this.instanceSelection = instanceSelection;
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
public DownloadProvider getDownloadProvider() {
return downloadProvider;
}
public ObservableList<Node> getActions() {
return actions;
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.instanceReference.set(instance);
setLoading(false);
setFailed(false);
if (!searchInitialized) {
searchInitialized = true;
search("", null, 0, "", RemoteAddonRepository.SortType.RELEVANCY);
}
if (instanceSelection) {
HMCLGameRepository repository = instance.repository();
instances.setAll(repository.getDisplayInstances()
.map(DefaultGameInstance::getId)
.toList());
@Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance();
selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null);
}
}
public boolean isFailed() {
return failed.get();
}
public BooleanProperty failedProperty() {
return failed;
}
public void setFailed(boolean failed) {
this.failed.set(failed);
}
public boolean isLoading() {
return loading.get();
}
public BooleanProperty loadingProperty() {
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
}
public void selectInstance(GameInstanceID instanceId) {
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
public DownloadPage.DownloadCallback getCallback() {
return callback;
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
retrySearch = null;
setLoading(true);
setFailed(false);
int currentSearchID = searchID = searchID + 1;
Task.supplyAsync(() -> {
HMCLGameInstance.Optional instanceReference = this.instanceReference.get();
@Nullable HMCLGameInstance instance = instanceReference.instance();
if (instance == null) {
return userGameVersion;
} else {
GameVersionNumber version = instance.getVersion();
return version != GameVersionNumber.unknown() ? version.toString() : "";
}
}).thenApplyAsync(
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (searchID != currentSearchID) {
return;
}
setLoading(false);
if (exception == null) {
items.setAll(result.results().collect(Collectors.toList()));
pageCount.set(result.totalPages());
failed.set(false);
} else {
failed.set(true);
pageCount.set(-1);
retrySearch = () -> search(userGameVersion, category, pageOffset, searchFilter, sort);
}
}).executor(true);
}
protected String getLocalizedCategory(String category, Object self) {
return repository instanceof ModrinthRemoteAddonRepository
? i18n("modrinth.category." + category)
: i18n("curse.category." + category);
}
protected boolean shouldDisplayCategory(String category) {
return !"minecraft".equals(category);
}
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
return StringUtils.repeats(' ', category.indent * 4) +
(category.category() == null
? i18n("curse.category.0")
: getLocalizedCategory(category.category().id(), category.category().self()));
}
protected String getLocalizedOfficialPage() {
if (repository instanceof ModrinthRemoteAddonRepository) {
return i18n("addon.modrinth");
} else {
return i18n("addon.curseforge");
}
}
protected HMCLGameInstance.Optional getInstanceOptional() {
if (instanceSelection) {
@Nullable GameInstanceID instanceId = selectedInstance.get();
return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId);
} else {
return instanceReference.get();
}
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModDownloadListPageSkin(this);
}
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteAddon> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader;
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
listView.getStyleClass().add("no-horizontal-scrollbar");
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
}
};
BorderPane pane = new BorderPane();
GridPane searchPane = new GridPane();
pane.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setHgrow(Priority.ALWAYS);
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
if (control.instanceSelection || !control.downloadSources.isEmpty()) {
searchPane.addRow(rowIndex);
int columns = 0;
Node lastNode = null;
if (control.instanceSelection) {
JFXComboBox<GameInstanceID> instancesComboBox = new JFXComboBox<>();
instancesComboBox.setMaxWidth(Double.MAX_VALUE);
Bindings.bindContent(instancesComboBox.getItems(), control.instances);
selectedItemPropertyFor(instancesComboBox).bindBidirectional(control.selectedInstance);
searchPane.add(new Label(i18n("instance")), columns++, rowIndex);
searchPane.add(lastNode = instancesComboBox, columns++, rowIndex);
}
if (control.downloadSources.getSize() > 1) {
JFXComboBox<String> downloadSourceComboBox = new JFXComboBox<>();
downloadSourceComboBox.setMaxWidth(Double.MAX_VALUE);
downloadSourceComboBox.getItems().setAll(control.downloadSources.get());
downloadSourceComboBox.setConverter(stringConverter(I18n::i18n));
selectedItemPropertyFor(downloadSourceComboBox).bindBidirectional(control.downloadSource);
searchPane.add(new Label(i18n("settings.launcher.download_source")), columns++, rowIndex);
searchPane.add(lastNode = downloadSourceComboBox, columns++, rowIndex);
}
if (columns == 2) {
GridPane.setColumnSpan(lastNode, 3);
}
rowIndex++;
}
JFXTextField nameField = new JFXTextField();
nameField.setPromptText(getSkinnable().supportChinese.get() ? i18n("search.hint.chinese") : i18n("search.hint.english"));
if (getSkinnable().supportChinese.get()) {
FXUtils.installFastTooltip(nameField, i18n("search.hint.chinese"));
} else {
FXUtils.installFastTooltip(nameField, i18n("search.hint.english"));
}
JFXComboBox<String> gameVersionField = new JFXComboBox<>();
gameVersionField.setMaxWidth(Double.MAX_VALUE);
gameVersionField.setEditable(true);
gameVersionField.getItems().setAll(GameVersionNumber.getDefaultGameVersions());
Label lblGameVersion = new Label(i18n("world.game_version"));
searchPane.addRow(rowIndex++, new Label(i18n("mods.name")), nameField, lblGameVersion, gameVersionField);
ObjectBinding<Boolean> hasVersion = BindingMapping.of(getSkinnable().instanceReference)
.map(instanceReference -> instanceReference.instanceId() == null);
lblGameVersion.managedProperty().bind(hasVersion);
lblGameVersion.visibleProperty().bind(hasVersion);
gameVersionField.managedProperty().bind(hasVersion);
gameVersionField.visibleProperty().bind(hasVersion);
FXUtils.installFastTooltip(gameVersionField, i18n("search.enter"));
FXUtils.onChangeAndOperate(getSkinnable().instanceReference, instanceReference -> {
if (instanceReference.instanceId() != null) {
GridPane.setColumnSpan(nameField, 3);
} else {
GridPane.setColumnSpan(nameField, 1);
}
});
StackPane categoryStackPane = new StackPane();
JFXComboBox<CategoryIndented> categoryComboBox = new JFXComboBox<>();
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryStackPane.getChildren().setAll(categoryComboBox);
categoryComboBox.prefWidthProperty().bind(categoryStackPane.widthProperty());
categoryComboBox.getStyleClass().add("fit-width");
categoryComboBox.setPromptText(i18n("addon.category"));
categoryComboBox.getSelectionModel().select(0);
categoryComboBox.setConverter(stringConverter(getSkinnable()::getLocalizedCategoryIndent));
FXUtils.onChangeAndOperate(getSkinnable().downloadSource, downloadSource -> {
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryComboBox.getSelectionModel().select(0);
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
.thenAcceptAsync(Schedulers.javafx(), categories -> {
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
return;
}
List<CategoryIndented> result = new ArrayList<>();
result.add(CategoryIndented.ALL);
for (RemoteAddonRepository.Category category : Lang.toIterable(categories)) {
resolveCategory(category, 0, result);
}
categoryComboBox.getItems().setAll(result);
categoryComboBox.getSelectionModel().select(0);
}).start();
});
StackPane sortStackPane = new StackPane();
JFXComboBox<RemoteAddonRepository.SortType> sortComboBox = new JFXComboBox<>();
sortStackPane.getChildren().setAll(sortComboBox);
sortComboBox.prefWidthProperty().bind(sortStackPane.widthProperty());
sortComboBox.getStyleClass().add("fit-width");
sortComboBox.setConverter(stringConverter(sortType -> i18n("addon.sort." + sortType.name().toLowerCase(Locale.ROOT))));
sortComboBox.getItems().setAll(RemoteAddonRepository.SortType.values());
sortComboBox.getSelectionModel().select(0);
searchPane.addRow(rowIndex++, new Label(i18n("addon.category")), categoryStackPane, new Label(i18n("search.sort")), sortStackPane);
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
EventHandler<ActionEvent> searchAction = e -> {
iconLoader.clearInvalidCache();
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
control.pageOffset.set(0);
}
currentFilterID.set(filterID.get());
int pageOffset = control.pageOffset.get();
getSkinnable().search(gameVersionField.getSelectionModel().getSelectedItem(),
Optional.ofNullable(categoryComboBox.getSelectionModel().getSelectedItem())
.map(CategoryIndented::category)
.orElse(null),
pageOffset == -1 ? 0 : pageOffset,
nameField.getText(),
sortComboBox.getSelectionModel().getSelectedItem());
};
control.listenerHolder.add(FXUtils.observeWeak(
() -> filterID.set(filterID.get() + 1),
control.downloadSource,
gameVersionField.getSelectionModel().selectedItemProperty(),
categoryComboBox.getSelectionModel().selectedItemProperty(),
nameField.textProperty(),
sortComboBox.getSelectionModel().selectedItemProperty()
));
HBox actionsBox = new HBox(8);
GridPane.setColumnSpan(actionsBox, 4);
actionsBox.setAlignment(Pos.CENTER);
{
AggregatedObservableList<Node> actions = new AggregatedObservableList<>();
Holder<Runnable> changeButton = new Holder<>();
JFXButton firstPageButton = FXUtils.newBorderButton(i18n("search.first_page"));
firstPageButton.setOnAction(event -> {
control.pageOffset.set(0);
searchAction.handle(event);
changeButton.value.run();
});
JFXButton previousPageButton = FXUtils.newBorderButton(i18n("search.previous_page"));
previousPageButton.setOnAction(event -> {
int pageOffset = control.pageOffset.get();
if (pageOffset > 0) {
control.pageOffset.set(pageOffset - 1);
searchAction.handle(event);
changeButton.value.run();
}
});
Label pageDescription = new Label();
pageDescription.textProperty().bind(Bindings.createStringBinding(() -> {
int pageCount = control.pageCount.get();
return i18n("search.page_n", control.pageOffset.get() + 1, pageCount == -1 ? "-" : String.valueOf(pageCount));
}, control.pageOffset, control.pageCount));
JFXButton nextPageButton = FXUtils.newBorderButton(i18n("search.next_page"));
nextPageButton.setOnAction(event -> {
int nv = control.pageOffset.get() + 1;
if (nv < control.pageCount.get()) {
control.pageOffset.set(nv);
searchAction.handle(event);
changeButton.value.run();
}
});
JFXButton lastPageButton = FXUtils.newBorderButton(i18n("search.last_page"));
lastPageButton.setOnAction(event -> {
control.pageOffset.set(control.pageCount.get() - 1);
searchAction.handle(event);
changeButton.value.run();
});
firstPageButton.setDisable(true);
previousPageButton.setDisable(true);
lastPageButton.setDisable(true);
nextPageButton.setDisable(true);
changeButton.value = () -> {
int pageOffset = control.pageOffset.get();
int pageCount = control.pageCount.get();
boolean disableAll = pageCount >= -1 && pageCount <= 1;
boolean disablePrevious = disableAll || pageOffset == 0;
firstPageButton.setDisable(disablePrevious);
previousPageButton.setDisable(disablePrevious);
boolean disableNext = disableAll || pageOffset == pageCount - 1;
nextPageButton.setDisable(disableNext);
lastPageButton.setDisable(disableNext);
listView.scrollTo(0);
};
FXUtils.onChange(control.pageCount, pageCountN -> {
int pageCount = pageCountN.intValue();
if (pageCount != -1) {
if (control.pageOffset.get() + 1 >= pageCount) {
control.pageOffset.set(pageCount - 1);
}
}
changeButton.value.run();
});
FXUtils.onChange(control.pageOffset, pageOffsetN -> {
changeButton.value.run();
});
Pane placeholder = new Pane();
HBox.setHgrow(placeholder, Priority.SOMETIMES);
JFXButton searchButton = FXUtils.newRaisedButton(i18n("search"));
searchButton.setOnAction(searchAction);
actions.appendList(FXCollections.observableArrayList(firstPageButton, previousPageButton, pageDescription, nextPageButton, lastPageButton, placeholder, searchButton));
actions.appendList(control.actions);
Bindings.bindContent(actionsBox.getChildren(), actions.getAggregatedList());
}
searchPane.addRow(rowIndex++, actionsBox);
FXUtils.onChange(control.downloadSource, v -> searchAction.handle(null));
nameField.setOnAction(searchAction);
gameVersionField.setOnAction(searchAction);
categoryComboBox.setOnAction(searchAction);
sortComboBox.setOnAction(searchAction);
}
SpinnerPane spinnerPane = new SpinnerPane();
pane.setCenter(spinnerPane);
{
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
spinnerPane.failedReasonProperty().bind(
Bindings.createStringBinding(() -> {
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else if (!getSkinnable().isLoading() && getSkinnable().pageCount.get() >= 0 && getSkinnable().items.isEmpty()) {
return i18n("search.no_results_found");
} else {
return null;
}
},
getSkinnable().failedProperty(),
getSkinnable().loadingProperty(),
getSkinnable().pageCount,
getSkinnable().items)
);
spinnerPane.setOnFailedAction(e -> {
if (getSkinnable().isFailed() && getSkinnable().retrySearch != null) {
getSkinnable().retrySearch.run();
}
});
spinnerPane.setContent(listView);
Bindings.bindContent(listView.getItems(), getSkinnable().items);
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.setCellFactory(x -> new DownloadListCell(getSkinnable(), listView, iconLoader));
}
this.getChildren().setAll(pane);
}
private record CategoryIndented(int indent, RemoteAddonRepository.Category category) {
private static final CategoryIndented ALL = new CategoryIndented(0, null);
}
private static void resolveCategory(RemoteAddonRepository.Category category, int indent, List<CategoryIndented> result) {
result.add(new CategoryIndented(indent, category));
for (RemoteAddonRepository.Category subcategory : category.subcategories()) {
resolveCategory(subcategory, indent + 1, result);
}
}
}
private static final class DownloadListCell extends ListCell<RemoteAddon> {
private final DownloadListPage page;
private final JFXListView<RemoteAddon> listView;
private final RemoteImageLoader iconLoader;
private static final Insets PADDING = new Insets(9, 9, 0, 9);
private static final Insets LAST_PADDING = new Insets(9, 9, 9, 9);
private final RipplerContainer graphic;
private final StackPane wrapper = new StackPane();
private final TwoLineListItem content = new TwoLineListItem();
private final ImageContainer imageContainer = new ImageContainer(40);
/// Creates a download card with its icon and text laid out by the same row.
private DownloadListCell(DownloadListPage page, JFXListView<RemoteAddon> listView, RemoteImageLoader iconLoader) {
this.page = page;
this.listView = listView;
this.iconLoader = iconLoader;
setPadding(PADDING);
content.setPadding(new Insets(8));
content.setCursor(Cursor.HAND);
TwoLineListItem.setMargin(imageContainer, new Insets(0, 8, 0, 0));
imageContainer.setMouseTransparent(true);
content.setLeading(imageContainer);
this.graphic = new RipplerContainer(content);
wrapper.getChildren().setAll(this.graphic);
wrapper.getStyleClass().add("card-no-padding");
FXUtils.onClicked(wrapper, () -> {
RemoteAddon item = getItem();
if (item != null) {
Controllers.navigate(new DownloadPage(page, item, page.getInstanceOptional(), page.callback));
}
});
setPrefWidth(0);
FXUtils.limitCellWidth(listView, this);
}
/// Updates addon text and replaces category tags together so their labels can be reused.
@Override
protected void updateItem(@Nullable RemoteAddon item, boolean empty) {
@Nullable RemoteAddon oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.resetFeedback();
if (empty || item == null) {
setGraphic(null);
} else {
setPadding(
getIndex() == listView.getItems().size() - 1
? LAST_PADDING
: PADDING
);
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(page.type);
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(item.slug()) : null;
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : item.title());
@Nullable String description = item.description();
if (description != null) {
description = description.replaceAll("\\R", " ");
}
content.setSubtitle(description);
content.getTags().setAll(item.categories().stream()
.filter(page::shouldDisplayCategory)
.map(category -> new Tag(page.getLocalizedCategory(category, null)))
.toList());
iconLoader.load(imageContainer.imageProperty(), item.iconUrl());
setGraphic(wrapper);
}
}
}
}
@@ -1,722 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends Control implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final RemoteAddonRepository repository;
private final RemoteAddon addon;
/// Localized project metadata, or null when no registry or matching project is available.
private final @Nullable AddonProjectInfo mod;
private final HMCLGameInstance.Optional instanceReference;
private final DownloadCallback callback;
private final DownloadListPage page;
private final RemoteAddon.Type type;
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.type = Objects.requireNonNullElse(addon.type(), page.type);
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(this.type);
this.mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
this.instanceReference = instanceReference;
this.callback = callback;
loadAddonVersions();
this.state.set(State.fromTitle(addon.title()));
}
private void loadAddonVersions() {
setLoading(true);
setFailed(false);
Task.supplyAsync(() -> {
Stream<RemoteAddon.Version> versions = addon.data().loadVersions(repository, page.getDownloadProvider());
return sortVersions(versions);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
this.versions = result;
loaded.set(true);
setFailed(false);
} else {
setFailed(true);
}
setLoading(false);
}).start();
}
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> sortVersions(Stream<RemoteAddon.Version> versions) {
SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> classifiedVersions
= new SimpleMultimap<>(HashMap::new, ArrayList::new);
versions.forEach(version -> {
for (String gameVersion : version.gameVersions()) {
classifiedVersions.put(gameVersion, version);
}
});
for (String gameVersion : classifiedVersions.keys()) {
List<RemoteAddon.Version> versionList = classifiedVersions.get(gameVersion);
versionList.sort(Comparator.comparing(RemoteAddon.Version::datePublished).reversed());
}
return classifiedVersions;
}
public RemoteAddon getAddon() {
return addon;
}
public HMCLGameInstance.Optional getInstanceOptional() {
return instanceReference;
}
public boolean isLoading() {
return loading.get();
}
public BooleanProperty loadingProperty() {
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
}
public boolean isFailed() {
return failed.get();
}
public BooleanProperty failedProperty() {
return failed;
}
public void setFailed(boolean failed) {
this.failed.set(failed);
}
public void download(RemoteAddon addon, RemoteAddon.Version file) {
if (this.callback == null) {
saveAs(file);
} else {
this.callback.download(page.getDownloadProvider(), instanceReference.repository(), instanceReference.instanceId(), addon, file);
}
}
public void saveAs(RemoteAddon.Version file) {
String extension = StringUtils.substringAfterLast(file.file().filename(), '.');
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("button.save_as"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*." + extension));
fileChooser.setInitialFileName(file.file().filename());
Path dest = Controllers.showSaveDialog(fileChooser);
if (dest == null) {
return;
}
Controllers.taskDialog(
Task.composeAsync(() -> {
var task = new FileDownloadTask(file.file().url(), dest, file.file().getIntegrityCheck());
task.setName(file.name());
return task;
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected Skin<?> createDefaultSkin() {
return new DownloadPageSkin(this);
}
private static class DownloadPageSkin extends SkinBase<DownloadPage> {
protected DownloadPageSkin(DownloadPage control) {
super(control);
VBox pane = new VBox(8);
pane.getStyleClass().add("gray-background");
pane.setPadding(new Insets(10));
HBox descriptionPane = new HBox(8);
descriptionPane.setMinHeight(Region.USE_PREF_SIZE);
descriptionPane.setAlignment(Pos.CENTER);
pane.getChildren().add(descriptionPane);
descriptionPane.getStyleClass().add("card-non-transparent");
{
var imageContainer = new ImageContainer(40);
if (StringUtils.isNotBlank(getSkinnable().addon.iconUrl())) {
imageContainer.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.iconUrl(), 80, 80, true, true));
}
descriptionPane.getChildren().add(imageContainer);
TwoLineListItem content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
@Nullable AddonProjectInfo mod = getSkinnable().mod;
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : getSkinnable().addon.title());
content.setSubtitle(getSkinnable().addon.description());
content.getSubtitleLabel().setWrapText(true);
getSkinnable().addon.categories().stream()
.filter(category -> getSkinnable().page.shouldDisplayCategory(category))
.map(category -> getSkinnable().page.getLocalizedCategory(category, null))
.forEach(content::addTag);
descriptionPane.getChildren().add(content);
@Nullable String mcmodUrl = mod != null ? mod.mcmodUrl() : null;
if (mcmodUrl != null) {
JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod"));
openMcmodButton.setExternalLink(mcmodUrl);
descriptionPane.getChildren().add(openMcmodButton);
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
}
JFXHyperlink openUrlButton = new JFXHyperlink(control.page.getLocalizedOfficialPage());
openUrlButton.setExternalLink(getSkinnable().addon.pageUrl());
descriptionPane.getChildren().add(openUrlButton);
openUrlButton.setMinWidth(Region.USE_PREF_SIZE);
}
SpinnerPane spinnerPane = new SpinnerPane();
VBox.setVgrow(spinnerPane, Priority.ALWAYS);
pane.getChildren().add(spinnerPane);
{
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
spinnerPane.failedReasonProperty().bind(Bindings.createStringBinding(() -> {
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else {
return null;
}
}, getSkinnable().failedProperty()));
spinnerPane.setOnFailedAction(e -> getSkinnable().loadAddonVersions());
ComponentList list = new ComponentList();
ScrollPane scrollPane = new ScrollPane(list);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.setOverflowHidden(scrollPane, 8);
StackPane.setAlignment(scrollPane, Pos.TOP_CENTER);
spinnerPane.setContent(scrollPane);
FXUtils.onChangeAndOperate(control.loaded, loaded -> {
if (control.versions == null) return;
@Nullable HMCLGameInstance instance = control.instanceReference.instance();
if (instance != null) {
String gameVersion = instance.getVersion().toString();
if (!GameVersionNumber.unknown().equals(instance.getVersion())
&& control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
if (addonVersions != null && !addonVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = instance.getModLoaders();
resolve:
for (RemoteAddon.Version addonVersion : addonVersions) {
if (getSkinnable().type == RemoteAddon.Type.MOD) {
for (AddonLoader loader : addonVersion.loaders()) {
if (loader.type() instanceof ModLoaderType modLoaderType && targetLoaders.contains(modLoaderType)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break resolve;
}
}
} else {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break;
}
}
}
}
} else if (getSkinnable().type == RemoteAddon.Type.MODPACK) {
control.versions
.keys()
.stream()
.max(Comparator.comparing(GameVersionNumber::asGameVersion))
.flatMap(gameVersion ->
control.versions.get(gameVersion)
.stream()
.max(Comparator.comparing(RemoteAddon.Version::datePublished))
.map(v -> Pair.pair(gameVersion, v))
).ifPresent(pair ->
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", pair.key())),
new AddonItem(control.addon, pair.value(), control)
)
);
}
final class Versions {
static ComponentSublist createSublist(DownloadPage control, String title, List<String> target) {
var sublist = new ComponentSublist(() -> {
ArrayList<AddonItem> items = new ArrayList<>();
for (String gv : target) {
List<RemoteAddon.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteAddon.Version v : versions) {
items.add(new AddonItem(control.addon, v, control));
}
}
}
return items;
});
sublist.setTitle(title);
sublist.getStyleClass().add("no-padding");
return sublist;
}
final List<String> releases = new ArrayList<>();
final List<String> snapshots = new ArrayList<>();
}
TreeMap<GameVersionNumber, Versions> map = new TreeMap<>(Collections.reverseOrder());
for (String version : control.versions.keys()) {
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(version);
GameVersionNumber releaseOfSnapshot = GameVersionNumber.getReleaseOfSnapshot(gameVersion);
GameVersionNumber releaseVersion = Objects.requireNonNullElse(releaseOfSnapshot, gameVersion);
Versions lists = map.computeIfAbsent(releaseVersion, v -> new Versions());
if (releaseOfSnapshot == null) {
lists.releases.add(version);
} else {
lists.snapshots.add(version);
}
}
for (Map.Entry<GameVersionNumber, Versions> entry : map.entrySet()) {
GameVersionNumber gameVersion = entry.getKey();
Versions versions = entry.getValue();
if (!versions.releases.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.release", gameVersion), versions.releases));
if (!versions.snapshots.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.snapshot", gameVersion), versions.snapshots));
}
});
}
getChildren().setAll(pane);
}
}
/// Displays an addon dependency and opens its download page when activated.
private static final class DependencyAddonItem extends LineButton {
/// Returns the localized dependency type, or its lowercase enum name for other types.
public static String translateDependencyType(RemoteAddon.DependencyType dependencyType) {
return switch (dependencyType) {
case EMBEDDED -> i18n("addon.dependency.embedded");
case OPTIONAL -> i18n("addon.dependency.optional");
case REQUIRED -> i18n("addon.dependency.required");
default -> dependencyType.name().toLowerCase(Locale.ROOT);
};
}
/// The dependency represented by this row.
public final RemoteAddon addon;
/// Creates a dependency row that opens the addon's download page.
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) {
this.addon = addon;
container.setPadding(new Insets(10, 24, 10, 24));
container.getTitleLabel().setMinWidth(0);
setLargeTitle(true);
var imageView = new ImageContainer(40);
imageView.setMouseTransparent(true);
setMargin(imageView, new Insets(0, 8, 0, 0));
setLeading(imageView);
FXUtils.setLimitHeight(this, 60);
RemoteAddon.@Nullable Type type = addon.type();
@Nullable DownloadCallback callback = type == null ? null : switch (type) {
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
default -> null; // Mods, modpacks, worlds, and custom types are not downloadable from this UI
};
setOnAction((e) -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(page, addon, instanceReference, callback));
});
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : addon.title());
setSubtitle(addon.description());
container.getSubtitleLabel().setWrapText(false);
for (String category : addon.categories()) {
if (page.shouldDisplayCategory(category))
getTags().add(new Tag(page.getLocalizedCategory(category, null), Tag.Type.NORMAL));
}
if (StringUtils.isNotBlank(addon.iconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
}
}
}
private static final class AddonItem extends StackPane {
/// Creates a version summary that opens its details when clicked.
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage) {
this(mod, dataItem, selfPage, true);
}
/// Creates a version summary, optionally enabling navigation to its details.
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage, boolean interactive) {
VBox pane = new VBox(8);
pane.setPadding(new Insets(8, 0, 8, 0));
{
HBox descPane = new HBox(8);
descPane.setPadding(new Insets(0, 8, 0, 8));
descPane.setAlignment(Pos.CENTER_LEFT);
descPane.setPickOnBounds(false);
{
StackPane graphicPane = new StackPane();
graphicPane.setMouseTransparent(true);
TwoLineListItem content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
content.setTitle(dataItem.name());
content.setSubtitle(I18n.formatDateTime(dataItem.datePublished()));
@Nullable SVG svg = null;
@Nullable String styleClass = null;
if (dataItem.versionType() != null) {
switch (dataItem.versionType()) {
case Alpha:
content.addTag(i18n("addon.channel.alpha"));
svg = SVG.ALPHA_CIRCLE;
styleClass = "alpha";
break;
case Beta:
content.addTag(i18n("addon.channel.beta"));
svg = SVG.BETA_CIRCLE;
styleClass = "beta";
break;
case Release:
content.addTag(i18n("addon.channel.release"));
svg = SVG.RELEASE_CIRCLE;
styleClass = "release";
break;
default:
styleClass = "";
break;
}
}
if (svg != null) {
SVGContainer icon = svg.createIcon(24);
icon.getStyleClass().addAll("addon-channel-icon", styleClass);
graphicPane.getChildren().setAll(icon);
}
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : dataItem.loaders()) {
tags.add(I18n.translateLoaderName(loader));
}
content.addTags(tags);
descPane.getChildren().setAll(graphicPane, content);
}
pane.getChildren().add(descPane);
}
RipplerContainer container = new RipplerContainer(pane);
container.setPressFeedbackEnabled(interactive);
if (interactive)
FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage)));
getChildren().setAll(container);
// Workaround for https://github.com/HMCL-dev/HMCL/issues/2129
this.setMinHeight(50);
}
}
private static final class AddonVersion extends JFXDialogLayout {
/// Creates version details with dependency downloads and a non-navigating summary.
public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) {
RemoteAddon.Type type = selfPage.type;
String title = switch (type) {
case RESOURCE_PACK -> "resourcepack.download.title";
case SHADER_PACK -> "shaderpack.download.title";
default -> "mods.download.title";
};
this.setHeading(new HBox(new Label(i18n(title, version.name()))));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
var addonItem = new AddonItem(mod, version, selfPage, false);
box.getChildren().setAll(addonItem);
JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog"));
changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider())));
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(version, selfPage.repository, versionPageBtn);
HBox additionalBox = new HBox(changelogButton, versionPageBtn);
box.getChildren().add(additionalBox);
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
ComponentList dependenciesList = new ComponentList();
loadDependencies(version, selfPage, spinnerPane, dependenciesList);
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setContent(dependenciesList);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.onChangeAndOperate(scrollPane.widthProperty(), d -> FXUtils.setLimitWidth(dependenciesList, d.doubleValue()));
FXUtils.smoothScrolling(scrollPane);
FXUtils.setOverflowHidden(scrollPane, 8);
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton downloadButton = null;
if (selfPage.callback != null) {
downloadButton = new JFXButton(i18n("mods.install"));
downloadButton.getStyleClass().add("dialog-accept");
downloadButton.setOnAction(e -> {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.download(mod, version);
});
}
JFXButton saveAsButton = new JFXButton(i18n("mods.save_as"));
saveAsButton.getStyleClass().add("dialog-accept");
saveAsButton.setOnAction(e -> {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.saveAs(version);
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
if (downloadButton == null) {
this.setActions(saveAsButton, cancelButton);
} else {
this.setActions(downloadButton, saveAsButton, cancelButton);
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, cancelButton::fire);
}
private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) {
spinnerPane.setLoading(true);
Task.composeAsync(() -> {
// TODO: Massive tasks may cause OOM.
EnumMap<RemoteAddon.DependencyType, Pair<Label, List<DependencyAddonItem>>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class);
AtomicBoolean hasBroken = new AtomicBoolean(false);
List<Task<?>> queue = new ArrayList<>(version.dependencies().size());
for (RemoteAddon.Dependency dependency : version.dependencies()) {
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE) continue;
if (dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
hasBroken.set(true);
continue;
}
if (!dependencies.containsKey(dependency.getType())) {
Label title = new Label(DependencyAddonItem.translateDependencyType(dependency.getType()));
title.setPadding(new Insets(0, 8, 0, 8));
List<DependencyAddonItem> list = new ArrayList<>();
dependencies.put(dependency.getType(), Pair.pair(title, list));
}
queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider()))
.setSignificance(Task.TaskSignificance.MINOR)
.thenAcceptAsync(Schedulers.javafx(), dep -> {
if (dep == RemoteAddon.BROKEN) {
hasBroken.set(true);
return;
}
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference);
var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue()));
dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener);
dependencies.get(dependency.getType()).value().add(dependencyAddonItem);
})
.setSignificance(Task.TaskSignificance.MINOR));
}
return Task.allOf(queue).thenSupplyAsync(() -> {
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
if (types.value().isEmpty()) return Stream.empty();
return Stream.concat(
Stream.of(types.key()),
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
});
if (!hasBroken.get()) {
return dependenciesStream;
} else {
Label warning = new Label(i18n("addon.dependencies.has_broken"));
warning.setWrapText(true);
warning.setPadding(new Insets(0, 8, 0, 8));
return Stream.concat(
Stream.of(warning),
dependenciesStream
);
}
});
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
dependenciesList.getContent().setAll(result.toList());
spinnerPane.setFailedReason(null);
} else {
dependenciesList.getContent().setAll();
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository repo, JFXHyperlink button) {
Task.supplyAsync(() -> repo.getVersionPageUrl(version))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
})
.start();
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) {
setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(version, repo, provider, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
Task.supplyAsync(() ->
StringUtils.convertToHtml(
repo.getAddonChangelog(provider, version.projectId(), version.versionId()),
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 FUCK YOU JEI, FUCK YOU CURSEFORGE
)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
}
@FunctionalInterface
public interface DownloadCallback {
void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon addon, RemoteAddon.Version file);
}
}
@@ -41,14 +41,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private final TabController tabController;
/// The settings tab bound to the current game instance.
private final TabItem<GameSettingsPage<GameSettings.Instance>> gameSettingsTab;
/// The component management tab bound to the current game instance.
private final TabItem<InstallerListPage> installerListTab;
/// The installed mods tab bound to the current game instance.
private final TabItem<ModListPage> modListTab;
/// The saved worlds tab bound to the current game instance.
private final TabItem<WorldListPage> worldListTab;
/// The installed resource packs tab bound to the current game instance.
private final TabItem<ResourcePackListPage> resourcePackTab;
/// The screenshots tab bound to the current game instance.
private final TabItem<ScreenshotsPage> screenshotsTab;
private final TransitionPane transitionPane = new TransitionPane();
@@ -79,18 +71,10 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
public GameInstancePage() {
// Child tabs subscribe to instanceProperty() themselves and reload on change.
this.gameSettingsTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance));
this.installerListTab = new TabItem<>(() -> new InstallerListPage(instance));
this.modListTab = new TabItem<>(() -> new ModListPage(instance));
this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance));
this.worldListTab = new TabItem<>(() -> new WorldListPage(instance));
this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance));
tabController = new TabController(transitionPane,
gameSettingsTab,
installerListTab,
modListTab,
resourcePackTab,
worldListTab,
screenshotsTab);
tabController.selectTab(gameSettingsTab);
@@ -118,11 +102,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}));
getSidebar()
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL)
.addNavigationDrawerTab(tabController, installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL)
.addNavigationDrawerTab(tabController, modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE)
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC);
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL);
PopupMenu browseList = new PopupMenu();
JFXPopup browsePopup = new JFXPopup(browseList);
@@ -1,108 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.util.MissingResourceException;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, true, true);
page.supportChinese.set(false);
return page;
}
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, boolean curseForge, boolean modrinth) {
super(type, new Repository(), callback, instanceSelection);
((Repository) repository).downloadSource.bind(downloadSource);
supportChinese.set(true);
boolean supportedCurseForge = curseForge && CurseForgeRemoteAddonRepository.isAvailable();
downloadSources.setAll("addon.modrinth");
if (supportedCurseForge) {
downloadSources.add("addon.curseforge");
}
if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) {
if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else if (modrinth) {
downloadSource.set("addon.modrinth");
} else {
throw new AssertionError("Should not be here.");
}
} else {
if (modrinth) {
downloadSource.set("addon.modrinth");
} else if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else {
throw new AssertionError("Should not be here.");
}
}
}
private static class Repository extends LocalizedRemoteAddonRepository {
public Repository() {
}
public final StringProperty downloadSource = new SimpleStringProperty();
@Override
protected RemoteAddonRepository getBackedRemoteModRepository() {
if ("addon.modrinth".equals(downloadSource.get())) {
return ModrinthRemoteAddonRepository.getInstance();
} else {
return CurseForgeRemoteAddonRepository.getInstance();
}
}
}
@Override
protected String getLocalizedCategory(String category, Object self) {
if (category.isEmpty()) {
return "";
}
String key = ("addon.modrinth".equals(downloadSource.get()) ? "modrinth" : "curse") + ".category." + category;
try {
return I18n.getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.warning("Cannot find key " + key + " in resource bundle");
if (self instanceof CurseForgeRemoteAddonRepository.Category curseCategory) {
return curseCategory.getName();
}
return category;
}
}
@Override
protected String getLocalizedOfficialPage() {
if ("addon.modrinth".equals(downloadSource.get())) {
return i18n("addon.modrinth");
} else {
return i18n("addon.curseforge");
}
}
}
@@ -1,127 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.*;
import org.jetbrains.annotations.Nullable;
import java.util.*;
public class InstallerListPage extends ListPageBase<InstallerItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
/// Creates an installer list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new InstallerListPageSkin();
}
/// Rebuilds the component controls for the selected instance, or clears them if none is selected.
/// Minecraft editing follows the stored manifest's modifiability; other installed components
/// require a recognized patch to expose editing controls.
public void loadInstance(HMCLGameInstance.Optional instance) {
@Nullable HMCLGameInstance gameInstance = instance.instance();
this.gameInstance = gameInstance;
if (gameInstance == null) {
itemsProperty().clear();
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
itemsProperty().clear();
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM);
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
for (InstallerItem component : group.getComponents()) {
// Skip fabric-api and quilt-api and legacyfabric-api
if (component.getComponentType().getPatchId().endsWith("-api")) {
continue;
}
@Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType());
if (libraryVersion != null) {
component.versionProperty().set(new InstallerItem.InstalledState(
libraryVersion,
component.getComponentType() == GameComponentType.GAME
? !gameInstance.getManifest().isModifiable()
: !gameInstance.getAnalyzer().isClear(component.getComponentType()),
false
));
} else {
component.versionProperty().set(null);
}
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
component.getComponentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(component);
}
var currentItems = getItems().stream().map(InstallerItem::getComponentType).toList();
// other third-party libraries which are unable to manage.
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
if (currentItems.contains(mark.componentType())) continue;
// we have done this library above.
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
mark.componentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(installerItem);
}
}
private void reloadCurrentInstance() {
if (gameInstance != null) {
loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId()));
}
}
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
InstallerListPageSkin() {
super(InstallerListPage.this);
}
@Override
protected List<Node> initializeToolbar(InstallerListPage skinnable) {
return Collections.emptyList();
}
}
}
@@ -1,900 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.mod.ModManager;
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
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.*;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> implements PageAware {
private final ReentrantLock lock = new ReentrantLock();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
private final BooleanProperty isLoadingMods = new SimpleBooleanProperty(false);
private long modLoadRequestId;
/// Creates a mod list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ModListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
mods.forEach(it -> {
try {
modManager.addMod(it);
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse mod file " + it, e);
}
});
loadMods(modManager);
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModListPageSkin(this);
}
public void refresh() {
if (isLoadingMods.get()) {
return;
}
loadMods(modManager);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
return;
}
loadMods(gameInstance.getModManager());
}
private void loadMods(ModManager modManager) {
long requestId = ++modLoadRequestId;
setLoading(true);
isLoadingMods.set(true);
if (this.modManager != modManager) {
getItems().clear();
}
this.modManager = modManager;
CompletableFuture.supplyAsync(() -> {
lock.lock();
try {
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
lock.unlock();
}
}, Schedulers.io()).whenCompleteAsync((list, exception) -> {
if (this.modManager != modManager || requestId != modLoadRequestId) {
return;
}
try {
supportedLoaders.clear();
supportedLoaders.addAll(modManager.getSupportedLoaders());
if (exception == null) {
getItems().setAll(list);
} else {
LOG.warning("Failed to load mods", exception);
getItems().clear();
}
} finally {
isLoadingMods.set(false);
setLoading(false);
}
}, Schedulers.javafx());
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null) return;
// It's guaranteed that succeeded and failed are thread safe here.
List<String> succeeded = new ArrayList<>(res.size());
List<String> failed = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : res) {
try {
modManager.addMod(file);
succeeded.add(FileUtils.getName(file));
} catch (Exception e) {
LOG.warning("Unable to add mod " + file, e);
failed.add(FileUtils.getName(file));
// Actually addMod will not throw exceptions because FileChooser has already filtered files.
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
List<String> prompt = new ArrayList<>(1);
if (!succeeded.isEmpty())
prompt.add(i18n("mods.add.success", String.join(", ", succeeded)));
if (!failed.isEmpty())
prompt.add(i18n("mods.add.failed", String.join(", ", failed)));
Controllers.dialog(String.join("\n", prompt), i18n("mods.add"));
loadMods(modManager);
}).start();
}
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModInfoObject::getModInfo)
.forEach(info -> {
try {
info.delete();
} catch (IOException e) {
failed.add(info.getFile().toString());
LOG.warning("Failed to delete mod \"" + info.getId() + "\"", e);
}
});
loadMods(modManager);
if (!failed.isEmpty())
Controllers.dialog(i18n("mods.delete.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void setSelectedEnabled(ObservableList<ModInfoObject> selectedItems, boolean enabled) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModInfoObject::getModInfo)
.forEach(info -> {
try {
info.setActive(enabled);
} catch (IOException e) {
failed.add(info.getFile().toString());
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("mods.toggle.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
public void openModFolder() {
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getModsDirectory());
}
}
/// Checks the supplied mods for updates and opens the results for the current instance.
/// The target directory is retained even if this page switches instances during the check.
/// Does nothing while the list is loading or no instance is selected.
///
/// @param mods the mods to check
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
if (isLoading() || gameInstance == null) {
return;
}
Path modDirectory = gameInstance.getModManager().getDirectory();
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception instanceof CancellationException) return;
if (exception != null || result == null) {
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
if (exception != null) LOG.warning("Failed to check for updates", exception);
} else if (result.commonUpdates().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), modDirectory, result));
}
})
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("mods.update_modpack_mod.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
public void rollback(LocalModFile from, LocalModFile to) {
try {
modManager.rollback(from, to);
refresh();
} catch (IOException ex) {
Controllers.showToast(i18n("message.failed"));
}
}
public GameDirectory getGameDirectory() {
return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null;
}
public HMCLGameRepository getRepository() {
return gameInstance != null ? gameInstance.getRepository() : null;
}
public GameInstanceID getInstanceId() {
return gameInstance != null ? gameInstance.getId() : null;
}
@NotNullByDefault
private static final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
private final HBox toolbarSelecting;
private final JFXListView<ModListPage.ModInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
public ModListPageSkin(ModListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
pane.getChildren().setAll(root);
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
toolbarSelecting = new HBox();
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
JFXButton refreshButton = createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh);
refreshButton.disableProperty().bind(skinnable.isLoadingMods);
// Toolbar Normal
toolbarNormal.getChildren().setAll(
refreshButton,
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
// Toolbar Selecting
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> {
selectAll.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
};
listView.getSelectionModel().getSelectedItems().addListener(listener);
listView.getItems().addListener(listener);
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
selectAll,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new ModListPage.ModInfoListCell(listView, skinnable));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("mods.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("mods.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super ModListPage.ModInfoObject>) c -> {
if (isSearching.get()) {
search();
}
});
listView.setOnContextMenuRequested(event -> {
ModListPage.ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ModListPage.ModInfoDialog(selectedItem));
}
});
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ModListPage.ModInfoObject item : getSkinnable().getItems()) {
LocalModFile modInfo = item.getModInfo();
if (predicate.test(modInfo.getFileName())
|| predicate.test(modInfo.getName())
|| predicate.test(modInfo.getVersion())
|| predicate.test(modInfo.getGameVersion())
|| predicate.test(modInfo.getId())
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|| predicate.test((item.getProjectInfo() != null ? item.getProjectInfo().displayName() : null))) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ModInfoObject {
private final LocalModFile localModFile;
/// Matching project metadata, or null if the local mod cannot be identified.
private final @Nullable ModProjectInfo projectInfo;
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public LocalModFile getModInfo() {
return localModFile;
}
/// Returns matching project metadata, or null if no project was found.
public @Nullable ModProjectInfo getProjectInfo() {
return projectInfo;
}
private Image getDefaultIcon() {
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
private Image loadIcon() {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
iconPaths.add(this.localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load mod icons", e);
}
return getDefaultIcon();
}
}
private static final class ModInfoDialog extends JFXDialogLayout {
ModInfoDialog(ModInfoObject modInfo) {
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
prefWidthProperty().bind(widthBinding);
maxWidthProperty().bind(widthBinding);
var imageContainer = new ImageContainer(40);
titleContainer.setAlignment(Pos.CENTER_LEFT);
modInfo.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.getTitleLabel().setWrapText(true);
String displayName = modInfo.getModInfo().getName();
if (modInfo.getProjectInfo() != null && I18n.isUseChinese()) {
String chineseName = modInfo.getProjectInfo().mainName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
displayName = chineseName + " (" + displayName + ")";
}
}
title.setTitle(displayName);
StringJoiner subtitle = new StringJoiner("\n");
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
}
title.setSubtitle(subtitle.toString());
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(modInfo.getModInfo().getDescription().toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : versionOptional.get().loaders()) {
String tag = I18n.translateLoaderName(loader);
if (tag != null) tags.add(tag);
}
title.addTagsIfNotExist(tags);
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
getActions().add(officialPageButton);
}
@Nullable String mcmodUrl = modInfo.getProjectInfo() != null ? modInfo.getProjectInfo().mcmodUrl() : null;
if (mcmodUrl == null) {
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
getActions().add(searchButton);
} else {
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
mcmodButton.setExternalLink(mcmodUrl);
getActions().add(mcmodButton);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ModListPage page;
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
private final JFXSwitch activeSwitch = new JFXSwitch();
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getModInfo().isActive());
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
private Tooltip warningTooltip;
/// Creates a selectable mod row with file actions for the supplied page.
ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
super(listView);
this.page = page;
this.getStyleClass().add("mod-info-list-cell");
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
container.setPadding(new Insets(0, 8, 0, 8));
HBox.setHgrow(content, Priority.ALWAYS);
setSelectable();
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
// free up space when not visible
restoreButton.managedProperty().bind(restoreButton.visibleProperty());
HBox actions = new HBox();
actions.setAlignment(Pos.CENTER);
actions.getChildren().setAll(restoreButton, revealButton, infoButton, activeSwitch);
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
container.getChildren().setAll(imageContainer, content, actions);
StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
}
/// Updates mod details, warnings, and tags while retaining reusable tag labels.
@Override
protected void updateControl(@Nullable ModInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
pseudoClassStateChanged(WARNING, false);
if (warningTooltip != null) {
Tooltip.uninstall(this, warningTooltip);
warningTooltip = null;
}
if (empty || dataItem == null) return;
List<String> warning = new ArrayList<>();
var tags = new ArrayList<Tag>(2);
LocalModFile modInfo = dataItem.getModInfo();
@Nullable ModProjectInfo projectInfo = dataItem.getProjectInfo();
ModLoaderType modLoaderType = modInfo.getModLoaderType();
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
String displayName = modInfo.getName();
if (projectInfo != null && I18n.isUseChinese()) {
String chineseName = projectInfo.mainName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
displayName = chineseName + " (" + displayName + ")";
}
}
content.setTitle(displayName);
StringJoiner joiner = new StringJoiner(" | ");
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());
joiner.add(FileUtils.getName(modInfo.getFile()));
content.setSubtitle(joiner.toString());
if (modLoaderType == ModLoaderType.UNKNOWN) {
tags.add(Tag.warning(i18n("mods.unknown")));
} else if (!page.supportedLoaders.contains(modLoaderType)) {
warning.add(i18n("mods.warning.loader_mismatch"));
tags.add(Tag.warning(I18n.translateLoaderType(dataItem.getModInfo().getModLoaderType())));
}
String modVersion = modInfo.getVersion();
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
tags.add(new Tag(modVersion));
}
content.getTags().setAll(tags);
(observedActiveProperty = modInfo.activeProperty()).addListener(weakActiveListener);
activeSwitch.setDisableAnimation(true);
activeSwitch.setSelected(modInfo.isActive());
activeSwitch.setOnAction(event -> {
try {
modInfo.setActive(activeSwitch.isSelected());
} catch (IOException e) {
LOG.warning("Unable to change state of mod file " + modInfo.getFile(), e);
Controllers.showToast(i18n("mods.toggle.failed"));
} finally {
activeSwitch.setSelected(modInfo.isActive());
}
});
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
() -> page.rollback(modInfo, localModFile),
popup.get()))
.toList()
);
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
});
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
if (!warning.isEmpty()) {
pseudoClassStateChanged(WARNING, true);
//noinspection ConstantValue
this.warningTooltip = warning.size() == 1
? new Tooltip(warning.get(0))
: new Tooltip(String.join("\n", warning));
FXUtils.installFastTooltip(this, warningTooltip);
}
}
}
}
@@ -1,689 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> {
private static final String TIP_KEY = "resourcePackWarning";
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
return switch (compatibility) {
case TOO_NEW -> i18n("resourcepack.warning.too_new");
case TOO_OLD -> i18n("resourcepack.warning.too_old");
case INVALID -> i18n("resourcepack.warning.invalid");
case MISSING_PACK_META -> i18n("resourcepack.warning.missing_pack_meta");
case MISSING_GAME_META -> i18n("resourcepack.warning.missing_game_meta");
default -> null;
};
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path resourcePackDirectory;
private ResourcePackManager resourcePackManager;
private final ReentrantLock lock = new ReentrantLock();
/// Creates a resource-pack list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ResourcePackListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new ResourcePackListPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.resourcePackManager = null;
this.resourcePackDirectory = null;
getItems().clear();
return;
}
this.resourcePackManager = gameInstance.getResourcePackManager();
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
refresh();
}
public void refresh() {
if (resourcePackManager == null) return;
setDisable(false);
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
lock.lock();
try {
if (!ResourcePackManager.isMcVersionSupported(resourcePackManager.getMinecraftVersion())) return null;
resourcePackManager.refresh();
return resourcePackManager.arePacksEnabled(resourcePackManager.getLocalFiles().stream())
.map(ResourcePackInfoObject::new)
.toList();
} finally {
lock.unlock();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result != null) {
getItems().setAll(result);
} else { // Unsupported mc version
getItems().clear();
setDisable(true);
}
} else {
LOG.warning("Failed to load resource packs", exception);
getItems().clear();
}
setLoading(false);
}).start();
}
public void addFiles(List<Path> files) {
if (resourcePackManager == null) return;
List<Path> failures = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : files) {
try {
resourcePackManager.importResourcePack(file);
} catch (Exception e) {
LOG.warning("Failed to add resource pack", e);
failures.add(file);
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
if (!failures.isEmpty()) {
StringBuilder failure = new StringBuilder(i18n("resourcepack.add.failed"));
for (Path file: failures) {
failure.append("\n").append(file.toString());
}
Controllers.dialog(failure.toString(), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
refresh();
}).start();
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("resourcepack.add"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
private void onOpenFolder() {
if (resourcePackDirectory != null) {
FXUtils.openFolder(resourcePackDirectory);
}
}
private void setSelectedEnabled(List<ResourcePackInfoObject> selectedItems, boolean enabled) {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.warning"),
MessageDialogPane.MessageType.WARNING,
() -> {
SettingsManager.state().getShownTips().put(TIP_KEY, true);
setSelectedEnabled(selectedItems, true);
}, null);
} else {
if (resourcePackManager == null) return;
if (enabled) {
resourcePackManager.enableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
} else {
resourcePackManager.disableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
}
for (ResourcePackInfoObject item : selectedItems) {
item.enabledProperty().set(enabled);
}
}
}
private void removeSelected(List<ResourcePackInfoObject> selectedItems) {
try {
if (resourcePackManager != null) {
if (resourcePackManager.removeResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList())) {
refresh();
}
}
} catch (IOException e) {
Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to delete resource packs", e);
}
}
/// Checks the supplied resource packs for updates and opens the results for the current instance.
/// The target directory is retained even if this page switches instances during the check.
/// Does nothing when no instance is selected.
///
/// @param resourcePacks the resource packs to check
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
if (gameInstance == null) {
return;
}
Path resourcePackDirectory = gameInstance.getResourcePackManager().getDirectory();
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), resourcePacks);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || result == null) {
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
} else if (result.commonUpdates().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), resourcePackDirectory, result));
}
})
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("resourcepack.update_in_modpack.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
@NotNullByDefault
private static final class ResourcePackListPageSkin extends SkinBase<ResourcePackListPage> {
private final JFXListView<ResourcePackInfoObject> listView;
private final JFXTextField searchField = new JFXTextField();
private final TransitionPane toolbarPane = new TransitionPane();
private final HBox searchBar = new HBox();
private final HBox toolbarNormal = new HBox();
private final HBox toolbarSelecting = new HBox();
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
private ResourcePackListPageSkin(ResourcePackListPage control) {
super(control);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
{
// Toolbar Selecting
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
control.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("button.enable"), SVG.CHECK, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("button.disable"), SVG.CLOSE, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream().map(ResourcePackInfoObject::getFile).toList()
)
),
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
listView.getSelectionModel().selectAll()),
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
FXUtils.onChange(searchField.textProperty(), newValue -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
// Toolbar Normal
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
toolbarNormal.setPickOnBounds(false);
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh),
createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, control::onOpenFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(control.loadingProperty());
listView.setCellFactory(x -> new ResourcePackListCell(listView, control));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("resourcepack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("resourcepack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), control.getItems());
listView.setOnContextMenuRequested(event -> {
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ResourcePackInfoDialog(selectedItem));
}
});
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ResourcePackInfoObject item : getSkinnable().getItems()) {
ResourcePackFile resourcePack = item.getFile();
LocalAddonFile.Description description = resourcePack.getDescription();
Stream<String> descriptionParts = description == null
? Stream.empty()
: description.parts().stream().map(LocalAddonFile.Description.Part::text);
if (predicate.test(resourcePack.getFileNameWithExtension())
|| predicate.test(resourcePack.getFileName())
|| descriptionParts.anyMatch(predicate)) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ResourcePackInfoObject {
private final ResourcePackFile file;
private final BooleanProperty enabled;
private final ItemPropertyAsyncCache<Image, ResourcePackInfoObject> iconCache;
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
this.file = pair.key();
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public ResourcePackFile getFile() {
return file;
}
public BooleanProperty enabledProperty() {
return enabled;
}
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image icon = file.loadIcon();
if (icon != null && !icon.isError() && icon.getWidth() > 0 && icon.getHeight() > 0 && Math.abs(icon.getWidth() - icon.getHeight()) < 1) {
return icon;
}
return getDefaultIcon();
}
}
private static final class ResourcePackListCell extends MDListCell<ResourcePackInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ResourcePackListPage page;
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
private final JFXSwitch activeSwitch;
private BooleanProperty booleanProperty = null;
/// Creates a selectable pack row with compatibility confirmation and file actions.
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
super(listView);
this.page = page;
getStyleClass().add("resource-pack-list-cell");
HBox root = new HBox(8);
root.setPickOnBounds(false);
root.setAlignment(Pos.CENTER_LEFT);
root.setPadding(new Insets(0, 8, 0, 8));
activeSwitch = new JFXSwitch() {
@Override
public void fire() {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && getItem() != null && !getItem().getFile().isCompatible()) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.info"),
MessageDialogPane.MessageType.INFO,
() -> {
super.fire();
SettingsManager.state().getShownTips().put(TIP_KEY, true);
}, null);
} else {
super.fire();
}
}
};
activeSwitch.setOnAction(e -> {
if (getItem() != null) getItem().getFile().setEnabled(activeSwitch.isSelected());
});
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
HBox.setHgrow(content, Priority.ALWAYS);
HBox actions = new HBox();
actions.setAlignment(Pos.CENTER);
actions.getChildren().setAll(btnReveal, btnInfo, activeSwitch);
root.getChildren().setAll(imageContainer, content, actions);
setSelectable();
StackPane.setMargin(root, new Insets(8));
getContainer().getChildren().add(root);
}
/// Updates pack details and reuses the compatibility tag when a warning remains.
@Override
protected void updateControl(@Nullable ResourcePackInfoObject item, boolean empty) {
pseudoClassStateChanged(WARNING, false);
if (empty || item == null) return;
ResourcePackFile file = item.getFile();
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(itemProperty()));
content.setTitle(file.getFileName());
{
var description = file.getDescription();
content.setSubtitle(description != null ? description.toStringSingleLine() : "");
}
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(item)));
if (booleanProperty != null) {
activeSwitch.selectedProperty().unbindBidirectional(booleanProperty);
}
activeSwitch.selectedProperty().bindBidirectional(booleanProperty = item.enabledProperty());
{
var compatibility = file.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
pseudoClassStateChanged(WARNING, true);
content.getTags().setAll(Tag.warning(getWarning(compatibility)));
} else {
content.getTags().clear();
}
}
}
}
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
ResourcePackInfoDialog(ResourcePackInfoObject packInfoObject) {
ResourcePackFile pack = packInfoObject.getFile();
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
packInfoObject.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(pack.getFileName());
title.setSubtitle(pack.getFileNameWithExtension());
var compatibility = pack.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
title.addTagWarning(getWarning(compatibility));
}
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(Objects.requireNonNullElse(pack.getDescription(), "").toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
FXUtils.onChange(description.heightProperty(), newVal -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
}
@@ -1,75 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* @author Glavo
*/
public final class WorldBackupTask extends Task<Path> {
private final World world;
private final Path backupsDir;
private final boolean needLock;
public WorldBackupTask(World world, Path backupsDir, boolean needLock) {
this.world = world;
this.backupsDir = backupsDir;
this.needLock = needLock;
}
@Override
public void execute() throws Exception {
try (FileChannel lockChannel = needLock ? world.lock() : null) {
Files.createDirectories(backupsDir);
String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER);
String baseName = time + "_" + world.getFileName();
Path backupFile = null;
OutputStream outputStream = null;
int count;
for (count = 0; count < 256; count++) {
try {
backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath();
outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
break;
} catch (FileAlreadyExistsException ignored) {
}
}
if (outputStream == null)
throw new IOException("Too many attempts");
try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) {
String rootName = world.getFileName();
Path rootDir = this.world.getFile();
Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
if (path.endsWith("session.lock")) {
return FileVisitResult.CONTINUE;
}
zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/')));
Files.copy(path, zipOutputStream);
zipOutputStream.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
setResult(backupFile);
}
}
}
@@ -1,265 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.BooleanProperty;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
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.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
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;
/**
* @author Glavo
*/
public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.BackupInfo> implements WorldManagePage.WorldRefreshable {
static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
private final World world;
private final Path backupsDir;
private final BooleanProperty readOnly;
private final Pattern backupFileNamePattern;
public WorldBackupsPage(WorldManagePage worldManagePage) {
this.world = worldManagePage.getWorld();
this.backupsDir = worldManagePage.getBackupsDir();
this.readOnly = worldManagePage.readOnlyProperty();
this.backupFileNamePattern = Pattern.compile("(?<datetime>[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?<count>[0-9]+))?\\.zip");
refresh();
}
public void refresh() {
setLoading(true);
Task.supplyAsync(() -> {
if (Files.isDirectory(backupsDir)) {
try (Stream<Path> paths = Files.list(backupsDir)) {
ArrayList<BackupInfo> result = new ArrayList<>();
paths.forEach(path -> {
if (Files.isRegularFile(path)) {
try {
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
if (matcher.matches()) {
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
int count = 0;
if (matcher.group("count") != null) {
count = Integer.parseInt(matcher.group("count"));
}
result.add(new BackupInfo(path, new World(path), time, count));
}
} catch (Throwable e) {
LOG.warning("Failed to load backup file " + path, e);
}
}
});
result.sort(Comparator.naturalOrder());
return result;
}
} else {
return new ArrayList<BackupInfo>();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
this.setLoading(false);
if (exception == null) {
this.setItems(FXCollections.observableList(result));
} else {
LOG.warning("Failed to load backups", exception);
}
}).start();
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldBackupsPageSkin();
}
void createBackup() {
Controllers.taskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> {
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
if (!matcher.matches()) {
throw new AssertionError("Wrong backup file name" + path);
}
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
int count = 0;
if (matcher.group("count") != null) {
count = Integer.parseInt(matcher.group("count"));
}
return Pair.pair(path, new BackupInfo(path, new World(path), time, count));
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
WorldBackupsPage.this.getItems().add(result.getValue());
WorldBackupsPage.this.getItems().sort(Comparator.naturalOrder());
Controllers.dialog(i18n("world.backup.create.success", result.getKey()), null, MessageDialogPane.MessageType.INFO);
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
LOG.warning("Failed to create backup", exception);
Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}), i18n("world.backup"), TaskCancellationAction.NO_CANCEL);
}
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
WorldBackupsPageSkin() {
super(WorldBackupsPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.backup.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
protected List<Node> initializeToolbar(WorldBackupsPage skinnable) {
JFXButton createBackup = createToolbarButton2(i18n("world.backup.create.new_one"), SVG.ARCHIVE, skinnable::createBackup);
createBackup.disableProperty().bind(getSkinnable().readOnly);
return Arrays.asList(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createBackup
);
}
}
public final class BackupInfo extends Control implements Comparable<BackupInfo> {
private final Path file;
private final World backupWorld;
private final LocalDateTime backupTime;
private final int count;
public BackupInfo(Path file, World backupWorld, LocalDateTime backupTime, int count) {
this.file = file;
this.backupWorld = backupWorld;
this.backupTime = backupTime;
this.count = count;
}
public World getBackupWorld() {
return backupWorld;
}
public LocalDateTime getBackupTime() {
return backupTime;
}
@Override
protected Skin<?> createDefaultSkin() {
return new BackupInfoSkin(this);
}
void onReveal() {
FXUtils.showFileInExplorer(file);
}
void onDelete() {
WorldBackupsPage.this.getItems().remove(this);
Task.runAsync(() -> Files.delete(file)).start();
}
@Override
public int compareTo(@NotNull WorldBackupsPage.BackupInfo that) {
int c = this.backupTime.compareTo(that.backupTime);
return c != 0 ? c : Integer.compare(this.count, that.count);
}
}
private static final class BackupInfoSkin extends SkinBase<BackupInfo> {
BackupInfoSkin(BackupInfo skinnable) {
super(skinnable);
World world = skinnable.getBackupWorld();
BorderPane root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
var imageView = new ImageContainer(32);
left.getChildren().add(imageView);
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
{
TwoLineListItem item = new TwoLineListItem();
root.setCenter(item);
if (skinnable.getBackupWorld().getWorldName() != null)
item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName()));
item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")"));
if (world.getGameVersion() != null)
item.addTag(I18n.getDisplayVersion(world.getGameVersion()));
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
right.getChildren().add(btnReveal);
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> skinnable.onReveal());
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
right.getChildren().add(btnDelete);
FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete"));
btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::onDelete, null));
}
getChildren().setAll(new RipplerContainer(root));
}
}
}
@@ -1,65 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardSinglePage;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class WorldExportPage extends WizardSinglePage {
private final StringProperty path = new SimpleStringProperty();
private final StringProperty gameVersion = new SimpleStringProperty();
private final StringProperty worldName = new SimpleStringProperty();
private final World world;
public WorldExportPage(World world, Path export, Runnable onFinish) {
super(onFinish);
this.world = world;
path.set(export.toString());
if (world.getGameVersion() != null)
gameVersion.set(I18n.getDisplayVersion(world.getGameVersion()));
worldName.set(world.getWorldName());
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldExportPageSkin(this);
}
public StringProperty pathProperty() {
return path;
}
public StringProperty gameVersionProperty() {
return gameVersion;
}
public StringProperty worldNameProperty() {
return worldName;
}
public void export() {
onFinish.run();
}
@Override
public String getTitle() {
return i18n("world.export.wizard", world.getFileName());
}
@Override
protected Object finish() {
return Task.runAsync(i18n("world.export.wizard", worldName.get()), () -> world.export(Paths.get(path.get()), worldName.get()));
}
}
@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.binding.Bindings;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
import org.jackhuang.hmcl.ui.construct.LinePane;
import org.jackhuang.hmcl.ui.construct.LineTextPane;
import java.nio.file.Files;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class WorldExportPageSkin extends SkinBase<WorldExportPage> {
public WorldExportPageSkin(WorldExportPage skinnable) {
super(skinnable);
VBox container = new VBox();
container.setSpacing(16);
container.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(container, 500);
ComponentList list = new ComponentList();
var chooseFileButton = new LineFileChooserButton();
chooseFileButton.setTitle(i18n("world.export.location"));
chooseFileButton.setType(LineFileChooserButton.Type.SAVE_FILE);
chooseFileButton.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
chooseFileButton.locationProperty().bindBidirectional(skinnable.pathProperty());
var worldNamePane = new LinePane();
worldNamePane.setTitle(i18n("world.name"));
JFXTextField txtWorldName = new JFXTextField();
txtWorldName.textProperty().bindBidirectional(skinnable.worldNameProperty());
txtWorldName.setPrefWidth(300);
worldNamePane.setRight(txtWorldName);
LineTextPane gameVersionPane = new LineTextPane();
gameVersionPane.setTitle(i18n("world.game_version"));
gameVersionPane.textProperty().bind(skinnable.gameVersionProperty());
list.getContent().setAll(chooseFileButton, worldNamePane, gameVersionPane);
container.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("world.export")),
list
);
JFXButton btnExport = FXUtils.newRaisedButton(i18n("button.export"));
btnExport.disableProperty().bind(Bindings.createBooleanBinding(() -> txtWorldName.getText().isEmpty() || Files.exists(Paths.get(chooseFileButton.getLocation())),
txtWorldName.textProperty().isEmpty(), chooseFileButton.locationProperty()));
btnExport.setOnAction(e -> skinnable.export());
HBox bottom = new HBox();
bottom.setAlignment(Pos.CENTER_RIGHT);
bottom.getChildren().setAll(btnExport);
container.getChildren().add(bottom);
getChildren().setAll(container);
}
}
@@ -1,670 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.effect.BoxBlur;
import javafx.scene.image.Image;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.glavo.nbt.tag.*;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Locale;
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;
/**
* @author Glavo
*/
public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable {
private final WorldManagePage worldManagePage;
private boolean isReadOnly;
private final World world;
private CompoundTag levelData;
private CompoundTag playerData;
private final ImageContainer iconImageView = new ImageContainer(32);
public WorldInfoPage(WorldManagePage worldManagePage) {
this.worldManagePage = worldManagePage;
this.world = worldManagePage.getWorld();
refresh();
}
private void updateControls() {
CompoundTag dataTag = (CompoundTag) levelData.get("Data");
CompoundTag playerTag = playerData;
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
setContent(scrollPane);
VBox rootPane = new VBox();
rootPane.setFillWidth(true);
scrollPane.setContent(rootPane);
FXUtils.smoothScrolling(scrollPane);
rootPane.getStyleClass().add("card-list");
ComponentList worldInfo = new ComponentList();
{
var worldNamePane = new LinePane();
{
worldNamePane.setTitle(i18n("world.name"));
JFXTextField worldNameField = new JFXTextField();
setRightTextField(worldNamePane, worldNameField, 200);
if (dataTag.get("LevelName") instanceof StringTag worldNameTag) {
var worldName = new SimpleStringProperty(worldNameTag.get());
FXUtils.bindString(worldNameField, worldName);
worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName);
worldName.addListener((observable, oldValue, newValue) -> {
if (StringUtils.isNotBlank(newValue)) {
try {
world.setWorldName(newValue);
worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())));
} catch (Exception e) {
LOG.warning("Failed to set world name", e);
}
}
});
} else {
worldNameField.setDisable(true);
}
}
var gameVersionPane = new LineTextPane();
{
gameVersionPane.setTitle(i18n("world.info.game_version"));
gameVersionPane.setText(world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString());
}
var iconPane = new LinePane();
{
iconPane.setTitle(i18n("world.icon"));
{
iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
JFXButton editIconButton = FXUtils.newToggleButton4(SVG.EDIT, 20);
JFXButton resetIconButton = FXUtils.newToggleButton4(SVG.RESTORE, 20);
{
editIconButton.setDisable(isReadOnly);
editIconButton.setOnAction(event -> Controllers.confirm(
I18n.i18n("world.icon.change.tip"),
I18n.i18n("world.icon.change"),
MessageDialogPane.MessageType.INFO,
this::changeWorldIcon,
null
));
FXUtils.installFastTooltip(editIconButton, i18n("button.edit"));
resetIconButton.setDisable(isReadOnly);
resetIconButton.setOnAction(event -> this.clearWorldIcon());
FXUtils.installFastTooltip(resetIconButton, i18n("button.reset"));
}
HBox hBox = new HBox(8);
hBox.setAlignment(Pos.CENTER_LEFT);
hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton);
iconPane.setRight(hBox);
}
var seedPane = new LinePane();
{
seedPane.setTitle(i18n("world.info.random_seed"));
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
JFXButton visibilityButton = FXUtils.newToggleButton4(SVG.VISIBILITY_OFF);
{
visibilityButton.setAlignment(Pos.CENTER);
visibilityButton.setOnAction((event) -> visibility.set(!visibility.get()));
}
Label seedLabel = new Label();
{
FXUtils.copyOnDoubleClick(seedLabel);
seedLabel.setAlignment(Pos.CENTER_RIGHT);
seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : "");
BoxBlur blur = new BoxBlur();
blur.setIterations(3);
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
visibilityButton.setGraphic(icon.createIcon(16));
seedLabel.setEffect(isVisibility ? null : blur);
});
}
HBox right = new HBox(8);
{
right.setAlignment(Pos.CENTER_RIGHT);
right.getChildren().setAll(visibilityButton, seedLabel);
seedPane.setRight(right);
}
}
var worldSpawnPoint = new LineTextPane();
{
worldSpawnPoint.setTitle(i18n("world.info.spawn"));
String value;
// Valid after 1.21.9-pre1
if (dataTag.get("spawn") instanceof CompoundTag spawnTag && spawnTag.get("pos") instanceof IntArrayTag posTag) {
value = (spawnTag.get("dimension") instanceof StringTag dimensionTag)
? Dimension.of(dimensionTag).formatPosition(posTag)
: Dimension.OVERWORLD.formatPosition(posTag);
}
// Valid before 1.21.9-pre1
else if (dataTag.get("SpawnX") instanceof IntTag intX
&& dataTag.get("SpawnY") instanceof IntTag intY
&& dataTag.get("SpawnZ") instanceof IntTag intZ) {
value = Dimension.OVERWORLD.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
} else {
value = null;
}
worldSpawnPoint.setText(value);
}
var lastPlayedPane = new LineTextPane();
{
lastPlayedPane.setTitle(i18n("world.info.last_played"));
lastPlayedPane.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
}
var timePane = new LineTextPane();
{
timePane.setTitle(i18n("world.info.time"));
if (dataTag.get("Time") instanceof LongTag timeTag) {
Duration duration = Duration.ofSeconds(timeTag.getValue() / 20);
timePane.setText(i18n("world.info.time.format", duration.toDays(), duration.toHoursPart(), duration.toMinutesPart()));
}
}
var allowCheatsButton = new LineSwitch();
{
allowCheatsButton.setTitle(i18n("world.info.allow_cheats"));
allowCheatsButton.setDisable(isReadOnly);
bindTagAndSwitch(dataTag.get("allowCommands"), allowCheatsButton);
}
var generateFeaturesButton = new LineSwitch();
{
generateFeaturesButton.setTitle(i18n("world.info.generate_features"));
generateFeaturesButton.setDisable(isReadOnly);
// Valid before (1.16)20w20a
if (dataTag.get("MapFeatures") instanceof ByteTag generateFeaturesTag) {
bindTagAndSwitch(generateFeaturesTag, generateFeaturesButton);
}
// Valid after (1.16)20w20a
else if (world.getNormalizedWorldGenSettingsData() != null) {
CompoundTag unifiedWorldGenSettingsDataTag = world.getNormalizedWorldGenSettingsData();
Tag tag = unifiedWorldGenSettingsDataTag.get("generate_features"); // Valid between (1.16)20w20a and 26.1-snapshot-6
if (tag == null)
tag = unifiedWorldGenSettingsDataTag.get("generate_structures"); // Valid after 26.1-snapshot-6
bindTagAndSwitch(tag, generateFeaturesButton);
} else {
generateFeaturesButton.setDisable(true);
}
}
var difficultyButton = new LineSelectButton<Difficulty>();
{
difficultyButton.setTitle(i18n("world.info.difficulty"));
difficultyButton.setDisable(isReadOnly);
difficultyButton.setItems(Difficulty.items);
Difficulty difficulty;
// Valid before 26.1-snapshot-6
if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue((byte) newValue.ordinal());
saveWorldData();
}
});
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("difficulty") instanceof StringTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue(newValue.getTagStringValue());
saveWorldData();
}
});
} else {
difficultyButton.setDisable(true);
}
}
var difficultyLockPane = new LineSwitch();
{
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
difficultyLockPane.setDisable(isReadOnly);
// Valid before 26.1-snapshot-6
if (dataTag.get("DifficultyLocked") instanceof ByteTag difficultyLockedTag) {
bindTagAndSwitch(difficultyLockedTag, difficultyLockPane);
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("locked") instanceof ByteTag lockedTag) {
bindTagAndSwitch(lockedTag, difficultyLockPane);
} else {
difficultyLockPane.setDisable(true);
}
}
worldInfo.getContent().setAll(
worldNamePane, gameVersionPane, iconPane, seedPane, worldSpawnPoint, lastPlayedPane, timePane,
allowCheatsButton, generateFeaturesButton, difficultyButton, difficultyLockPane);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info")), worldInfo);
}
if (playerTag != null) {
ComponentList playerInfo = new ComponentList();
var locationPane = new LineTextPane();
{
locationPane.setTitle(i18n("world.info.player.location"));
Dimension dimension = Dimension.of(playerTag.get("Dimension"));
if (dimension != null && playerTag.get("Pos") instanceof ListTag<?> posTag) {
locationPane.setText(dimension.formatPosition(posTag));
}
}
var lastDeathLocationPane = new LineTextPane();
{
lastDeathLocationPane.setTitle(i18n("world.info.player.last_death_location"));
// Valid after 22w14a; prior to this version, the game did not record the last death location data.
if (playerTag.get("LastDeathLocation") instanceof CompoundTag LastDeathLocationTag) {
Dimension dimension = Dimension.of(LastDeathLocationTag.get("dimension"));
if (dimension != null && LastDeathLocationTag.get("pos") instanceof IntArrayTag posTag) {
lastDeathLocationPane.setText(dimension.formatPosition(posTag));
}
}
}
var spawnPane = new LineTextPane();
{
spawnPane.setTitle(i18n("world.info.player.spawn"));
if (playerTag.get("respawn") instanceof CompoundTag respawnTag
&& respawnTag.get("dimension") instanceof StringTag dimensionTag
&& respawnTag.get("pos") instanceof IntArrayTag intArrayTag
&& intArrayTag.size() >= 3) { // Valid after 25w07a
spawnPane.setText(Dimension.of(dimensionTag).formatPosition(intArrayTag));
} else if (playerTag.get("SpawnX") instanceof IntTag intX
&& playerTag.get("SpawnY") instanceof IntTag intY
&& playerTag.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a
// SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld.
spawnPane.setText((playerTag.get("SpawnDimension") instanceof StringTag dimensionTag ? Dimension.of(dimensionTag) : Dimension.OVERWORLD)
.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()));
}
}
var playerGameTypePane = new LineSelectButton<GameType>();
{
playerGameTypePane.setTitle(i18n("world.info.player.game_type"));
playerGameTypePane.setDisable(worldManagePage.isReadOnly());
playerGameTypePane.setItems(GameType.items);
// Valid before 26.1-snapshot-6
Tag rawHardcoreTag = dataTag.get("hardcore");
// Valid after 26.1-snapshot-6
if (rawHardcoreTag == null && dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingsTag) {
rawHardcoreTag = difficultySettingsTag.get("hardcore");
}
if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag && rawHardcoreTag instanceof ByteTag hardcoreTag) {
boolean isHardcore = hardcoreTag.getValue() == 1;
GameType gameType = GameType.of(playerGameTypeTag.getValue(), isHardcore);
if (gameType != null) {
playerGameTypePane.setValue(gameType);
playerGameTypePane.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
if (newValue == GameType.HARDCORE) {
playerGameTypeTag.setValue(0); // survival (hardcore worlds are survival+hardcore flag)
hardcoreTag.setValue((byte) 1);
} else {
playerGameTypeTag.setValue(newValue.ordinal());
hardcoreTag.setValue((byte) 0);
}
saveWorldData();
}
});
} else {
playerGameTypePane.setDisable(true);
}
} else {
playerGameTypePane.setDisable(true);
}
}
var healthPane = new LinePane();
{
healthPane.setTitle(i18n("world.info.player.health"));
setRightTextField(healthPane, 50, playerTag.get("Health"));
}
var foodLevelPane = new LinePane();
{
foodLevelPane.setTitle(i18n("world.info.player.food_level"));
setRightTextField(foodLevelPane, 50, playerTag.get("foodLevel"));
}
var foodSaturationPane = new LinePane();
{
foodSaturationPane.setTitle(i18n("world.info.player.food_saturation_level"));
setRightTextField(foodSaturationPane, 50, playerTag.get("foodSaturationLevel"));
}
var xpLevelPane = new LinePane();
{
xpLevelPane.setTitle(i18n("world.info.player.xp_level"));
setRightTextField(xpLevelPane, 50, playerTag.get("XpLevel"));
}
playerInfo.getContent().setAll(
locationPane, lastDeathLocationPane, spawnPane, playerGameTypePane,
healthPane, foodLevelPane, foodSaturationPane, xpLevelPane
);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.player")), playerInfo);
}
}
private void setRightTextField(LinePane linePane, int perfWidth, Tag tag) {
JFXTextField textField = new JFXTextField();
setRightTextField(linePane, textField, perfWidth);
if (tag instanceof IntTag intTag) {
bindTagAndTextField(intTag, textField);
} else if (tag instanceof FloatTag floatTag) {
bindTagAndTextField(floatTag, textField);
} else {
textField.setDisable(true);
}
}
private void setRightTextField(LinePane linePane, JFXTextField textField, int perfWidth) {
textField.setDisable(isReadOnly);
textField.setPrefWidth(perfWidth);
linePane.setRight(textField);
}
/// Saves boolean byte-tag edits through the switch, disabling it for missing or invalid tags.
private void bindTagAndSwitch(@Nullable Tag tag, LineSwitch switchControl) {
if (tag instanceof ByteTag byteTag) {
byte value = byteTag.getValue();
if (value == 0 || value == 1) {
switchControl.setSelected(value == 1);
switchControl.selectedProperty().addListener((o, oldValue, newValue) -> {
try {
byteTag.setValue((byte) (newValue ? 1 : 0));
saveWorldData();
} catch (Exception e) {
switchControl.setSelected(oldValue);
LOG.warning("Exception happened when saving world info", e);
}
});
} else {
switchControl.setDisable(true);
}
} else {
switchControl.setDisable(true);
}
}
private void bindTagAndTextField(IntTag intTag, JFXTextField jfxTextField) {
jfxTextField.setText(intTag.getValue().toString());
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
Integer integer = Lang.toIntOrNull(newValue);
if (integer != null) {
intTag.setValue(integer);
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving world data", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true));
}
private void bindTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) {
jfxTextField.setText(new DecimalFormat("0.#").format(floatTag.getValue()));
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
Float floatValue = Lang.toFloatOrNull(newValue);
if (floatValue != null) {
floatTag.setValue(floatValue);
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving world data", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
}
private void saveWorldData() {
LOG.info("Saving data of world " + world.getWorldName());
try {
this.world.writeWorldData();
} catch (IOException e) {
LOG.warning("Failed to save world data", e);
}
}
@Override
public void refresh() {
setFailedReason(null);
try {
this.isReadOnly = worldManagePage.isReadOnly();
this.levelData = world.getLevelData();
this.playerData = world.getPlayerData();
updateControls();
} catch (Exception e) {
LOG.warning("Failed to refresh world info", e);
setFailedReason(i18n("world.info.failed"));
}
}
private record Dimension(String name) {
static final Dimension OVERWORLD = new Dimension(null);
static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether"));
static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end"));
static Dimension of(Tag tag) {
if (tag instanceof IntTag intTag) {
return switch (intTag.get()) {
case 0 -> OVERWORLD;
case -1 -> THE_NETHER;
case 1 -> THE_END;
default -> null;
};
} else if (tag instanceof StringTag stringTag) {
String id = stringTag.getValue();
return switch (id) {
case "overworld", "minecraft:overworld" -> OVERWORLD;
case "the_nether", "minecraft:the_nether" -> THE_NETHER;
case "the_end", "minecraft:the_end" -> THE_END;
default -> new Dimension(id);
};
} else {
return null;
}
}
String formatPosition(Tag tag) {
if (tag instanceof ListTag<?> listTag && listTag.size() == 3) {
if (listTag.getTag(0) instanceof DoubleTag x
&& listTag.getTag(1) instanceof DoubleTag y
&& listTag.getTag(2) instanceof DoubleTag z) {
return formatPosition(x.get(), y.get(), z.get());
}
return null;
}
if (tag instanceof IntArrayTag intArrayTag) {
int x = intArrayTag.get(0);
int y = intArrayTag.get(1);
int z = intArrayTag.get(2);
return formatPosition(x, y, z);
}
return null;
}
String formatPosition(int x, int y, int z) {
return this == OVERWORLD
? "(%d, %d, %d)".formatted(x, y, z)
: "%s (%d, %d, %d)".formatted(name, x, y, z);
}
String formatPosition(double x, double y, double z) {
return this == OVERWORLD
? "(%.2f, %.2f, %.2f)".formatted(x, y, z)
: "%s (%.2f, %.2f, %.2f)".formatted(name, x, y, z);
}
}
private enum Difficulty {
PEACEFUL, EASY, NORMAL, HARD;
static final ObservableList<Difficulty> items = FXCollections.observableList(Arrays.asList(values()));
static Difficulty of(int d) {
return (d >= 0 && d < items.size()) ? items.get(d) : null;
}
static Difficulty of(String name) {
for (Difficulty d : items) if (d.name().toLowerCase(Locale.ROOT).equals(name)) return d;
return null;
}
String getTagStringValue() {
return this.name().toLowerCase(Locale.ROOT);
}
@Override
public String toString() {
return i18n("world.info.difficulty." + name().toLowerCase(Locale.ROOT));
}
}
private enum GameType {
SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR, HARDCORE;
static final ObservableList<GameType> items = FXCollections.observableList(Arrays.asList(values()));
static GameType of(int d, boolean hardcore) {
if (hardcore && d == 0) return HARDCORE; // hardcore + survival
return d >= 0 && d < 4 ? items.get(d) : null;
}
@Override
public String toString() {
return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT));
}
}
private void changeWorldIcon() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.icon.choose.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
fileChooser.setInitialFileName("icon.png");
Path iconPath = Controllers.showOpenDialog(fileChooser);
if (iconPath == null) return;
Image image;
try {
image = FXUtils.loadImage(iconPath);
} catch (Exception e) {
LOG.warning("Failed to load image", e);
Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR);
return;
}
if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) {
Path output = world.getFile().resolve("icon.png");
saveWorldIcon(iconPath, image, output);
} else {
Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR);
}
}
private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) {
Image oldImage = iconImageView.getImage();
try {
FileUtils.copyFile(sourcePath, targetPath);
iconImageView.setImage(image);
Controllers.showToast(i18n("world.icon.change.succeed.toast"));
} catch (IOException e) {
LOG.warning("Failed to save world icon " + e.getMessage());
iconImageView.setImage(oldImage);
}
}
private void clearWorldIcon() {
Path output = world.getFile().resolve("icon.png");
try {
Files.deleteIfExists(output);
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
} catch (IOException e) {
LOG.warning("Failed to delete world icon ", e);
}
}
}
@@ -1,426 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
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.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
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;
import org.jetbrains.annotations.Nullable;
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.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
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<World> {
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private Path savesDir;
private List<World> worlds;
private @Nullable HMCLGameInstance gameInstance;
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
private int refreshCount = 0;
/// Creates a world list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public WorldListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
installWorld(modpacks.get(0));
});
showAll.addListener(e -> updateWorldList());
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<WorldListPage> createDefaultSkin() {
return new WorldListPageSkin();
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null;
refresh();
}
private void updateWorldList() {
if (worlds == null || gameInstance == null) {
getItems().clear();
} else if (showAll.get()) {
getItems().setAll(worlds);
} else {
GameVersionNumber gameVersion = gameInstance.getVersion();
getItems().setAll(worlds.stream()
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.toList());
}
}
public void refresh() {
if (gameInstance == null || savesDir == null)
return;
int currentRefresh = ++refreshCount;
HMCLGameInstance gameInstance = this.gameInstance;
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
// Ensure the game version number is parsed
gameInstance.getVersion();
return World.getWorlds(savesDir);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (refreshCount != currentRefresh) {
// A newer refresh task is running, discard this result
return;
}
supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion()));
worlds = result;
updateWorldList();
if (exception != null)
LOG.warning("Failed to load world list page", exception);
setLoading(false);
}).start();
}
public void openSavesFolder() {
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getSavesDirectory());
}
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("world.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.world"), "*.zip"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null || res.isEmpty()) return;
installWorld(res.get(0));
}
private void installWorld(Path zipFile) {
// Only accept one world file because user is required to confirm the new world name
// Or too many input dialogs are popped.
Task.supplyAsync(() -> new World(zipFile))
.whenComplete(Schedulers.javafx(), world -> {
Controllers.prompt(i18n("world.name.enter"), (name, handler) -> {
Task.runAsync(() -> world.install(savesDir, name))
.whenComplete(Schedulers.javafx(), () -> {
handler.resolve();
refresh();
}, e -> {
if (e instanceof FileAlreadyExistsException)
handler.reject(i18n("world.add.failed", i18n("world.add.already_exists")));
else if (e instanceof IOException && e.getCause() instanceof InvalidPathException)
handler.reject(i18n("world.add.failed", i18n("install.new_game.malformed")));
else
handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage()));
}).start();
}, world.getWorldName(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
}, e -> {
LOG.warning("Unable to parse world file " + zipFile, e);
Controllers.dialog(i18n("world.add.invalid"));
}).start();
}
private void showManagePage(World world) {
if (gameInstance != null) {
Controllers.navigate(new WorldManagePage(world, gameInstance));
}
}
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) {
if (gameInstance != null) {
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
}
public void generateLaunchScript(World world) {
if (gameInstance != null) {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
}
public BooleanProperty showAllProperty() {
return showAll;
}
public ReadOnlyBooleanProperty supportQuickPlayProperty() {
return supportQuickPlay;
}
private final class WorldListPageSkin extends ToolbarListPageSkin<World, WorldListPage> {
WorldListPageSkin() {
super(WorldListPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
protected List<Node> initializeToolbar(WorldListPage skinnable) {
JFXCheckBox chkShowAll = new JFXCheckBox(i18n("world.show_all"));
chkShowAll.selectedProperty().bindBidirectional(skinnable.showAllProperty());
return Arrays.asList(
chkShowAll,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openSavesFolder)
);
}
@Override
protected ListCell<World> createListCell(JFXListView<World> listView) {
return new WorldListCell(getSkinnable());
}
}
private static final class WorldListCell extends ListCell<World> {
private final WorldListPage page;
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final Tooltip leftTooltip;
private final TwoLineListItem content;
private final JFXButton btnLaunch;
/// Creates a world row with launch and management actions for the supplied page.
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 ImageContainer(32);
left.getChildren().add(imageView);
}
{
this.content = new TwoLineListItem();
root.setCenter(content);
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
btnLaunch = FXUtils.newToggleButton4(SVG.ROCKET_LAUNCH);
btnLaunch.visibleProperty().bind(page.supportQuickPlayProperty());
btnLaunch.managedProperty().bind(btnLaunch.visibleProperty());
right.getChildren().add(btnLaunch);
FXUtils.installFastTooltip(btnLaunch, i18n("instance.launch"));
btnLaunch.setOnAction(event -> {
World world = getItem();
if (world != null)
page.launch(world);
});
JFXButton btnMore = FXUtils.newToggleButton4(SVG.MORE_VERT);
right.getChildren().add(btnMore);
btnMore.setOnAction(event -> {
World world = getItem();
if (world != null)
showPopupMenu(world, page.supportQuickPlayProperty().get(), 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, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
});
}
/// Updates world details and replaces version and lock tags together.
@Override
protected void updateItem(@Nullable World world, boolean empty) {
@Nullable World oldWorld = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(world, empty);
if (oldWorld == world && oldEmpty == empty) return;
this.graphic.resetFeedback();
if (empty || world == null) {
content.getTags().clear();
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()) : "");
var tags = new ArrayList<Tag>(2);
if (world.getGameVersion() != null)
tags.add(new Tag(I18n.getDisplayVersion(world.getGameVersion())));
if (world.isLocked()) {
tags.add(new Tag(i18n("world.locked")));
btnLaunch.setDisable(true);
} else {
btnLaunch.setDisable(false);
}
content.getTags().setAll(tags);
content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
setGraphic(graphic);
}
}
// Popup Menu
public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
boolean worldLocked = world.isLocked();
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
if (supportQuickPlay) {
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch_and_enter_world"), () -> page.launch(world), popup);
launchItem.setDisable(worldLocked);
popupMenu.getContent().addAll(
launchItem,
new IconedMenuItem(SVG.SCRIPT, i18n("instance.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 (ChunkBaseApp.supportEndCity(world)) {
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);
exportMenuItem.setDisable(worldLocked);
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> page.delete(world), popup);
deleteMenuItem.setDisable(worldLocked);
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
duplicateMenuItem.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);
}
}
}
@@ -1,250 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
import javafx.beans.property.*;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays world information, backups, and management actions.
public final class WorldManagePage extends DecoratorAnimatedPage implements DecoratorPage {
private final World world;
private final Path backupsDir;
private final HMCLGameInstance gameInstance;
private final boolean supportQuickPlay;
/// The active world lock, or null when the world is read-only.
private @Nullable FileChannel sessionLockChannel;
private final ObjectProperty<State> state;
private boolean isFirstNavigation = true;
private final BooleanProperty refreshable = new SimpleBooleanProperty(true);
private final BooleanProperty readOnly = new SimpleBooleanProperty(false);
private final TransitionPane transitionPane = new TransitionPane();
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController = new TabController(transitionPane);
/// The tab displaying information about this world.
private final TabItem<WorldInfoPage> worldInfoTab;
/// The tab for managing backups of this world.
private final TabItem<WorldBackupsPage> worldBackupsTab;
/// The tab for managing data packs in this world.
private final TabItem<DataPackListPage> dataPackTab;
/// Loads world metadata and creates its tabs and sidebar actions.
public WorldManagePage(World world, HMCLGameInstance gameInstance) {
this.world = world;
this.gameInstance = gameInstance;
this.backupsDir = gameInstance.getBackupsDirectory();
updateSessionLockChannel();
try {
this.world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world data of world: " + this.world.getFile(), e);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail());
}
this.worldInfoTab = new TabItem<>(() -> new WorldInfoPage(this));
this.worldBackupsTab = new TabItem<>(() -> new WorldBackupsPage(this));
this.dataPackTab = new TabItem<>(() -> new DataPackListPage(this));
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion());
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
setContent(transitionPane);
initializeSidebar();
setSidebarBottom(createToolBar());
}
/// Reloads world metadata and refreshes only tab pages whose content is already initialized.
@Override
public void refresh() {
updateSessionLockChannel();
try {
world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world data of world: " + world.getFile(), e);
closePageForLoadingFail();
return;
}
for (var tab : tabController.getTabs()) {
if (tab.isInitialized() && tab.getNode() instanceof WorldRefreshable r) {
r.refresh();
}
}
}
private void closePageForLoadingFail() {
Platform.runLater(() -> {
fireEvent(new PageCloseEvent());
Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR);
});
}
private void updateSessionLockChannel() {
if (sessionLockChannel == null || !sessionLockChannel.isOpen()) {
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
readOnly.set(sessionLockChannel == null);
}
}
private void onNavigated(Navigator.NavigationEvent event) {
if (isFirstNavigation)
isFirstNavigation = false;
else
refresh();
}
public void onExited(Navigator.NavigationEvent event) {
try {
WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException ignored) {
}
}
public void launch() {
fireEvent(new PageCloseEvent());
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
public void generateLaunchScript() {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
public void setTitle(String title) {
this.state.set(new DecoratorPage.State(title, null, true, true, true));
}
public World getWorld() {
return world;
}
public Path getBackupsDir() {
return backupsDir;
}
public boolean isReadOnly() {
return readOnly.get();
}
public BooleanProperty readOnlyProperty() {
return readOnly;
}
@Override
public BooleanProperty refreshableProperty() {
return refreshable;
}
/// Registers supported world tabs and selects the information tab.
private void initializeSidebar() {
tabController.getTabs().addAll(worldInfoTab, worldBackupsTab);
tabController.selectTab(worldInfoTab);
getSidebar().addNavigationDrawerTab(tabController, worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL)
.addNavigationDrawerTab(tabController, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL);
if (world.supportDataPacks()) {
tabController.getTabs().add(dataPackTab);
getSidebar().addNavigationDrawerTab(tabController, dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL);
}
}
/// Creates world actions and binds write operations to the lock state.
private AdvancedListBox createToolBar() {
AdvancedListBox toolbar = new AdvancedListBox();
{
if (supportQuickPlay) {
toolbar.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> launch(), advancedListItem -> advancedListItem.disableProperty().bind(readOnlyProperty()));
}
if (ChunkBaseApp.isSupported(world)) {
PopupMenu chunkBasePopupMenu = new PopupMenu();
JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu);
chunkBasePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup)
);
if (ChunkBaseApp.supportEndCity(world)) {
chunkBasePopupMenu.getContent().add(
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup));
}
toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem ->
chunkBaseMenuItem.setOnAction(e ->
chunkBasePopup.show(chunkBaseMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
chunkBaseMenuItem.getWidth(), 0)));
}
toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()));
{
PopupMenu managePopupMenu = new PopupMenu();
JFXPopup managePopup = new JFXPopup(managePopupMenu);
if (supportQuickPlay) {
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch"), () -> launch(), managePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> generateLaunchScript(), managePopup),
new MenuSeparator()
);
}
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup),
new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup),
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup)
);
toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem ->
{
managePopupMenuItem.setOnAction(e ->
managePopup.show(managePopupMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
managePopupMenuItem.getWidth(), 0));
managePopupMenuItem.disableProperty().bind(readOnlyProperty());
});
}
}
return toolbar;
}
public interface WorldRefreshable {
void refresh();
}
}
@@ -1,127 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldManageUIUtils {
private WorldManageUIUtils() {
}
public static void delete(World world, Runnable runnable) {
delete(world, runnable, null);
}
public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) {
Controllers.confirm(
i18n("button.remove.confirm"),
i18n("world.delete"),
() -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel))
.thenRunAsync(world::delete)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
runnable.run();
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}).start(),
null
);
}
public static void export(World world) {
export(world, null);
}
public static void export(World world, FileChannel sessionLockChannel) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
fileChooser.setInitialFileName(world.getWorldName() + ".zip");
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
return;
}
try {
closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException e) {
return;
}
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
}
public static void copyWorld(World world, Runnable runnable) {
Path worldPath = world.getFile();
Controllers.prompt(
i18n("world.duplicate.prompt"),
(result, handler) -> {
Path targetDir = worldPath.resolveSibling(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("world.duplicate.failed.already_exists"));
return;
}
Task.runAsync(Schedulers.io(), () -> world.copy(result))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast")))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> {
if (runnable != null) {
runnable.run();
}
}
).whenComplete(Schedulers.javafx(), (throwable) -> {
if (throwable == null) {
handler.resolve();
} else {
handler.reject(i18n("world.duplicate.failed"));
LOG.warning("Failed to duplicate world " + world.getFile(), throwable);
}
})
.start();
}, "", new RequiredValidator(), new Validator(i18n("world.duplicate.failed.invalid_name"), FileUtils::isNameValid));
}
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
if (sessionLockChannel != null) {
try {
sessionLockChannel.close();
LOG.info("Closed session lock channel of the world " + world.getFileName());
} catch (IOException e) {
throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e);
}
}
}
public static FileChannel getSessionLockChannel(World world) {
try {
FileChannel lock = world.lock();
LOG.info("Acquired lock on world " + world.getFileName());
return lock;
} catch (WorldLockedException ignored) {
return null;
}
}
}