diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index bc3221973..533927d60 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -50,6 +50,7 @@ public enum SVG { CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"), CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"), + CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"), DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"), DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"), DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"), @@ -91,6 +92,7 @@ public enum SVG { RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"), + SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"), SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"), SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java new file mode 100644 index 000000000..e4f26dff9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java @@ -0,0 +1,618 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.schematic.LitematicFile; +import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Theme; +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.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; +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; + +/** + * @author Glavo + */ +public final class SchematicsPage extends ListPageBase implements VersionPage.VersionLoadable { + + private static String translateAuthorName(String author) { + if (I18n.isUseChinese() && "hsds".equals(author)) { + return "黑山大叔"; + } + return author; + } + + private Path schematicsDirectory; + private DirItem currentDirectory; + + public SchematicsPage() { + FXUtils.applyDragListener(this, + file -> currentDirectory != null && file.isFile() && file.getName().endsWith(".litematic"), + files -> addFiles(files.stream().map(File::toPath).collect(Collectors.toList())) + ); + } + + @Override + protected Skin createDefaultSkin() { + return new SchematicsPageSkin(); + } + + @Override + public void loadVersion(Profile profile, String version) { + this.schematicsDirectory = profile.getRepository().getSchematicsDirectory(version); + + refresh(); + } + + public void refresh() { + Path schematicsDirectory = this.schematicsDirectory; + if (schematicsDirectory == null) return; + + setLoading(true); + Task.supplyAsync(() -> loadAll(schematicsDirectory, null)) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + setLoading(false); + if (exception == null) { + DirItem target = result; + if (currentDirectory != null) { + loop: + for (int i = 0; i < currentDirectory.relativePath.size(); i++) { + String dirName = currentDirectory.relativePath.get(i); + + for (Item child : target.children) { + if (child instanceof DirItem && child.getName().equals(dirName)) { + target = (DirItem) child; + continue loop; + } + } + break; + } + } + + navigateTo(target); + } else { + LOG.warning("Failed to load schematics", exception); + } + }).start(); + } + + public void addFiles(List files) { + if (currentDirectory == null) + return; + + Path dir = currentDirectory.path; + try { + // Can be executed in the background, but be careful that users can call loadVersion during this time + Files.createDirectories(dir); + for (Path file : files) { + Files.copy(file, dir.resolve(file.getFileName())); + } + refresh(); + } catch (FileAlreadyExistsException ignored) { + } catch (IOException e) { + Controllers.dialog(i18n("schematics.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + LOG.warning("Failed to add schematics to " + dir, e); + } + } + + public void onAddFiles() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("schematics.add")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter( + i18n("schematics"), "*.litematic")); + List files = fileChooser.showOpenMultipleDialog(Controllers.getStage()); + if (files != null && !files.isEmpty()) { + addFiles(files.stream().map(File::toPath).collect(Collectors.toList())); + } + } + + public void onCreateDirectory() { + if (currentDirectory == null) + return; + + Path parent = currentDirectory.path; + Controllers.dialog(new InputDialogPane( + i18n("schematics.create_directory.prompt"), + "", + (result, resolve, reject) -> { + if (StringUtils.isBlank(result)) { + reject.accept(i18n("schematics.create_directory.failed.empty_name")); + return; + } + + if (result.contains("/") || result.contains("\\") || !OperatingSystem.isNameValid(result)) { + reject.accept(i18n("schematics.create_directory.failed.invalid_name")); + return; + } + + Path targetDir = parent.resolve(result); + if (Files.exists(targetDir)) { + reject.accept(i18n("schematics.create_directory.failed.already_exists")); + return; + } + + try { + Files.createDirectories(targetDir); + resolve.run(); + refresh(); + } catch (IOException e) { + LOG.warning("Failed to create directory: " + targetDir, e); + reject.accept(i18n("schematics.create_directory.failed", targetDir)); + } + })); + } + + private DirItem loadAll(Path dir, @Nullable DirItem parent) { + DirItem item = new DirItem(dir, parent); + + try (Stream stream = Files.list(dir)) { + for (Path path : Lang.toIterable(stream)) { + if (Files.isDirectory(path)) { + item.children.add(loadAll(path, item)); + } else if (path.getFileName().toString().endsWith(".litematic") && Files.isRegularFile(path)) { + try { + item.children.add(new LitematicFileItem(LitematicFile.load(path))); + } catch (IOException e) { + LOG.warning("Failed to load litematic file: " + path, e); + } + } + } + } catch (IOException e) { + LOG.warning("Failed to load schematics in " + dir, e); + } + + item.children.sort(Comparator.naturalOrder()); + return item; + } + + private void navigateTo(DirItem item) { + currentDirectory = item; + getItems().clear(); + if (item.parent != null) { + getItems().add(new BackItem(item.parent)); + } + getItems().addAll(item.children); + } + + abstract class Item extends Control implements Comparable { + + boolean isDirectory() { + return this instanceof DirItem; + } + + abstract Path getPath(); + + abstract String getName(); + + abstract String getDescription(); + + abstract SVG getIcon(); + + Node getIcon(int size) { + StackPane icon = new StackPane(); + icon.setPrefSize(size, size); + icon.setMaxSize(size, size); + icon.getChildren().add(getIcon().createIcon(Theme.blackFill(), size)); + return icon; + } + + abstract int order(); + + abstract void onClick(); + + abstract void onReveal(); + + abstract void onDelete(); + + @Override + public int compareTo(@NotNull SchematicsPage.Item o) { + if (this.order() != o.order()) + return Integer.compare(this.order(), o.order()); + + return this.getName().compareTo(o.getName()); + } + + @Override + protected Skin createDefaultSkin() { + return new ItemSkin(this); + } + } + + private final class BackItem extends Item { + + private final DirItem parent; + + BackItem(DirItem parent) { + this.parent = parent; + } + + @Override + int order() { + return 0; + } + + @Override + Path getPath() { + return null; + } + + @Override + String getName() { + return ".."; + } + + @Override + String getDescription() { + return i18n("schematics.back_to", parent.getName()); + } + + @Override + SVG getIcon() { + return SVG.FOLDER; + } + + @Override + void onClick() { + navigateTo(parent); + } + + @Override + void onReveal() { + throw new UnsupportedOperationException("Unreachable"); + } + + @Override + void onDelete() { + throw new UnsupportedOperationException("Unreachable"); + } + } + + private final class DirItem extends Item { + final Path path; + final @Nullable DirItem parent; + final List children = new ArrayList<>(); + final List relativePath; + + DirItem(Path path, @Nullable DirItem parent) { + this.path = path; + this.parent = parent; + + if (parent != null) { + this.relativePath = new ArrayList<>(parent.relativePath); + relativePath.add(path.getFileName().toString()); + } else { + this.relativePath = Collections.emptyList(); + } + } + + @Override + int order() { + return 1; + } + + @Override + Path getPath() { + return path; + } + + @Override + public String getName() { + return path.getFileName().toString(); + } + + @Override + String getDescription() { + return i18n("schematics.sub_items", children.size()); + } + + @Override + SVG getIcon() { + return SVG.FOLDER; + } + + @Override + void onClick() { + navigateTo(this); + } + + @Override + void onReveal() { + FXUtils.openFolder(path.toFile()); + } + + @Override + void onDelete() { + try { + FileUtils.cleanDirectory(path.toFile()); + Files.deleteIfExists(path); + refresh(); + } catch (IOException e) { + LOG.warning("Failed to delete directory: " + path, e); + } + } + } + + private final class LitematicFileItem extends Item { + final LitematicFile file; + final String name; + final Image image; + + private LitematicFileItem(LitematicFile file) { + this.file = file; + + String name = file.getName(); + if (name != null && !"Unnamed".equals(name)) { + this.name = name; + } else { + this.name = StringUtils.removeSuffix(file.getFile().getFileName().toString(), ".litematic"); + } + + WritableImage image = null; + int[] previewImageData = file.getPreviewImageData(); + if (previewImageData != null && previewImageData.length > 0) { + int size = (int) Math.sqrt(previewImageData.length); + if ((size * size) == previewImageData.length) { + image = new WritableImage(size, size); + PixelWriter pixelWriter = image.getPixelWriter(); + + for (int y = 0, i = 0; y < size; ++y) { + for (int x = 0; x < size; ++x) { + pixelWriter.setArgb(x, y, previewImageData[i++]); + } + } + + } + } + this.image = image; + } + + @Override + int order() { + return 2; + } + + @Override + Path getPath() { + return file.getFile(); + } + + @Override + public String getName() { + return name; + } + + @Override + String getDescription() { + return file.getFile().getFileName().toString(); + } + + @Override + SVG getIcon() { + return SVG.SCHEMA; + } + + Node getIcon(int size) { + if (image == null) { + return super.getIcon(size); + } else { + ImageView imageView = new ImageView(); + imageView.setFitHeight(size); + imageView.setFitWidth(size); + imageView.setImage(image); + return imageView; + } + } + + @Override + void onClick() { + Controllers.dialog(new LitematicInfoDialog()); + } + + @Override + void onReveal() { + FXUtils.showFileInExplorer(file.getFile()); + } + + @Override + void onDelete() { + try { + Files.deleteIfExists(file.getFile()); + refresh(); + } catch (IOException e) { + LOG.warning("Failed to delete litematic file: " + file.getFile(), e); + } + } + + private final class LitematicInfoDialog extends JFXDialogLayout { + private final ComponentList details; + + private void addDetailItem(String key, Object detail) { + BorderPane borderPane = new BorderPane(); + borderPane.setLeft(new Label(key)); + borderPane.setRight(new Label(detail.toString())); + details.getContent().add(borderPane); + } + + private void updateContent(LitematicFile file) { + details.getContent().clear(); + addDetailItem(i18n("schematics.info.name"), file.getName()); + if (StringUtils.isNotBlank(file.getAuthor())) + addDetailItem(i18n("schematics.info.schematic_author"), translateAuthorName(file.getAuthor())); + if (file.getTimeCreated() != null) + addDetailItem(i18n("schematics.info.time_created"), I18n.formatDateTime(file.getTimeCreated())); + if (file.getTimeModified() != null && !file.getTimeModified().equals(file.getTimeCreated())) + addDetailItem(i18n("schematics.info.time_modified"), I18n.formatDateTime(file.getTimeModified())); + if (file.getRegionCount() > 0) + addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount())); + if (file.getTotalVolume() > 0) + addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume()); + if (file.getTotalBlocks() > 0) + addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks()); + if (file.getEnclosingSize() != null) + addDetailItem(i18n("schematics.info.enclosing_size"), + String.format("%d x %d x %d", (int) file.getEnclosingSize().getX(), + (int) file.getEnclosingSize().getY(), + (int) file.getEnclosingSize().getZ())); + + addDetailItem(i18n("schematics.info.version"), file.getVersion()); + } + + LitematicInfoDialog() { + HBox titleBox = new HBox(8); + { + Node icon = getIcon(40); + + TwoLineListItem title = new TwoLineListItem(); + title.setTitle(getName()); + title.setSubtitle(file.getFile().getFileName().toString()); + + titleBox.getChildren().setAll(icon, title); + setHeading(titleBox); + } + + { + this.details = new ComponentList(); + StackPane detailsContainer = new StackPane(); + detailsContainer.setPadding(new Insets(10, 0, 0, 0)); + detailsContainer.getChildren().add(details); + setBody(detailsContainer); + } + + { + 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); + } + + updateContent(file); + } + } + } + + private static final class ItemSkin extends SkinBase { + public ItemSkin(Item item) { + super(item); + + BorderPane root = new BorderPane(); + root.getStyleClass().add("md-list-cell"); + root.setPadding(new Insets(8)); + + { + StackPane left = new StackPane(); + left.setMaxSize(32, 32); + left.setPrefSize(32, 32); + left.getChildren().add(item.getIcon(24)); + left.setPadding(new Insets(0, 8, 0, 0)); + + Path path = item.getPath(); + if (path != null) { + FXUtils.installSlowTooltip(left, path.toAbsolutePath().normalize().toString()); + } + + BorderPane.setAlignment(left, Pos.CENTER); + root.setLeft(left); + } + + { + TwoLineListItem center = new TwoLineListItem(); + center.setTitle(item.getName()); + center.setSubtitle(item.getDescription()); + + root.setCenter(center); + } + + if (!(item instanceof BackItem)) { + HBox right = new HBox(8); + right.setAlignment(Pos.CENTER_RIGHT); + + JFXButton btnReveal = new JFXButton(); + FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_explorer")); + btnReveal.getStyleClass().add("toggle-icon4"); + btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), -1)); + btnReveal.setOnAction(event -> item.onReveal()); + + JFXButton btnDelete = new JFXButton(); + btnDelete.getStyleClass().add("toggle-icon4"); + btnDelete.setGraphic(SVG.DELETE_FOREVER.createIcon(Theme.blackFill(), -1)); + btnDelete.setOnAction(event -> + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), + item::onDelete, null)); + + right.getChildren().setAll(btnReveal, btnDelete); + root.setRight(right); + } + + RipplerContainer container = new RipplerContainer(root); + FXUtils.onClicked(container, item::onClick); + this.getChildren().add(container); + } + } + + private final class SchematicsPageSkin extends ToolbarListPageSkin { + SchematicsPageSkin() { + super(SchematicsPage.this); + } + + @Override + protected List initializeToolbar(SchematicsPage skinnable) { + return Arrays.asList( + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), + createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles), + createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory) + ); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java index f28108ae8..31e86d286 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java @@ -59,6 +59,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage private final TabHeader.Tab installerListTab = new TabHeader.Tab<>("installerListTab"); private final TabHeader.Tab modListTab = new TabHeader.Tab<>("modListTab"); private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); + private final TabHeader.Tab schematicsTab = new TabHeader.Tab<>("schematicsTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty(); private final ObjectProperty version = new SimpleObjectProperty<>(); @@ -71,8 +72,9 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage installerListTab.setNodeSupplier(loadVersionFor(InstallerListPage::new)); modListTab.setNodeSupplier(loadVersionFor(ModListPage::new)); worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new)); + schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new)); - tab = new TabHeader(versionSettingsTab, installerListTab, modListTab, worldListTab); + tab = new TabHeader(versionSettingsTab, installerListTab, modListTab, worldListTab, schematicsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -135,6 +137,8 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage modListTab.getNode().loadVersion(profile, version); if (worldListTab.isInitialized()) worldListTab.getNode().loadVersion(profile, version); + if (schematicsTab.isInitialized()) + schematicsTab.getNode().loadVersion(profile, version); currentVersionUpgradable.set(profile.getRepository().isModpack(version)); } @@ -271,11 +275,20 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage worldListItem.activeProperty().bind(control.tab.getSelectionModel().selectedItemProperty().isEqualTo(control.worldListTab)); worldListItem.setOnAction(e -> control.tab.select(control.worldListTab)); + AdvancedListItem schematicsListItem = new AdvancedListItem(); + schematicsListItem.getStyleClass().add("navigation-drawer-item"); + schematicsListItem.setTitle(i18n("schematics.manage")); + schematicsListItem.setLeftGraphic(wrap(SVG.SCHEMA)); + schematicsListItem.setActionButtonVisible(false); + schematicsListItem.activeProperty().bind(control.tab.getSelectionModel().selectedItemProperty().isEqualTo(control.schematicsTab)); + schematicsListItem.setOnAction(e -> control.tab.select(control.schematicsTab)); + AdvancedListBox sideBar = new AdvancedListBox() .add(versionSettingsItem) .add(installerListItem) .add(modListItem) - .add(worldListItem); + .add(worldListItem) + .add(schematicsListItem); VBox.setVgrow(sideBar, Priority.ALWAYS); PopupMenu browseList = new PopupMenu(); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index c855df33f..a2b48c032 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1150,6 +1150,31 @@ repositories.chooser.title=Choose download source for JavaFX resourcepack=Resource Packs resourcepack.download.title=Download Resource Pack - %1s +reveal.in_explorer=Reveal in File Manager + +schematics=Schematics +schematics.add=Add Schematic Files +schematics.add.failed=Failed to add schematic files +schematics.back_to=Back to "%s" +schematics.create_directory=Create Directory +schematics.create_directory.prompt=Please enter the new directory name +schematics.create_directory.failed=Failed to create directory +schematics.create_directory.failed.already_exists=Directory already exists +schematics.create_directory.failed.empty_name=Name cannot be empty +schematics.create_directory.failed.invalid_name=Name contains invalid characters +schematics.info.description=Description +schematics.info.enclosing_size=Enclosing Size +schematics.info.name=Name +schematics.info.region_count=Regions +schematics.info.schematic_author=Author +schematics.info.time_created=Created Time +schematics.info.time_modified=Modified Time +schematics.info.total_blocks=Total Blocks +schematics.info.total_volume=Total Volume +schematics.info.version=Schematic Version +schematics.manage=Schematics +schematics.sub_items=%d sub-items + search=Search search.hint.chinese=Search in English and Chinese search.hint.english=Search in English only diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index ea4bdc85e..ac6b5f7f0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -947,6 +947,31 @@ repositories.chooser.title=選取 JavaFX 下載源 resourcepack=資源包 resourcepack.download.title=資源包下載 - %1s +reveal.in_explorer=在檔案管理員中查看 + +schematics=原理圖 +schematics.add=添加原理圖 +schematics.add.failed=添加原理圖失敗 +schematics.back_to=返回到「%s」 +schematics.create_directory=建立目錄 +schematics.create_directory.prompt=請輸入新目錄名稱 +schematics.create_directory.failed=建立目錄失敗 +schematics.create_directory.failed.already_exists=目錄已存在 +schematics.create_directory.failed.empty_name=名稱不能為空 +schematics.create_directory.failed.invalid_name=名稱中包含非法字元 +schematics.info.description=描述 +schematics.info.enclosing_size=框大小 +schematics.info.name=名稱 +schematics.info.region_count=區域數量 +schematics.info.schematic_author=作者 +schematics.info.time_created=建立時間 +schematics.info.time_modified=修改時間 +schematics.info.total_blocks=總方塊數 +schematics.info.total_volume=總體積 +schematics.info.version=原理圖版本 +schematics.manage=原理圖管理 +schematics.sub_items=%d 個子項 + search=搜尋 search.hint.chinese=支援中英文搜尋 search.hint.english=僅支援英文搜尋 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index a60e3892a..a5f6676dc 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -957,6 +957,31 @@ repositories.chooser.title=选择 JavaFX 下载源 resourcepack=资源包 resourcepack.download.title=资源包下载 - %1s +reveal.in_explorer=在文件管理器中查看 + +schematics=原理图 +schematics.add=添加原理图 +schematics.add.failed=添加原理图失败 +schematics.back_to=返回到“%s” +schematics.create_directory=创建文件夹 +schematics.create_directory.prompt=请输入新文件夹名称 +schematics.create_directory.failed=创建文件夹失败 +schematics.create_directory.failed.already_exists=文件夹已存在 +schematics.create_directory.failed.empty_name=名称不能为空 +schematics.create_directory.failed.invalid_name=名称中包含非法字符 +schematics.info.description=描述 +schematics.info.enclosing_size=包围盒尺寸 +schematics.info.name=名称 +schematics.info.region_count=区域数量 +schematics.info.schematic_author=作者 +schematics.info.time_created=创建时间 +schematics.info.time_modified=修改时间 +schematics.info.total_blocks=总方块数 +schematics.info.total_volume=总体积 +schematics.info.version=原理图版本 +schematics.manage=原理图管理 +schematics.sub_items=%d 个子项 + search=搜索 search.hint.chinese=支持中英文搜索 search.hint.english=仅支持英文搜索 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 91c7e0adb..98ce5eb69 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -526,6 +526,10 @@ public class DefaultGameRepository implements GameRepository { return getRunDirectory(id).toPath().resolve("backups"); } + public Path getSchematicsDirectory(String id) { + return getRunDirectory(id).toPath().resolve("schematics"); + } + @Override public String toString() { return new ToStringBuilder(this) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java new file mode 100644 index 000000000..f76111c97 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/schematic/LitematicFile.java @@ -0,0 +1,163 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.schematic; + +import com.github.steveice10.opennbt.NBTIO; +import com.github.steveice10.opennbt.tag.builtin.*; +import javafx.geometry.Point3D; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.zip.GZIPInputStream; + +/** + * @author Glavo + * @see The Litematic file format + */ +public final class LitematicFile { + + private static int tryGetInt(Tag tag) { + return tag instanceof IntTag ? ((IntTag) tag).getValue() : 0; + } + + private static @Nullable Instant tryGetLongTimestamp(Tag tag) { + if (tag instanceof LongTag) { + return Instant.ofEpochMilli(((LongTag) tag).getValue()); + } + return null; + } + + private static @Nullable String tryGetString(Tag tag) { + return tag instanceof StringTag ? ((StringTag) tag).getValue() : null; + } + + public static LitematicFile load(Path file) throws IOException { + + CompoundTag root; + try (InputStream in = new GZIPInputStream(Files.newInputStream(file))) { + root = (CompoundTag) NBTIO.readTag(in); + } + + Tag versionTag = root.get("Version"); + if (versionTag == null) + throw new IOException("Version tag not found"); + else if (!(versionTag instanceof IntTag)) + throw new IOException("Version tag is not an integer"); + + + Tag metadataTag = root.get("Metadata"); + if (metadataTag == null) + throw new IOException("Metadata tag not found"); + else if (!(metadataTag instanceof CompoundTag)) + throw new IOException("Metadata tag is not a compound tag"); + + return new LitematicFile(file, root); + } + + private final @NotNull Path file; + private final @NotNull CompoundTag root; + + private LitematicFile(@NotNull Path file, @NotNull CompoundTag root) { + this.file = file; + this.root = root; + } + + private @NotNull CompoundTag getMetadata() { + return root.get("Metadata"); + } + + public @NotNull Path getFile() { + return file; + } + + public int getVersion() { + return root.get("Version").getValue(); + } + + public int getSubVersion() { + return tryGetInt(root.get("SubVersion")); + } + + public int getMinecraftDataVersion() { + return tryGetInt(root.get("MinecraftDataVersion")); + } + + public int[] getPreviewImageData() { + Tag previewImageData = getMetadata().get("PreviewImageData"); + if (previewImageData instanceof IntArrayTag) { + return ((IntArrayTag) previewImageData).getValue().clone(); + } else { + return null; + } + } + + public String getName() { + return tryGetString(getMetadata().get("Name")); + } + + public String getAuthor() { + return tryGetString(getMetadata().get("Author")); + } + + public String getDescription() { + return tryGetString(getMetadata().get("Description")); + } + + public Instant getTimeCreated() { + return tryGetLongTimestamp(getMetadata().get("TimeCreated")); + } + + public Instant getTimeModified() { + return tryGetLongTimestamp(getMetadata().get("TimeModified")); + } + + public int getTotalBlocks() { + return tryGetInt(getMetadata().get("TotalBlocks")); + } + + public int getTotalVolume() { + return tryGetInt(getMetadata().get("TotalVolume")); + } + + public Point3D getEnclosingSize() { + Tag enclosingSizeTag = getMetadata().get("EnclosingSize"); + if (enclosingSizeTag instanceof CompoundTag) { + CompoundTag list = (CompoundTag) enclosingSizeTag; + int x = tryGetInt(list.get("x")); + int y = tryGetInt(list.get("y")); + int z = tryGetInt(list.get("z")); + + if (x >= 0 && y >= 0 && z >= 0) + return new Point3D(x, y, z); + } + + return null; + } + + public int getRegionCount() { + Tag regions = root.get("Regions"); + if (regions instanceof CompoundTag) + return ((CompoundTag) regions).size(); + else return 0; + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/LitematicFileTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/LitematicFileTest.java new file mode 100644 index 000000000..1a42bfba9 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/schematic/LitematicFileTest.java @@ -0,0 +1,53 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.schematic; + +import javafx.geometry.Point3D; +import org.jackhuang.hmcl.game.CrashReportAnalyzerTest; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public final class LitematicFileTest { + private static LitematicFile load(String name) throws IOException, URISyntaxException { + URL resource = CrashReportAnalyzerTest.class.getResource(name); + if (resource == null) + throw new IOException("Resource not found: " + name); + return LitematicFile.load(Paths.get(resource.toURI())); + } + + @Test + public void test() throws Exception { + LitematicFile file = load("/schematics/test.litematic"); + assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", file.getName()); + assertEquals("hsds", file.getAuthor()); + assertEquals("", file.getDescription()); + assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeCreated()); + assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeModified()); + assertEquals(1334, file.getTotalBlocks()); + assertEquals(5746, file.getTotalVolume()); + assertEquals(new Point3D(17, 26, 13), file.getEnclosingSize()); + assertEquals(1, file.getRegionCount()); + } +} diff --git a/HMCLCore/src/test/resources/schematics/test.litematic b/HMCLCore/src/test/resources/schematics/test.litematic new file mode 100644 index 000000000..fa6b715e0 Binary files /dev/null and b/HMCLCore/src/test/resources/schematics/test.litematic differ