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:
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);
|
||||
|
||||
-108
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user