Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 5 additions & 13 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}

/// User preference for saving custom game icons.
@SerializedName("saveCustomGameIcons")
private final ObjectProperty<TriPreference> saveCustomGameIcons = new SimpleObjectProperty<>(TriPreference.CONFIRM_EACH_TIME);

public ObjectProperty<TriPreference> saveCustomGameIconsProperty() {
return saveCustomGameIcons;
}

/// The common Minecraft directory selection mode.
@SerializedName("commonDirectoryType")
private final ObjectProperty<EnumCommonDirectory> commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
30 changes: 30 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/setting/TriPreference.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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 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
}
21 changes: 21 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 @@ -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;
Expand Down Expand Up @@ -601,6 +604,24 @@ public static void dialogLater(Region content) {
decorator.showDialogLater(content);
}

public static void askTriPreference(String text, @Nullable Consumer<Boolean> resConsumer, @Nullable Consumer<TriPreference> 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<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 @@ -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);
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -71,24 +83,71 @@ 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() {
FileChooser chooser = new FileChooser();
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);
}
}

Expand Down Expand Up @@ -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));
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.TriPreference;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
Expand Down Expand Up @@ -202,6 +203,20 @@ else if (locale.isSameLanguage(currentLocale))
miscPaneList.getContent().add(disableAprilFools);
}

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

Expand Down
5 changes: 5 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading