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 c7a99881db..12d0f936f8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -105,6 +105,7 @@ public enum SVG { SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"), SCHEMA_FILL("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Z"), 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"), + SCREENSHOT_MONITOR_FILL("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 19H16V21H8Z"), 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"), SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImageContainer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImageContainer.java index c9aa50c8c0..c2a96531e8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImageContainer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImageContainer.java @@ -64,6 +64,33 @@ public ImageContainer(double width, double height) { this.getChildren().setAll(imageView); } + public ImageContainer(Image image, double maxWidth, double maxHeight) { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + + double width = maxWidth; + double height = width / image.getWidth() * image.getHeight(); + if (height > maxHeight) { + height = maxHeight; + width = height / image.getHeight() * image.getWidth(); + } + + FXUtils.setLimitWidth(this, width); + FXUtils.setLimitHeight(this, height); + + imageView.setPreserveRatio(true); + FXUtils.limitSize(imageView, width, height); + StackPane.setAlignment(imageView, Pos.CENTER); + + clip.setWidth(width); + clip.setHeight(height); + updateCornerRadius(getCornerRadius()); + this.setClip(clip); + + setImage(image); + + this.getChildren().setAll(imageView); + } + private void updateCornerRadius(double radius) { clip.setArcWidth(radius); clip.setArcHeight(radius); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ScreenshotsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ScreenshotsPage.java new file mode 100644 index 0000000000..30af25b695 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ScreenshotsPage.java @@ -0,0 +1,390 @@ +package org.jackhuang.hmcl.ui.versions; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXListView; +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +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 org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.*; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.Collection; +import java.util.Comparator; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +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; + +public class ScreenshotsPage extends ListPageBase implements VersionPage.VersionLoadable, PageAware { + + private Path screenshotsDir; + + @Override + protected Skin createDefaultSkin() { + return new ScreenshotsPageSkin(this); + } + + @Override + public void loadVersion(Profile profile, String version) { + screenshotsDir = profile.getRepository().getRunDirectory(version).resolve("screenshots"); + refresh(); + } + + public void refresh() { + setLoading(true); + Task.supplyAsync(Schedulers.io(), () -> { + try (Stream stream = Files.list(screenshotsDir)) { + return stream.map(Screenshot::fromFile).filter(Objects::nonNull).sorted(Comparator.reverseOrder()).toList(); + } + }).whenComplete(Schedulers.javafx(), (list, exception) -> { + if (exception != null) { + LOG.warning("Failed to load screenshots in: " + screenshotsDir, exception); + getItems().clear(); + } else { + getItems().setAll(list); + } + setLoading(false); + }).start(); + } + + private void deleteAt(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOG.warning("Failed to delete screenshot: " + path, e); + } + } + + private void delete(Screenshot screenshot) { + deleteAt(screenshot.getPath()); + refresh(); + } + + private void delete(Collection screenshots) { + screenshots.stream().map(Screenshot::getPath).forEach(this::deleteAt); + refresh(); + } + + private void clear() { + try (var stream = Files.list(screenshotsDir)) { + stream.filter(Screenshot::isFileScreenshot).forEach(this::deleteAt); + } catch (IOException e) { + LOG.warning("Failed to clear screenshots at: " + screenshotsDir, e); + } + } + + public static final class Screenshot implements Comparable { + private final Path path; + private final String fileName; + private final Instant creationTime; + private Image thumbnail, fullImage; + + public static boolean isFileScreenshot(Path path) { + return Files.isRegularFile(path) && "png".equalsIgnoreCase(FileUtils.getExtension(path)); + } + + public static Screenshot fromFile(Path path) { + if (!isFileScreenshot(path)) return null; + Instant creationTime = null; + try { + creationTime = Files.readAttributes(path, BasicFileAttributes.class).creationTime().toInstant(); + } catch (IOException e) { + LOG.warning("Failed to read screenshot creation time at: " + path, e); + } + return new Screenshot(path, FileUtils.getName(path), creationTime); + } + + public Screenshot(Path path, String fileName, Instant creationTime) { + this.path = path; + this.fileName = fileName; + this.creationTime = creationTime; + } + + @Override + public int compareTo(@NotNull ScreenshotsPage.Screenshot o) { + return this.getFileName().compareTo(o.getFileName()); + } + + public Path getPath() { + return path; + } + + public String getFileName() { + return fileName; + } + + public Instant getCreationTime() { + return creationTime; + } + + public boolean isThumbnailLoaded() { + return thumbnail != null; + } + + public boolean isFullImageLoaded() { + return fullImage != null; + } + + @Nullable + public Image getThumbnail() { + if (thumbnail == null) { + try { + thumbnail = FXUtils.loadImage(path, 72, 72, true, false); + } catch (Exception e) { + LOG.warning("Failed to load screenshot thumbnail at: " + path, e); + } + } + return thumbnail; + } + + @Nullable + public Image getFullImage() { + if (fullImage == null) { + try { + fullImage = FXUtils.loadImage(path); + } catch (Exception e) { + LOG.warning("Failed to load screenshot content at: " + path, e); + } + } + return fullImage; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (Screenshot) obj; + return Objects.equals(this.path, that.path) && + Objects.equals(this.fileName, that.fileName) && + Objects.equals(this.creationTime, that.creationTime) && + Objects.equals(this.thumbnail, that.thumbnail); + } + + @Override + public int hashCode() { + return Objects.hash(path, fileName, creationTime, thumbnail); + } + + @Override + public String toString() { + return "Screenshot[" + + "path=" + path + ", " + + "fileName=" + fileName + ", " + + "creationTime=" + creationTime + ", " + + "thumbnail=" + thumbnail + ']'; + } + + } + + public static final class ScreenshotCell extends MDListCell { + + private final StackPane imagePane = new StackPane(); + private final TwoLineListItem content = new TwoLineListItem(); + + public ScreenshotCell(JFXListView listView, ScreenshotsPage page) { + super(listView); + + setSelectable(); + + HBox container = new HBox(8); + container.setPickOnBounds(false); + container.setAlignment(Pos.CENTER_LEFT); + + content.setMouseTransparent(true); + HBox.setHgrow(content, Priority.ALWAYS); + + JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO); + infoButton.setOnAction(e -> { + Screenshot screenshot = getItem(); + if (screenshot != null) { + Controllers.dialog(new ScreenshotDialog(screenshot)); + } + }); + + JFXButton deleteButton = FXUtils.newToggleButton4(SVG.DELETE_FOREVER); + deleteButton.setOnAction(e -> { + Screenshot screenshot = getItem(); + if (screenshot != null) { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), + () -> page.delete(screenshot), null); + } + }); + + container.getChildren().setAll(imagePane, content, infoButton, deleteButton); + + StackPane.setMargin(container, new Insets(8)); + getContainer().getChildren().setAll(container); + } + + @Override + protected void updateControl(Screenshot item, boolean empty) { + if (item == null || empty) return; + + if (item.isThumbnailLoaded()) { + imagePane.getChildren().setAll(new ImageContainer(item.getThumbnail(), 36, 36)); + } else { + imagePane.getChildren().setAll(SVG.SCREENSHOT_MONITOR.createIcon(36)); + CompletableFuture + .supplyAsync(item::getThumbnail, Schedulers.io()) + .whenCompleteAsync((image, t) -> { + if (image != null) imagePane.getChildren().setAll(new ImageContainer(image, 36, 36)); + }, Schedulers.javafx()); + } + + content.setTitle(item.getFileName()); + content.setSubtitle(I18n.formatDateTime(item.getCreationTime())); + } + } + + public static final class ScreenshotDialog extends JFXDialogLayout { + + public ScreenshotDialog(Screenshot screenshot) { + TwoLineListItem head = new TwoLineListItem(); + head.setTitle(screenshot.getFileName()); + head.setSubtitle(I18n.formatDateTime(screenshot.getCreationTime())); + setHeading(head); + + var image = screenshot.getFullImage(); + setBody(image != null + ? new ImageContainer(image, Math.min(Controllers.getScene().getWidth() * 0.8, image.getWidth()), Controllers.getScene().getHeight() * 0.5) + : SVG.SCREENSHOT_MONITOR.createIcon(360) + ); + + JFXButton okButton = new JFXButton(); + okButton.getStyleClass().add("dialog-accept"); + okButton.setText(i18n("button.ok")); + okButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + FXUtils.onEscPressed(this, okButton::fire); + setActions(okButton); + } + } + + public static final class ScreenshotsPageSkin extends SkinBase { + + private final TransitionPane toolbarPane; + private final HBox toolbarNormal; + private final HBox toolbarSelecting; + + private final JFXListView listView; + + public ScreenshotsPageSkin(ScreenshotsPage 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"); + + { + toolbarPane = new TransitionPane(); + + toolbarNormal = new HBox(); + toolbarSelecting = new HBox(); + + // Toolbar Normal + toolbarNormal.getChildren().setAll( + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), + createToolbarButton2(i18n("button.clear"), SVG.DELETE_FOREVER, () -> { + if (!listView.getItems().isEmpty()) { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::clear, null); + } + }) + ); + + // Toolbar Selecting + toolbarSelecting.getChildren().setAll( + createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> + skinnable.delete(listView.getSelectionModel().getSelectedItems()), null); + }), + createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> + listView.getSelectionModel().selectAll()), + createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> + listView.getSelectionModel().clearSelection()) + ); + + FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(), + selectedItem -> { + if (selectedItem == null) + changeToolbar(toolbarNormal); + else + changeToolbar(toolbarSelecting); + }); + 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 ScreenshotCell(listView, skinnable)); + listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + Bindings.bindContent(listView.getItems(), skinnable.getItems()); + + listView.setOnContextMenuRequested(event -> { + Screenshot selectedItem = listView.getSelectionModel().getSelectedItem(); + if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) { + listView.getSelectionModel().clearSelection(); + Controllers.dialog(new ScreenshotDialog(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); + } + + pane.getChildren().setAll(root); + getChildren().setAll(pane); + } + + private void changeToolbar(HBox newToolbar) { + if (newToolbar != toolbarPane.getCurrentNode()) toolbarPane.setContent(newToolbar, ContainerAnimations.FADE); + } + } + +} 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 0184026260..5d922df857 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 @@ -56,6 +56,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); private final TabHeader.Tab schematicsTab = new TabHeader.Tab<>("schematicsTab"); private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); + private final TabHeader.Tab screenshotsTab = new TabHeader.Tab<>("screenshotsTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty(); private final ObjectProperty version = new SimpleObjectProperty<>(); @@ -77,8 +78,9 @@ public VersionPage() { resourcePackTab.setNodeSupplier(loadVersionFor(ResourcepackListPage::new)); worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new)); schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new)); + screenshotsTab.setNodeSupplier(loadVersionFor(ScreenshotsPage::new)); - tab = new TabHeader(transitionPane, versionSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab); + tab = new TabHeader(transitionPane, versionSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab, screenshotsTab); tab.select(versionSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -95,6 +97,8 @@ public VersionPage() { worldListTab.getNode().loadVersion(getProfile(), getVersion()); if (schematicsTab.isInitialized()) schematicsTab.getNode().loadVersion(getProfile(), getVersion()); + if (screenshotsTab.isInitialized()) + screenshotsTab.getNode().loadVersion(getProfile(), getVersion()); } }); @@ -159,6 +163,8 @@ public void loadVersion(String version, Profile profile) { worldListTab.getNode().loadVersion(profile, version); if (schematicsTab.isInitialized()) schematicsTab.getNode().loadVersion(profile, version); + if (screenshotsTab.isInitialized()) + screenshotsTab.getNode().loadVersion(profile, version); currentVersionUpgradable.set(profile.getRepository().isModpack(version)); } @@ -265,7 +271,8 @@ protected Skin(VersionPage control) { .addNavigationDrawerTab(control.tab, control.modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL) .addNavigationDrawerTab(control.tab, control.resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE) .addNavigationDrawerTab(control.tab, control.worldListTab, i18n("world.manage"), SVG.PUBLIC) - .addNavigationDrawerTab(control.tab, control.schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL); + .addNavigationDrawerTab(control.tab, control.schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL) + .addNavigationDrawerTab(control.tab, control.screenshotsTab, i18n("screenshots.manage"), SVG.SCREENSHOT_MONITOR, SVG.SCREENSHOT_MONITOR_FILL); 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 2aeadb39df..7d10336142 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1282,6 +1282,8 @@ schematics.info.version=Schematic Version schematics.manage=Schematics schematics.sub_items=%d sub-item(s) +screenshots.manage=Screenshots + 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 663980d42b..7c10b8b7b0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1072,6 +1072,8 @@ schematics.info.version=原理圖版本 schematics.manage=原理圖管理 schematics.sub_items=%d 個子項 +screenshots.manage=截圖管理 + 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 a3c977c0f8..5f75fb2cd0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1077,6 +1077,8 @@ schematics.info.version=原理图版本 schematics.manage=原理图管理 schematics.sub_items=%d 个子项 +screenshots.manage=截图管理 + search=搜索 search.hint.chinese=支持中英文搜索 search.hint.english=仅支持英文搜索