diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index 714a7ebe03..a255a25ae6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -792,6 +792,21 @@ public MapProperty getConfigurations() { return configurations; } + @SerializedName("saveCustomGameIcons") + private final ObjectProperty saveCustomGameIcons = new SimpleObjectProperty<>(EnumAskable.ASK); + + public ObjectProperty saveCustomGameIconsProperty() { + return saveCustomGameIcons; + } + + public EnumAskable getSaveCustomGameIcons() { + return saveCustomGameIcons.get(); + } + + public void setSaveCustomGameIcons(EnumAskable saveCustomGameIcons) { + this.saveCustomGameIcons.set(saveCustomGameIcons); + } + public static final class Adapter extends ObservableSetting.Adapter { @Override protected Config createInstance() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumAskable.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumAskable.java new file mode 100644 index 0000000000..a2c1a0dc38 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumAskable.java @@ -0,0 +1,22 @@ +/* + * 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 EnumAskable { + TRUE, FALSE, ASK +} 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 c94a86accc..ca9ea4cb15 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; @@ -77,6 +78,7 @@ import java.time.LocalDate; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; @@ -558,6 +560,21 @@ public static void confirmWithCountdown(String text, String title, int seconds, timeline.play(); } + /// @param consumer Consumer for the result, with the first boolean for yes or no and the second for whether no more asking is needed + /// @see EnumAskable + public static void ask(String text, String title, BiConsumer consumer) { + var check = new JFXCheckBox(i18n("button.do_not_show_again")); + var dialog = new MessageDialogPane.Builder( + text, + title, + MessageDialogPane.MessageType.QUESTION + ) + .addActionNoClosing(check) + .yesOrNo(() -> consumer.accept(true, check.isSelected()), () -> consumer.accept(false, check.isSelected())) + .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/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/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index 2281016c35..20c7adf22d 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 @@ -33,6 +33,7 @@ import javafx.scene.layout.*; import javafx.scene.text.TextAlignment; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.setting.EnumAskable; import org.jackhuang.hmcl.setting.EnumCommonDirectory; import org.jackhuang.hmcl.setting.Settings; import org.jackhuang.hmcl.task.Schedulers; @@ -269,6 +270,20 @@ else if (locale.isSameLanguage(currentLocale)) settingsPane.getContent().add(allowAutoAgentPane); } + { + LineSelectButton saveCustomGameIconsPane = new LineSelectButton<>(); + saveCustomGameIconsPane.setTitle(i18n("settings.launcher.save_custom_game_icons")); + saveCustomGameIconsPane.setConverter(a -> switch (a) { + case ASK -> i18n("message.ask"); + case TRUE -> i18n("button.yes"); + case FALSE -> i18n("button.no"); + }); + saveCustomGameIconsPane.setItems(EnumAskable.values()); + saveCustomGameIconsPane.valueProperty().bindBidirectional(config().saveCustomGameIconsProperty()); + + settingsPane.getContent().add(saveCustomGameIconsPane); + } + { BorderPane debugPane = new BorderPane(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java index a48fc63a09..6069a37bdf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java @@ -21,10 +21,9 @@ 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.setting.Profile; -import org.jackhuang.hmcl.setting.VersionIconType; -import org.jackhuang.hmcl.setting.VersionSetting; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -33,12 +32,17 @@ import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +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 VersionIconDialog extends DialogPane { + public static final Path GAME_ICONS_DIR = Metadata.HMCL_CURRENT_DIRECTORY.resolve("game_icons"); + private final Profile profile; private final String versionId; private final Runnable onFinish; @@ -71,6 +75,19 @@ public VersionIconDialog(Profile profile, String versionId, Runnable onFinish) { createIcon(VersionIconType.FURNACE), createIcon(VersionIconType.QUILT) ); + + if (Files.isDirectory(GAME_ICONS_DIR)) { + try (var stream = Files.list(GAME_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 game icons", e); + } + } } private void exploreIcon() { @@ -78,17 +95,47 @@ private void exploreIcon() { chooser.getExtensionFilters().add(FXUtils.getImageExtensionFilter()); Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); if (selectedFile != null) { - try { - profile.getRepository().setVersionIconFile(versionId, selectedFile); + EnumAskable saveOption = ConfigHolder.config().getSaveCustomGameIcons(); + if (saveOption == EnumAskable.ASK && !GAME_ICONS_DIR.equals(selectedFile.getParent())) { + Controllers.ask( + i18n("settings.icon.save_custom"), + i18n("message.question"), + (res, doNotAsk) -> { + if (doNotAsk) ConfigHolder.config().setSaveCustomGameIcons(res ? EnumAskable.TRUE : EnumAskable.FALSE); + setCustomIcon(selectedFile, res); + } + ); + } else { + setCustomIcon(selectedFile, saveOption == EnumAskable.TRUE); + } + } + } - if (vs != null) { - vs.setVersionIcon(VersionIconType.DEFAULT); + private void setCustomIcon(Path selectedFile, boolean save) { + try { + Path dest; + if (GAME_ICONS_DIR.equals(selectedFile.getParent()) || !save) { + dest = selectedFile; + } else { + dest = GAME_ICONS_DIR.resolve(selectedFile.getFileName()); + int i = 1; + String name = FileUtils.getNameWithoutExtension(selectedFile); + String ext = FileUtils.getExtension(selectedFile); + while (Files.exists(dest)) { + dest = GAME_ICONS_DIR.resolve(name + " " + i + "." + ext); + i++; } + FileUtils.copyFile(selectedFile, dest); + } + profile.getRepository().setVersionIconFile(versionId, dest); - onAccept(); - } catch (IOException | IllegalArgumentException e) { - LOG.error("Failed to set icon file: " + selectedFile, e); + if (vs != null) { + vs.setVersionIcon(VersionIconType.DEFAULT); } + + onAccept(); + } catch (IOException | IllegalArgumentException e) { + LOG.error("Failed to set icon file: " + selectedFile, e); } } @@ -117,6 +164,33 @@ private Node createIcon(VersionIconType type) { return container; } + private Node createIcon(Path path) { + ImageView imageView; + try { + imageView = new ImageView(FXUtils.loadImage(path, 72, 72, true, false)); + } catch (Exception e) { + LOG.warning("Failed to load custom game icon: " + path, e); + return null; + } + imageView.setMouseTransparent(true); + FXUtils.limitSize(imageView, 36, 36); + RipplerContainer container = new RipplerContainer(imageView); + FXUtils.setLimitWidth(container, 36); + FXUtils.setLimitHeight(container, 36); + FXUtils.onClicked(container, () -> { + try { + profile.getRepository().setVersionIconFile(versionId, path); + } catch (IOException e) { + LOG.error("Failed to set icon file: " + path, e); + } + if (vs != null) { + vs.setVersionIcon(VersionIconType.DEFAULT); + onAccept(); + } + }); + return container; + } + @Override protected void onAccept() { profile.getRepository().onVersionIconChanged.fireEvent(new Event(this)); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index bb5233c158..53b76acf00 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -888,6 +888,7 @@ logwindow.export_dump.no_dependency=Your Java does not contain the dependencies main_page=Home +message.ask=Ask message.cancelled=Operation Canceled message.confirm=Confirm message.copied=Copied to clipboard @@ -1422,6 +1423,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_custom=Save custom icon? settings.launcher=Launcher Settings settings.launcher.allow_auto_agent=Allow HMCL to modify the game @@ -1465,6 +1467,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 Color settings.launcher.title_transparent=Transparent Titlebar settings.launcher.turn_off_animations=Disable Animation diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 633f66094e..921701af72 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -692,6 +692,7 @@ logwindow.export_dump.no_dependency=你的 Java 不包含用於建立遊戲執 main_page=首頁 +message.ask=詢問 message.cancelled=操作被取消 message.confirm=提示 message.copied=已複製到剪貼簿 @@ -1207,6 +1208,7 @@ settings.game.working_directory.choose=選取執行目錄 settings.game.working_directory.hint=在「執行路徑」選項中選取「各實例獨立」使目前實例獨立存放設定、世界、模組等資料。使用模組時建議開啟此選項以避免不同實例模組衝突。修改此選項後需自行移動世界等檔案。 settings.icon=遊戲圖示 +settings.icon.save_custom=是否要保存自定義遊戲圖示? settings.launcher=啟動器設定 settings.launcher.allow_auto_agent=允許 HMCL 修改遊戲 @@ -1250,6 +1252,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.title_transparent=標題欄透明 settings.launcher.turn_off_animations=關閉動畫 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 2cd778cea6..97534ea55c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -697,6 +697,7 @@ logwindow.export_dump.no_dependency=你的 Java 不包含用于创建游戏运 main_page=主页 +message.ask=询问 message.cancelled=操作被取消 message.confirm=提示 message.copied=已复制到剪贴板 @@ -1212,6 +1213,7 @@ settings.game.working_directory.choose=选择运行文件夹 settings.game.working_directory.hint=在“版本隔离”中选择“各实例独立”使当前实例独立存放设置、世界、模组等数据。使用模组时建议启用此选项以避免不同实例模组冲突。修改此选项后需自行移动世界等文件。 settings.icon=游戏图标 +settings.icon.save_custom=是否要保存自定义游戏图标? settings.launcher=启动器设置 settings.launcher.allow_auto_agent=允许 HMCL 修改游戏 @@ -1255,6 +1257,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.title_transparent=标题栏透明 settings.launcher.turn_off_animations=关闭动画