diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index 0b70431060..ee901bf9cb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -35,18 +35,7 @@ import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackProvider; -import org.jackhuang.hmcl.setting.LauncherSettings; -import org.jackhuang.hmcl.setting.SettingsManager; -import org.jackhuang.hmcl.setting.DefaultIsolationType; -import org.jackhuang.hmcl.setting.DownloadProviders; -import org.jackhuang.hmcl.setting.GameSettings; -import org.jackhuang.hmcl.setting.GameWindowType; -import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; -import org.jackhuang.hmcl.setting.GameDirectory; -import org.jackhuang.hmcl.setting.ProxyType; -import org.jackhuang.hmcl.setting.SettingFileUtils; -import org.jackhuang.hmcl.setting.GameSettingsPresetID; -import org.jackhuang.hmcl.setting.GameInstanceIconType; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; @@ -607,9 +596,12 @@ public void setInstanceIconFile(GameInstanceID instanceId, Path iconFile) throws throw new IllegalArgumentException("Unsupported icon file: " + ext); } + var dest = getInstanceRoot(instanceId).resolve("icon." + ext); + if (dest.equals(iconFile)) return; + deleteIconFile(instanceId); - FileUtils.copyFile(iconFile, getInstanceRoot(instanceId).resolve("icon." + ext)); + FileUtils.copyFile(iconFile, dest); } public void deleteIconFile(GameInstanceID instanceId) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java index f280224498..319fe353f2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java @@ -219,6 +219,14 @@ public BooleanProperty disableAprilFoolsProperty() { return disableAprilFools; } + /// User preference for saving custom game icons. + @SerializedName("saveCustomGameIcons") + private final ObjectProperty saveCustomGameIcons = new SimpleObjectProperty<>(TriPreference.CONFIRM_EACH_TIME); + + public ObjectProperty saveCustomGameIconsProperty() { + return saveCustomGameIcons; + } + /// The common Minecraft directory selection mode. @SerializedName("commonDirectoryType") private final ObjectProperty commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java index 3b34a00764..52c599b8a9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java @@ -72,8 +72,8 @@ private enum GameDirectoryType { CUSTOM } - /// Legacy `VersionIconType` ordinal order used by old local settings. - private static final GameInstanceIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = { + /// Legacy `GameInstanceIconType` ordinal order used by old local settings. + private static final GameInstanceIconType @Unmodifiable [] LEGACY_INSTANCE_ICON_TYPES = { GameInstanceIconType.DEFAULT, GameInstanceIconType.GRASS, GameInstanceIconType.CHEST, @@ -497,13 +497,13 @@ private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObj try { if (primitive.isNumber()) { int index = primitive.getAsInt(); - return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length - ? LEGACY_VERSION_ICON_TYPES[index] + return index >= 0 && index < LEGACY_INSTANCE_ICON_TYPES.length + ? LEGACY_INSTANCE_ICON_TYPES[index] : GameInstanceIconType.DEFAULT; } String value = primitive.getAsString(); - for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) { + for (GameInstanceIconType iconType : LEGACY_INSTANCE_ICON_TYPES) { if (iconType.name().equalsIgnoreCase(value)) { return iconType; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/TriPreference.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/TriPreference.java new file mode 100644 index 0000000000..36179117c3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/TriPreference.java @@ -0,0 +1,30 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.setting; + +public enum TriPreference { + + /// Always perform the action without asking + ALWAYS, + + /// Skip the action without asking + NEVER, + + /// Let the user decide whether to perform the action each time the event is triggered + CONFIRM_EACH_TIME +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index ef342baddd..aaf70a4f73 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXCheckBox; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.validation.base.ValidatorBase; import javafx.animation.KeyFrame; @@ -74,7 +75,9 @@ import java.nio.file.Path; import java.time.LocalDate; import java.util.List; +import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; @@ -601,6 +604,24 @@ public static void dialogLater(Region content) { decorator.showDialogLater(content); } + public static void askTriPreference(String text, @Nullable Consumer resConsumer, @Nullable Consumer prefConsumer) { + var r = Objects.requireNonNullElse(resConsumer, (__) -> {}); + var p = Objects.requireNonNullElse(prefConsumer, (__) -> {}); + + var check = new JFXCheckBox(i18n("button.do_not_show_again")); + var dialog = new MessageDialogPane.Builder(text, i18n("message.question"), MessageDialogPane.MessageType.QUESTION) + .addActionNoClosing(check) + .yesOrNo(() -> { + r.accept(true); + p.accept(check.isSelected() ? TriPreference.ALWAYS : TriPreference.CONFIRM_EACH_TIME); + }, () -> { + r.accept(false); + p.accept(check.isSelected() ? TriPreference.NEVER : TriPreference.CONFIRM_EACH_TIME); + }) + .build(); + dialog(dialog); + } + public static CompletableFuture prompt(String title, FutureCallback onResult) { return prompt(title, onResult, ""); } 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..f8c0d49d42 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,11 @@ public ImageContainer(double width, double height) { this.getChildren().setAll(imageView); } + public ImageContainer(double size, Image image) { + this(size); + setImage(image); + } + private void updateCornerRadius(double radius) { clip.setArcWidth(radius); clip.setArcHeight(radius); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java index 2bed442a5f..7e5b7c7f0f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java @@ -169,6 +169,11 @@ public Builder addAction(String text, @Nullable Runnable action) { return this; } + public Builder addActionNoClosing(Node actionNode) { + dialog.actions.getChildren().add(actionNode); + return this; + } + public Builder ok(@Nullable Runnable ok) { JFXButton btnOk = new JFXButton(i18n("button.ok")); btnOk.getStyleClass().add("dialog-accept"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java index a81828116f..7ff403e6c0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java @@ -21,24 +21,36 @@ import javafx.scene.image.ImageView; import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; +import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.setting.GameSettings; -import org.jackhuang.hmcl.setting.GameInstanceIconType; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.DialogPane; +import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Objects; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class GameInstanceIconDialog extends DialogPane { + + public static final Path INSTANCE_ICONS_DIR = Metadata.HMCL_LOCAL_HOME.resolve("instance_icons"); + + private static final SimpleDateFormat fileNameFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss_"); + private final HMCLGameRepository repository; private final GameInstanceID instanceId; private final Runnable onFinish; @@ -48,7 +60,7 @@ public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID inst this.repository = repository; this.instanceId = instanceId; this.onFinish = onFinish; - this.setting = repository.getInstanceGameSettingsOrCreate(this.instanceId); + this.setting = repository.getInstanceGameSettingsOrCreate(instanceId); setTitle(i18n("settings.icon")); FlowPane pane = new FlowPane(); @@ -71,6 +83,18 @@ public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID inst createIcon(GameInstanceIconType.FURNACE), createIcon(GameInstanceIconType.QUILT) ); + if (Files.isDirectory(INSTANCE_ICONS_DIR)) { + try (var stream = Files.list(INSTANCE_ICONS_DIR)) { + pane.getChildren().addAll( + stream.filter(p -> Files.isRegularFile(p) && FXUtils.IMAGE_EXTENSIONS.contains(FileUtils.getExtension(p).toLowerCase(Locale.ROOT))) + .map(this::createIcon) + .filter(Objects::nonNull) + .toList() + ); + } catch (Exception e) { + LOG.warning("Failed to load custom instance icons at " + INSTANCE_ICONS_DIR, e); + } + } } private void exploreIcon() { @@ -78,17 +102,52 @@ private void exploreIcon() { chooser.getExtensionFilters().add(FXUtils.getImageExtensionFilter()); Path selectedFile = Controllers.showOpenDialog(chooser); if (selectedFile != null) { - try { - repository.setInstanceIconFile(instanceId, selectedFile); + TriPreference pref = SettingsManager.settings().saveCustomGameIconsProperty().get(); + if (pref == TriPreference.CONFIRM_EACH_TIME && !INSTANCE_ICONS_DIR.equals(selectedFile.getParent())) { + Controllers.askTriPreference( + i18n("settings.icon.save"), + (b) -> setCustomIcon(selectedFile, b), + (p) -> SettingsManager.settings().saveCustomGameIconsProperty().set(p) + ); + } else { + setCustomIcon(selectedFile, pref == TriPreference.ALWAYS); + } + } + } - if (setting != null) { - setting.iconProperty().setValue(GameInstanceIconType.DEFAULT); - } + private Path saveIcon(Path selectedFile) throws IOException { + String date = fileNameFormat.format(new Date()); + Path dest = INSTANCE_ICONS_DIR.resolve(date + selectedFile.getFileName()); + { + int i = 1; + String nameBase = date + FileUtils.getNameWithoutExtension(selectedFile); + String ext = FileUtils.getExtension(selectedFile); + while (Files.exists(dest)) { + dest = INSTANCE_ICONS_DIR.resolve(nameBase + "_" + i + "." + ext); + i++; + } + } + FileUtils.copyFile(selectedFile, dest); + return dest; + } - onAccept(); - } catch (IOException | IllegalArgumentException e) { - LOG.error("Failed to set icon file: " + selectedFile, e); + private void setCustomIcon(Path selectedFile, boolean save) { + try { + Path dest; + if (INSTANCE_ICONS_DIR.equals(selectedFile.getParent()) || !save) { + dest = selectedFile; + } else { + dest = saveIcon(selectedFile); + } + repository.setInstanceIconFile(instanceId, dest); + + if (setting != null) { + setting.iconProperty().setValue(GameInstanceIconType.DEFAULT); } + + onAccept(); + } catch (IOException | IllegalArgumentException e) { + LOG.error("Failed to set instance icon file: " + selectedFile, e); } } @@ -117,6 +176,32 @@ private Node createIcon(GameInstanceIconType type) { return container; } + private Node createIcon(Path path) { + ImageContainer imageContainer; + try { + imageContainer = new ImageContainer(32, FXUtils.loadImage(path, 64, 64, true, true)); + } catch (Exception e) { + LOG.warning("Failed to load custom instance icon at " + path, e); + return null; + } + imageContainer.setMouseTransparent(true); + RipplerContainer container = new RipplerContainer(imageContainer); + FXUtils.setLimitWidth(container, 36); + FXUtils.setLimitHeight(container, 36); + FXUtils.onClicked(container, () -> { + try { + repository.setInstanceIconFile(instanceId, path); + } catch (IOException e) { + LOG.error("Failed to set icon file: " + path, e); + } + if (setting != null) { + setting.iconProperty().setValue(GameInstanceIconType.DEFAULT); + onAccept(); + } + }); + return container; + } + @Override protected void onAccept() { repository.onInstanceIconChanged.fireEvent(new Event(this)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index ee8ea5593c..98f3b12728 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -31,6 +31,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.setting.TriPreference; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -202,6 +203,20 @@ else if (locale.isSameLanguage(currentLocale)) miscPaneList.getContent().add(disableAprilFools); } + { + LineSelectButton saveCustomGameIconsPane = new LineSelectButton<>(); + saveCustomGameIconsPane.setTitle(i18n("settings.launcher.save_custom_game_icons")); + saveCustomGameIconsPane.valueProperty().bindBidirectional(settings().saveCustomGameIconsProperty()); + saveCustomGameIconsPane.setConverter(a -> switch (a) { + case CONFIRM_EACH_TIME -> i18n("message.tri_pref.confirm_each_time"); + case ALWAYS -> i18n("message.tri_pref.always"); + case NEVER -> i18n("message.tri_pref.never"); + }); + saveCustomGameIconsPane.setItems(TriPreference.values()); + + miscPaneList.getContent().add(saveCustomGameIconsPane); + } + { BorderPane debugPane = new BorderPane(); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 6ee719bc05..43164bb426 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1016,6 +1016,9 @@ message.success=Operation successfully completed message.unknown=Unknown message.warning=Warning message.question=Question +message.tri_pref.always=Always +message.tri_pref.never=Never +message.tri_pref.confirm_each_time=Confirm Each Time modpack=Modpacks modpack.choose=Choose Modpack @@ -1588,6 +1591,7 @@ settings.game.working_directory.hint=Enable the "Isolated" option in "Working Di It is recommended to enable this option to avoid mod conflicts, but you will need to move your worlds manually. settings.icon=Icon +settings.icon.save=Save custom instance icon? settings.game_directories.read_only=The game directory file was saved by a newer version of HMCL. The current version of HMCL cannot modify game directories. settings.launcher=Launcher Settings @@ -1637,6 +1641,7 @@ settings.launcher.proxy.password=Password settings.launcher.proxy.port=Port settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=Username +settings.launcher.save_custom_game_icons=Save Custom Game Icons settings.launcher.theme=Theme settings.launcher.theme_color=Theme Color settings.launcher.theme_color_style=Color Style diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index f4021b9b3b..9b6aca9d0f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -825,6 +825,9 @@ message.success=完成 message.unknown=未知 message.warning=警告 message.question=確認 +message.tri_pref.always=總是 +message.tri_pref.never=從不 +message.tri_pref.confirm_each_time=每次詢問 modpack=模組包 modpack.choose=選取要安裝的遊戲模組包檔案 @@ -1389,6 +1392,7 @@ settings.game.working_directory.choose=選取執行目錄 settings.game.working_directory.hint=在「執行路徑」選項中選取「各實例獨立」使目前實例獨立存放設定、世界、模組等資料。使用模組時建議開啟此選項以避免不同實例模組衝突。修改此選項後需自行移動世界等檔案。 settings.icon=遊戲圖示 +settings.icon.save=是否儲存自定義例項圖示? settings.game_directories.read_only=遊戲目錄設定檔是由更高版本的 HMCL 儲存的,目前版本的 HMCL 無法新增遊戲目錄。 settings.launcher=啟動器設定 @@ -1438,6 +1442,7 @@ settings.launcher.proxy.password=密碼 settings.launcher.proxy.port=連線埠 settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=帳戶 +settings.launcher.save_custom_game_icons=保存自定義遊戲圖示 settings.launcher.theme=主題色 settings.launcher.theme_color=主題色 settings.launcher.theme_color_style=色彩模式 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 848051d09f..6dc33cee0c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -831,6 +831,9 @@ message.success=完成 message.unknown=未知 message.warning=警告 message.question=确认 +message.tri_pref.always=总是 +message.tri_pref.never=从不 +message.tri_pref.confirm_each_time=每次询问 modpack=整合包 modpack.choose=选择要安装的游戏整合包文件 @@ -1389,6 +1392,7 @@ settings.game.working_directory.choose=选择运行文件夹 settings.game.working_directory.hint=在“版本隔离”中选择“各实例独立”使当前实例独立存放设置、世界、模组等数据。使用模组时建议启用此选项以避免不同实例模组冲突。修改此选项后需自行移动世界等文件。 settings.icon=游戏图标 +settings.icon.save=是否保存自定义实例图标? settings.game_directories.read_only=游戏文件夹配置文件是由更高版本的 HMCL 保存的,当前版本的 HMCL 无法修改游戏文件夹。 settings.launcher=启动器设置 @@ -1438,6 +1442,7 @@ settings.launcher.proxy.password=密码 settings.launcher.proxy.port=端口 settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=账户 +settings.launcher.save_custom_game_icons=保存自定义游戏图标 settings.launcher.theme=主题 settings.launcher.theme_color=主题色 settings.launcher.theme_color_style=色彩模式