Skip to content
15 changes: 15 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,21 @@ public MapProperty<String, Profile> getConfigurations() {
return configurations;
}

@SerializedName("saveCustomGameIcons")
private final ObjectProperty<EnumAskable> saveCustomGameIcons = new SimpleObjectProperty<>(EnumAskable.ASK);

public ObjectProperty<EnumAskable> 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<Config> {
@Override
protected Config createInstance() {
Expand Down
22 changes: 22 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumAskable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.setting;

public enum EnumAskable {
TRUE, FALSE, ASK
}
17 changes: 17 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +79,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;
Expand Down Expand Up @@ -579,6 +581,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<Boolean, Boolean> 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<String> prompt(String title, FutureCallback<String> onResult) {
return prompt(title, onResult, "");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.EnumAskable;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
Expand Down Expand Up @@ -217,6 +218,20 @@ else if (locale.isSameLanguage(currentLocale))
miscPaneList.getContent().add(allowAutoAgentPane);
}

{
LineSelectButton<EnumAskable> 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());

miscPaneList.getContent().add(saveCustomGameIconsPane);
}

{
BorderPane debugPane = new BorderPane();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -71,24 +75,67 @@ 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() {
FileChooser chooser = new FileChooser();
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);
}
}

Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,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
Expand Down Expand Up @@ -1463,6 +1464,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
Expand Down Expand Up @@ -1508,6 +1510,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
Expand Down
3 changes: 3 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ logwindow.export_dump.no_dependency=你的 Java 不包含用於建立遊戲執

main_page=首頁

message.ask=詢問
message.cancelled=操作被取消
message.confirm=提示
message.copied=已複製到剪貼簿
Expand Down Expand Up @@ -1253,6 +1254,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 修改遊戲
Expand Down Expand Up @@ -1298,6 +1300,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=關閉動畫
Expand Down
3 changes: 3 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ logwindow.export_dump.no_dependency=你的 Java 不包含用于创建游戏运

main_page=主页

message.ask=询问
message.cancelled=操作被取消
message.confirm=提示
message.copied=已复制到剪贴板
Expand Down Expand Up @@ -1258,6 +1259,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 修改游戏
Expand Down Expand Up @@ -1303,6 +1305,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=关闭动画
Expand Down