feat: 优化数据包管理功能 (#4672)
close #4661 Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
This commit is contained in:
@@ -26,16 +26,21 @@ 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.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.DatapackInfoObject> {
|
||||
private final Path worldDir;
|
||||
@@ -47,7 +52,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
datapack = new Datapack(worldDir.resolve("datapacks"));
|
||||
datapack.loadFromDir();
|
||||
|
||||
setItems(MappedObservableList.create(datapack.getInfo(), DatapackListPageSkin.DatapackInfoObject::new));
|
||||
setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new));
|
||||
|
||||
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
|
||||
mods -> mods.forEach(this::installSingleDatapack), this::refresh);
|
||||
@@ -55,9 +60,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
|
||||
private void installSingleDatapack(Path datapack) {
|
||||
try {
|
||||
Datapack zip = new Datapack(datapack);
|
||||
zip.loadFromZip();
|
||||
zip.installTo(worldDir);
|
||||
this.datapack.installPack(datapack);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
LOG.warning("Unable to parse datapack file " + datapack, e);
|
||||
}
|
||||
@@ -83,8 +86,9 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("datapack.extension"), "*.zip"));
|
||||
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
|
||||
|
||||
if (res != null)
|
||||
if (res != null) {
|
||||
res.forEach(this::installSingleDatapack);
|
||||
}
|
||||
|
||||
datapack.loadFromDir();
|
||||
}
|
||||
@@ -97,7 +101,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
datapack.deletePack(pack);
|
||||
} catch (IOException e) {
|
||||
// Fail to remove mods if the game is running or the datapack is absent.
|
||||
LOG.warning("Failed to delete datapack " + pack);
|
||||
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -105,12 +109,41 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
void enableSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.map(DatapackListPageSkin.DatapackInfoObject::getPackInfo)
|
||||
.forEach(info -> info.setActive(true));
|
||||
.forEach(pack -> pack.setActive(true));
|
||||
}
|
||||
|
||||
void disableSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.map(DatapackListPageSkin.DatapackInfoObject::getPackInfo)
|
||||
.forEach(info -> info.setActive(false));
|
||||
.forEach(pack -> pack.setActive(false));
|
||||
}
|
||||
|
||||
void openDataPackFolder() {
|
||||
FXUtils.openFolder(datapack.getPath());
|
||||
}
|
||||
|
||||
@NotNull Predicate<DatapackListPageSkin.DatapackInfoObject> updateSearchPredicate(String queryString) {
|
||||
if (queryString.isBlank()) {
|
||||
return dataPack -> true;
|
||||
}
|
||||
|
||||
final Predicate<String> stringPredicate;
|
||||
if (queryString.startsWith("regex:")) {
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(StringUtils.substringAfter(queryString, "regex:"));
|
||||
stringPredicate = s -> s != null && pattern.matcher(s).find();
|
||||
} catch (Exception e) {
|
||||
return dataPack -> false;
|
||||
}
|
||||
} else {
|
||||
String lowerCaseFilter = queryString.toLowerCase(Locale.ROOT);
|
||||
stringPredicate = s -> s != null && s.toLowerCase(Locale.ROOT).contains(lowerCaseFilter);
|
||||
}
|
||||
|
||||
return dataPack -> {
|
||||
String id = dataPack.getPackInfo().getId();
|
||||
String description = dataPack.getPackInfo().getDescription().toString();
|
||||
return stringPredicate.test(id) || stringPredicate.test(description);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,33 +17,80 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.mod.Datapack;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
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.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.MDListCell;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
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;
|
||||
|
||||
final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
private final HBox normalToolbar;
|
||||
private final HBox selectingToolbar;
|
||||
InvalidationListener updateBarByStateWeakListener;
|
||||
|
||||
private final JFXListView<DatapackInfoObject> listView;
|
||||
private final FilteredList<DatapackInfoObject> filteredList;
|
||||
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty isSelecting = new SimpleBooleanProperty(false);
|
||||
private final JFXTextField searchField;
|
||||
|
||||
private static final AtomicInteger lastShiftClickIndex = new AtomicInteger(-1);
|
||||
final Consumer<Integer> toggleSelect;
|
||||
|
||||
DatapackListPageSkin(DatapackListPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
@@ -53,22 +100,79 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
|
||||
|
||||
ComponentList root = new ComponentList();
|
||||
root.getStyleClass().add("no-padding");
|
||||
JFXListView<DatapackInfoObject> listView = new JFXListView<>();
|
||||
listView = new JFXListView<>();
|
||||
filteredList = new FilteredList<>(skinnable.getItems());
|
||||
|
||||
{
|
||||
HBox toolbar = new HBox();
|
||||
toolbar.getChildren().add(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh));
|
||||
toolbar.getChildren().add(createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add));
|
||||
toolbar.getChildren().add(createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
|
||||
}, null);
|
||||
}));
|
||||
toolbar.getChildren().add(createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())));
|
||||
toolbar.getChildren().add(createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())));
|
||||
root.getContent().add(toolbar);
|
||||
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, () -> isSearching.set(true))
|
||||
);
|
||||
|
||||
selectingToolbar.getChildren().addAll(
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
|
||||
listView.getSelectionModel().selectRange(0, listView.getItems().size())),//reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
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);
|
||||
PauseTransition pause = new PauseTransition(Duration.millis(100));
|
||||
pause.setOnFinished(e -> filteredList.setPredicate(skinnable.updateSearchPredicate(searchField.getText())));
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
pause.setRate(1);
|
||||
pause.playFromStart();
|
||||
});
|
||||
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
|
||||
() -> {
|
||||
isSearching.set(false);
|
||||
searchField.clear();
|
||||
});
|
||||
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 -> isSelecting.set(selectedItem != null));
|
||||
root.getContent().add(toolbarPane);
|
||||
|
||||
updateBarByStateWeakListener = FXUtils.observeWeak(() -> {
|
||||
if (isSelecting.get()) {
|
||||
changeToolbar(selectingToolbar);
|
||||
} else if (!isSelecting.get() && !isSearching.get()) {
|
||||
changeToolbar(normalToolbar);
|
||||
} else {
|
||||
changeToolbar(searchBar);
|
||||
}
|
||||
}, isSearching, isSelecting);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -80,26 +184,48 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
|
||||
Holder<Object> lastCell = new Holder<>();
|
||||
listView.setCellFactory(x -> new DatapackInfoListCell(listView, lastCell));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
this.listView.setItems(filteredList);
|
||||
|
||||
// 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);
|
||||
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
toggleSelect = i -> {
|
||||
if (listView.getSelectionModel().isSelected(i)) {
|
||||
listView.getSelectionModel().clearSelection(i);
|
||||
} else {
|
||||
listView.getSelectionModel().select(i);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
// 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 extends RecursiveTreeObject<DatapackInfoObject> {
|
||||
private final BooleanProperty active;
|
||||
private final BooleanProperty activeProperty;
|
||||
private final Datapack.Pack packInfo;
|
||||
|
||||
private SoftReference<CompletableFuture<Image>> iconCache;
|
||||
|
||||
DatapackInfoObject(Datapack.Pack packInfo) {
|
||||
this.packInfo = packInfo;
|
||||
this.active = packInfo.activeProperty();
|
||||
this.activeProperty = packInfo.activeProperty();
|
||||
}
|
||||
|
||||
String getTitle() {
|
||||
@@ -113,10 +239,69 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
|
||||
Datapack.Pack getPackInfo() {
|
||||
return packInfo;
|
||||
}
|
||||
|
||||
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);
|
||||
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
|
||||
}
|
||||
} 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);
|
||||
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
|
||||
}
|
||||
}
|
||||
|
||||
if (image != null && !image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
|
||||
Math.abs(image.getWidth() - image.getHeight()) < 1) {
|
||||
return image;
|
||||
} else {
|
||||
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
|
||||
}
|
||||
}
|
||||
|
||||
public void loadIcon(ImageView imageView, @Nullable WeakReference<ObjectProperty<DatapackInfoObject>> current) {
|
||||
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
|
||||
CompletableFuture<Image> imageFuture;
|
||||
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
|
||||
Image image = imageFuture.getNow(null);
|
||||
if (image != null) {
|
||||
imageView.setImage(image);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
|
||||
this.iconCache = new SoftReference<>(imageFuture);
|
||||
}
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
|
||||
imageFuture.thenAcceptAsync(image -> {
|
||||
if (current != null) {
|
||||
ObjectProperty<DatapackInfoObject> infoObjectProperty = current.get();
|
||||
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
|
||||
// The current ListCell has already switched to another object
|
||||
return;
|
||||
}
|
||||
}
|
||||
imageView.setImage(image);
|
||||
}, Schedulers.javafx());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DatapackInfoListCell extends MDListCell<DatapackInfoObject> {
|
||||
private final class DatapackInfoListCell extends MDListCell<DatapackInfoObject> {
|
||||
final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
ImageView imageView = new ImageView();
|
||||
final TwoLineListItem content = new TwoLineListItem();
|
||||
BooleanProperty booleanProperty;
|
||||
|
||||
@@ -130,9 +315,16 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
|
||||
imageView.setFitWidth(32);
|
||||
imageView.setFitHeight(32);
|
||||
imageView.setPreserveRatio(true);
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
container.getChildren().setAll(checkBox, content);
|
||||
container.getChildren().setAll(checkBox, imageView, content);
|
||||
getContainer().getChildren().setAll(container);
|
||||
|
||||
getContainer().getParent().addEventHandler(MouseEvent.MOUSE_PRESSED, mouseEvent -> handleSelect(this, mouseEvent));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -143,7 +335,41 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty);
|
||||
dataItem.loadIcon(imageView, new WeakReference<>(this.itemProperty()));
|
||||
}
|
||||
}
|
||||
|
||||
public void handleSelect(DatapackInfoListCell cell, MouseEvent mouseEvent) {
|
||||
if (cell.isEmpty()) {
|
||||
mouseEvent.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mouseEvent.isShiftDown()) {
|
||||
int currentIndex = cell.getIndex();
|
||||
if (lastShiftClickIndex.get() == -1) {
|
||||
lastShiftClickIndex.set(currentIndex);
|
||||
toggleSelect.accept(cell.getIndex());
|
||||
} else if (listView.getItems().size() >= lastShiftClickIndex.get() && !(lastShiftClickIndex.get() < -1)) {
|
||||
if (cell.isSelected()) {
|
||||
IntStream.rangeClosed(
|
||||
Math.min(lastShiftClickIndex.get(), currentIndex),
|
||||
Math.max(lastShiftClickIndex.get(), currentIndex))
|
||||
.forEach(listView.getSelectionModel()::clearSelection);
|
||||
} else {
|
||||
listView.getSelectionModel().selectRange(lastShiftClickIndex.get(), currentIndex);
|
||||
listView.getSelectionModel().select(currentIndex);
|
||||
}
|
||||
lastShiftClickIndex.set(-1);
|
||||
} else {
|
||||
lastShiftClickIndex.set(currentIndex);
|
||||
listView.getSelectionModel().select(currentIndex);
|
||||
}
|
||||
} else {
|
||||
toggleSelect.accept(cell.getIndex());
|
||||
}
|
||||
cell.requestFocus();
|
||||
mouseEvent.consume();
|
||||
}
|
||||
}
|
||||
|
||||
BIN
HMCL/src/main/resources/assets/img/unknown_pack.png
Normal file
BIN
HMCL/src/main/resources/assets/img/unknown_pack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -33,13 +33,17 @@ import org.jackhuang.hmcl.util.io.Unzipper;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class Datapack {
|
||||
private boolean isMultiple;
|
||||
private static final String DISABLED_EXT = "disabled";
|
||||
private static final String ZIP_EXT = "zip";
|
||||
|
||||
private final Path path;
|
||||
private final ObservableList<Pack> info = FXCollections.observableArrayList();
|
||||
private final ObservableList<Pack> packs = FXCollections.observableArrayList();
|
||||
|
||||
public Datapack(Path path) {
|
||||
this.path = path;
|
||||
@@ -49,96 +53,104 @@ public class Datapack {
|
||||
return path;
|
||||
}
|
||||
|
||||
public ObservableList<Pack> getInfo() {
|
||||
return info;
|
||||
public ObservableList<Pack> getPacks() {
|
||||
return packs;
|
||||
}
|
||||
|
||||
public void installTo(Path worldPath) throws IOException {
|
||||
Path datapacks = worldPath.resolve("datapacks");
|
||||
|
||||
public static void installPack(Path sourceDatapackPath, Path targetDatapackDirectory) throws IOException {
|
||||
boolean containsMultiplePacks;
|
||||
Set<String> packs = new HashSet<>();
|
||||
for (Pack pack : info) packs.add(pack.getId());
|
||||
|
||||
if (Files.isDirectory(datapacks)) {
|
||||
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(datapacks)) {
|
||||
for (Path datapack : directoryStream) {
|
||||
if (Files.isDirectory(datapack) && packs.contains(FileUtils.getName(datapack)))
|
||||
FileUtils.deleteDirectory(datapack);
|
||||
else if (Files.isRegularFile(datapack) && packs.contains(FileUtils.getNameWithoutExtension(datapack)))
|
||||
Files.delete(datapack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
new Unzipper(path, worldPath)
|
||||
.setReplaceExistentFile(true)
|
||||
.setFilter(new Unzipper.FileFilter() {
|
||||
@Override
|
||||
public boolean accept(Path destPath, boolean isDirectory, Path zipEntry, String entryPath) {
|
||||
// We will merge resources.zip instead of replacement.
|
||||
return !entryPath.equals("resources.zip");
|
||||
}
|
||||
})
|
||||
.unzip();
|
||||
|
||||
try (FileSystem dest = CompressingUtils.createWritableZipFileSystem(worldPath.resolve("resources.zip"));
|
||||
FileSystem zip = CompressingUtils.createReadOnlyZipFileSystem(path)) {
|
||||
Path resourcesZip = zip.getPath("resources.zip");
|
||||
if (Files.isRegularFile(resourcesZip)) {
|
||||
Path temp = Files.createTempFile("hmcl", ".zip");
|
||||
Files.copy(resourcesZip, temp, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (FileSystem resources = CompressingUtils.createReadOnlyZipFileSystem(temp)) {
|
||||
FileUtils.copyDirectory(resources.getPath("/"), dest.getPath("/"));
|
||||
}
|
||||
}
|
||||
Path packMcMeta = dest.getPath("pack.mcmeta");
|
||||
Files.write(packMcMeta, Arrays.asList("{",
|
||||
"\t\"pack\": {",
|
||||
"\t\t\"pack_format\": 4,",
|
||||
"\t\t\"description\": \"Modified by HMCL.\"",
|
||||
"\t}",
|
||||
"}"), StandardOpenOption.CREATE);
|
||||
|
||||
|
||||
Path packPng = dest.getPath("pack.png");
|
||||
if (Files.isRegularFile(packPng))
|
||||
Files.delete(packPng);
|
||||
}
|
||||
} else {
|
||||
FileUtils.copyFile(path, datapacks.resolve(FileUtils.getName(path)));
|
||||
}
|
||||
}
|
||||
|
||||
public void deletePack(Pack pack) throws IOException {
|
||||
Path subPath = pack.file;
|
||||
if (Files.isDirectory(subPath))
|
||||
FileUtils.deleteDirectory(subPath);
|
||||
else if (Files.isRegularFile(subPath))
|
||||
Files.delete(subPath);
|
||||
|
||||
Platform.runLater(() -> info.removeIf(p -> p.getId().equals(pack.getId())));
|
||||
}
|
||||
|
||||
public void loadFromZip() throws IOException {
|
||||
try (FileSystem fs = CompressingUtils.readonly(path).setAutoDetectEncoding(true).build()) {
|
||||
Path datapacks = fs.getPath("/datapacks/");
|
||||
try (FileSystem fs = CompressingUtils.readonly(sourceDatapackPath).setAutoDetectEncoding(true).build()) {
|
||||
Path datapacks = fs.getPath("datapacks");
|
||||
Path mcmeta = fs.getPath("pack.mcmeta");
|
||||
if (Files.exists(datapacks)) { // multiple datapacks
|
||||
isMultiple = true;
|
||||
loadFromDir(datapacks);
|
||||
} else if (Files.exists(mcmeta)) { // single datapack
|
||||
isMultiple = false;
|
||||
try {
|
||||
PackMcMeta pack = JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class);
|
||||
Platform.runLater(() -> info.add(new Pack(path, FileUtils.getNameWithoutExtension(path), pack.getPackInfo().getDescription(), this)));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read datapack " + path, e);
|
||||
}
|
||||
|
||||
if (Files.exists(datapacks)) {
|
||||
containsMultiplePacks = true;
|
||||
} else if (Files.exists(mcmeta)) {
|
||||
containsMultiplePacks = false;
|
||||
} else {
|
||||
throw new IOException("Malformed datapack zip");
|
||||
}
|
||||
|
||||
if (containsMultiplePacks) {
|
||||
try (Stream<Path> s = Files.list(datapacks)) {
|
||||
packs = s.map(FileUtils::getNameWithoutExtension).collect(Collectors.toSet());
|
||||
}
|
||||
} else {
|
||||
packs.add(FileUtils.getNameWithoutExtension(sourceDatapackPath));
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(targetDatapackDirectory)) {
|
||||
for (Path dir : stream) {
|
||||
String packName = FileUtils.getName(dir);
|
||||
if (FileUtils.getExtension(dir).equals(DISABLED_EXT)) {
|
||||
packName = StringUtils.removeSuffix(packName, "." + DISABLED_EXT);
|
||||
}
|
||||
packName = FileUtils.getNameWithoutExtension(packName);
|
||||
if (packs.contains(packName)) {
|
||||
if (Files.isDirectory(dir)) {
|
||||
FileUtils.deleteDirectory(dir);
|
||||
} else if (Files.isRegularFile(dir)) {
|
||||
Files.delete(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!containsMultiplePacks) {
|
||||
FileUtils.copyFile(sourceDatapackPath, targetDatapackDirectory.resolve(FileUtils.getName(sourceDatapackPath)));
|
||||
} else {
|
||||
new Unzipper(sourceDatapackPath, targetDatapackDirectory)
|
||||
.setReplaceExistentFile(true)
|
||||
.setSubDirectory("/datapacks/")
|
||||
.unzip();
|
||||
|
||||
try (FileSystem outputResourcesZipFS = CompressingUtils.createWritableZipFileSystem(targetDatapackDirectory.getParent().resolve("resources.zip"));
|
||||
FileSystem inputPackZipFS = CompressingUtils.createReadOnlyZipFileSystem(sourceDatapackPath)) {
|
||||
Path resourcesZip = inputPackZipFS.getPath("resources.zip");
|
||||
if (Files.isRegularFile(resourcesZip)) {
|
||||
Path tempResourcesFile = Files.createTempFile("hmcl", ".zip");
|
||||
try {
|
||||
Files.copy(resourcesZip, tempResourcesFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (FileSystem resources = CompressingUtils.createReadOnlyZipFileSystem(tempResourcesFile)) {
|
||||
FileUtils.copyDirectory(resources.getPath("/"), outputResourcesZipFS.getPath("/"));
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tempResourcesFile);
|
||||
}
|
||||
}
|
||||
Path packMcMeta = outputResourcesZipFS.getPath("pack.mcmeta");
|
||||
String metaContent = """
|
||||
{
|
||||
"pack": {
|
||||
"pack_format": 4,
|
||||
"description": "Modified by HMCL."
|
||||
}
|
||||
}
|
||||
""";
|
||||
Files.writeString(packMcMeta, metaContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
Path packPng = outputResourcesZipFS.getPath("pack.png");
|
||||
if (Files.isRegularFile(packPng))
|
||||
Files.delete(packPng);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void installPack(Path sourcePackPath) throws IOException {
|
||||
installPack(sourcePackPath, path);
|
||||
loadFromDir();
|
||||
}
|
||||
|
||||
public void deletePack(Pack packToDelete) throws IOException {
|
||||
Path pathToDelete = packToDelete.path;
|
||||
if (Files.isDirectory(pathToDelete))
|
||||
FileUtils.deleteDirectory(pathToDelete);
|
||||
else if (Files.isRegularFile(pathToDelete))
|
||||
Files.delete(pathToDelete);
|
||||
|
||||
Platform.runLater(() -> packs.removeIf(p -> p.getId().equals(packToDelete.getId())));
|
||||
}
|
||||
|
||||
public void loadFromDir() {
|
||||
@@ -150,86 +162,134 @@ public class Datapack {
|
||||
}
|
||||
|
||||
private void loadFromDir(Path dir) throws IOException {
|
||||
List<Pack> info = new ArrayList<>();
|
||||
List<Pack> discoveredPacks;
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
discoveredPacks = stream
|
||||
.parallel()
|
||||
.map(this::loadSinglePackFromPath)
|
||||
.flatMap(Optional::stream)
|
||||
.sorted(Comparator.comparing(Pack::getId, String.CASE_INSENSITIVE_ORDER))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
Platform.runLater(() -> this.packs.setAll(discoveredPacks));
|
||||
}
|
||||
|
||||
if (Files.isDirectory(dir)) {
|
||||
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(dir)) {
|
||||
for (Path subDir : directoryStream) {
|
||||
if (Files.isDirectory(subDir)) {
|
||||
Path mcmeta = subDir.resolve("pack.mcmeta");
|
||||
Path mcmetaDisabled = subDir.resolve("pack.mcmeta.disabled");
|
||||
private Optional<Pack> loadSinglePackFromPath(Path path) {
|
||||
if (Files.isDirectory(path)) {
|
||||
return loadSinglePackFromDirectory(path);
|
||||
} else if (Files.isRegularFile(path)) {
|
||||
return loadSinglePackFromZipFile(path);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled))
|
||||
continue;
|
||||
private Optional<Pack> loadSinglePackFromDirectory(Path path) {
|
||||
Path mcmeta = path.resolve("pack.mcmeta");
|
||||
Path mcmetaDisabled = path.resolve("pack.mcmeta.disabled");
|
||||
|
||||
boolean enabled = Files.exists(mcmeta);
|
||||
|
||||
try {
|
||||
PackMcMeta pack = enabled ? JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class)
|
||||
: JsonUtils.fromNonNullJson(Files.readString(mcmetaDisabled), PackMcMeta.class);
|
||||
info.add(new Pack(enabled ? mcmeta : mcmetaDisabled, FileUtils.getName(subDir), pack.getPackInfo().getDescription(), this));
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to read datapack " + subDir, e);
|
||||
}
|
||||
} else if (Files.isRegularFile(subDir)) {
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(subDir)) {
|
||||
Path mcmeta = fs.getPath("pack.mcmeta");
|
||||
|
||||
if (!Files.exists(mcmeta))
|
||||
continue;
|
||||
|
||||
String name = FileUtils.getName(subDir);
|
||||
if (name.endsWith(".disabled")) {
|
||||
name = name.substring(0, name.length() - ".disabled".length());
|
||||
}
|
||||
if (!name.endsWith(".zip"))
|
||||
continue;
|
||||
name = StringUtils.substringBeforeLast(name, ".zip");
|
||||
|
||||
PackMcMeta pack = JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class);
|
||||
info.add(new Pack(subDir, name, pack.getPackInfo().getDescription(), this));
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to read datapack " + subDir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Platform.runLater(() -> this.info.setAll(info));
|
||||
Path targetPath = Files.exists(mcmeta) ? mcmeta : mcmetaDisabled;
|
||||
return parsePack(path, true, FileUtils.getNameWithoutExtension(path), targetPath);
|
||||
}
|
||||
|
||||
private Optional<Pack> loadSinglePackFromZipFile(Path path) {
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(path)) {
|
||||
Path mcmeta = fs.getPath("pack.mcmeta");
|
||||
|
||||
if (!Files.exists(mcmeta)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String packName = FileUtils.getName(path);
|
||||
if (FileUtils.getExtension(path).equals(DISABLED_EXT)) {
|
||||
packName = FileUtils.getNameWithoutExtension(packName);
|
||||
}
|
||||
packName = FileUtils.getNameWithoutExtension(packName);
|
||||
|
||||
return parsePack(path, false, packName, mcmeta);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("IO error reading " + path, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Pack> parsePack(Path datapackPath, boolean isDirectory, String name, Path mcmetaPath) {
|
||||
try {
|
||||
PackMcMeta mcMeta = JsonUtils.fromNonNullJson(Files.readString(mcmetaPath), PackMcMeta.class);
|
||||
return Optional.of(new Pack(datapackPath, isDirectory, name, mcMeta.getPackInfo().getDescription(), this));
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Invalid pack.mcmeta format in " + datapackPath, e);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("IO error reading " + datapackPath, e);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static class Pack {
|
||||
private Path file;
|
||||
private final BooleanProperty active;
|
||||
private Path path;
|
||||
private final boolean isDirectory;
|
||||
private Path statusFile;
|
||||
private final BooleanProperty activeProperty;
|
||||
private final String id;
|
||||
private final LocalModFile.Description description;
|
||||
private final Datapack datapack;
|
||||
private final Datapack parentDatapack;
|
||||
|
||||
public Pack(Path file, String id, LocalModFile.Description description, Datapack datapack) {
|
||||
this.file = file;
|
||||
public Pack(Path path, boolean isDirectory, String id, LocalModFile.Description description, Datapack parentDatapack) {
|
||||
this.path = path;
|
||||
this.isDirectory = isDirectory;
|
||||
this.id = id;
|
||||
this.description = description;
|
||||
this.datapack = datapack;
|
||||
this.parentDatapack = parentDatapack;
|
||||
|
||||
active = new SimpleBooleanProperty(this, "active", !DISABLED_EXT.equals(FileUtils.getExtension(file))) {
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
Path f = Pack.this.file.toAbsolutePath(), newF;
|
||||
if (DISABLED_EXT.equals(FileUtils.getExtension(f)))
|
||||
newF = f.getParent().resolve(FileUtils.getNameWithoutExtension(f));
|
||||
else
|
||||
newF = f.getParent().resolve(FileUtils.getName(f) + "." + DISABLED_EXT);
|
||||
this.statusFile = initializeStatusFile(path, isDirectory);
|
||||
this.activeProperty = initializeActiveProperty();
|
||||
}
|
||||
|
||||
try {
|
||||
Files.move(f, newF);
|
||||
Pack.this.file = newF;
|
||||
} catch (IOException e) {
|
||||
// Mod file is occupied.
|
||||
LOG.warning("Unable to rename file " + f + " to " + newF);
|
||||
}
|
||||
private Path initializeStatusFile(Path path, boolean isDirectory) {
|
||||
if (isDirectory) {
|
||||
Path mcmeta = path.resolve("pack.mcmeta");
|
||||
return Files.exists(mcmeta) ? mcmeta : path.resolve("pack.mcmeta.disabled");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private BooleanProperty initializeActiveProperty() {
|
||||
BooleanProperty property = new SimpleBooleanProperty(this, "active", !FileUtils.getExtension(this.statusFile).equals(DISABLED_EXT));
|
||||
property.addListener((obs, wasActive, isNowActive) -> {
|
||||
if (wasActive != isNowActive) {
|
||||
handleFileRename(isNowActive);
|
||||
}
|
||||
};
|
||||
});
|
||||
return property;
|
||||
}
|
||||
|
||||
private void handleFileRename(boolean isNowActive) {
|
||||
Path newStatusFile = calculateNewStatusFilePath(isNowActive);
|
||||
if (statusFile.equals(newStatusFile)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.move(this.statusFile, newStatusFile);
|
||||
this.statusFile = newStatusFile;
|
||||
if (!this.isDirectory) {
|
||||
this.path = newStatusFile;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to rename file from " + this.statusFile + " to " + newStatusFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Path calculateNewStatusFilePath(boolean isActive) {
|
||||
boolean isFileDisabled = DISABLED_EXT.equals(FileUtils.getExtension(this.statusFile));
|
||||
if (isActive && isFileDisabled) {
|
||||
return this.statusFile.getParent().resolve(FileUtils.getNameWithoutExtension(this.statusFile));
|
||||
} else if (!isActive && !isFileDisabled) {
|
||||
return this.statusFile.getParent().resolve(FileUtils.getName(this.statusFile) + "." + DISABLED_EXT);
|
||||
}
|
||||
return this.statusFile;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -240,23 +300,29 @@ public class Datapack {
|
||||
return description;
|
||||
}
|
||||
|
||||
public Datapack getDatapack() {
|
||||
return datapack;
|
||||
public Datapack getParentDatapack() {
|
||||
return parentDatapack;
|
||||
}
|
||||
|
||||
public BooleanProperty activeProperty() {
|
||||
return active;
|
||||
return activeProperty;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active.get();
|
||||
return activeProperty.get();
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active.set(active);
|
||||
this.activeProperty.set(active);
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public boolean isDirectory() {
|
||||
return isDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static final String DISABLED_EXT = "disabled";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user