Strip NBT, schematics, multi-dir, themes, and download source UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 14s
Check Codes / build (pull_request) Failing after 15s
PR Size Label / label (pull_request_target) Failing after 25s
Java CI / build (push) Failing after 2m48s
Java CI / build (pull_request) Failing after 6m9s

Remove NBT editor and schematic management, collapse game directories to the
fixed workspace .minecraft path, drop Countly packaging and theme-pack
marketplace UI, and hide extra download-source selectors for the server client.
This commit is contained in:
浩 刘 committed 2026-11-07 18:53:31 +00:00
1 parent cf44b2e536
commit 2b76d44ab0
21 files changed
+21 -2716

No files matched your search

@@ -152,12 +152,6 @@ public final class GameDirectoryManager {
gameDirectories.getGameDirectories().add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
}
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = userGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
}
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
if (needRebuildGameDirectories) {
@@ -166,15 +160,13 @@ public final class GameDirectoryManager {
assert !mergedGameDirectories.isEmpty();
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
// Dedicated server client: always prefer the fixed workspace `.minecraft` directory.
GameDirectory currentGameDirectory = null;
if (selectedId != null) {
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (gameDirectory.getId().equals(selectedId)) {
currentGameDirectory = gameDirectory;
break;
}
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (DEFAULT_GAME_DIRECTORY_ID.equals(gameDirectory.getId())
|| isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
currentGameDirectory = gameDirectory;
break;
}
}
@@ -215,16 +207,13 @@ public final class GameDirectoryManager {
}
}
/// Creates the built-in game directories only when no game directory exists.
/// Creates the fixed workspace game directory when no game directory exists.
private static boolean createDefaultGameDirectoriesIfEmpty() {
if (localGameDirectories().getGameDirectories().isEmpty()
&& userGameDirectories().getGameDirectories().isEmpty()) {
localGameDirectories().getGameDirectories()
.add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
userGameDirectories().getGameDirectories()
.add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
return true;
} else {
return false;
@@ -277,11 +277,8 @@ public final class Controllers {
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
Scene mainScene = decorator.attachStage(stage);
if (settings().commonDirectoryTypeProperty().get() == EnumCommonDirectory.CUSTOM &&
!FileUtils.canCreateDirectory(settings().getResolvedCommonDirectory())) {
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
dialog(i18n("launcher.cache_directory.invalid"));
}
// Dedicated server client: always use the fixed default cache directory.
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
Lang.thread(JavaManager::initialize, "Search Java", true);
@@ -8,7 +8,7 @@ import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import org.jackhuang.hmcl.countly.CrashReport;
import org.jackhuang.hmcl.util.CrashReport;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -1,57 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.directory;
import javafx.beans.binding.Bindings;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.Controllers;
import org.jetbrains.annotations.NotNullByDefault;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Navigation drawer item for one game directory entry.
@NotNullByDefault
public class GameDirectoryListItem extends RadioButton {
/// Game directory represented by this item.
private final GameDirectory gameDirectory;
/// Creates a list item for the given game directory.
///
/// @param gameDirectory the represented game directory
public GameDirectoryListItem(GameDirectory gameDirectory) {
this.gameDirectory = gameDirectory;
getStyleClass().setAll("game-directory-list-item", "navigation-drawer-item");
setUserData(gameDirectory);
this.selectedProperty().bind(Bindings.equal(gameDirectory, GameDirectoryManager.selectedGameDirectoryProperty()));
}
/// Creates the JavaFX skin for this item.
@Override
protected Skin<?> createDefaultSkin() {
return new GameDirectoryListItemSkin(this);
}
/// Removes the represented game directory after handling read-only storage.
public void remove() {
if (!GameDirectoryManager.canRemoveGameDirectory(gameDirectory)) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
GameDirectoryManager.forceOverwriteGameDirectoryFiles(gameDirectory);
GameDirectoryManager.removeGameDirectory(gameDirectory);
});
return;
}
GameDirectoryManager.removeGameDirectory(gameDirectory);
}
/// Returns the represented game directory.
public GameDirectory getGameDirectory() {
return gameDirectory;
}
}
@@ -1,58 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.directory;
import com.jfoenix.controls.JFXButton;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.scene.control.SkinBase;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jetbrains.annotations.NotNullByDefault;
/// Skin for [GameDirectoryListItem].
@NotNullByDefault
public class GameDirectoryListItemSkin extends SkinBase<GameDirectoryListItem> {
/// Pseudo class applied to the selected item.
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
/// Creates the skin for a game directory list item.
///
/// @param skinnable the list item controlled by this skin
public GameDirectoryListItemSkin(GameDirectoryListItem skinnable) {
super(skinnable);
TwoLineListItem item = new TwoLineListItem();
RipplerContainer container = new RipplerContainer(item);
SVGContainer left = (skinnable.isSelected() ? SVG.FOLDER_FILL : SVG.FOLDER).createIcon(20);
left.setMouseTransparent(true);
TwoLineListItem.setMargin(left, new Insets(0, 6, 0, 6));
item.setLeading(left);
FXUtils.onChangeAndOperate(skinnable.selectedProperty(), active -> {
skinnable.pseudoClassStateChanged(SELECTED, active);
SVG targetIcon = active ? SVG.FOLDER_FILL : SVG.FOLDER;
if (left.getIcon() != targetIcon) {
left.setIcon(targetIcon, Motion.SHORT4);
}
});
FXUtils.onClicked(getSkinnable(), () -> GameDirectoryManager.setSelectedGameDirectory(skinnable.getGameDirectory()));
JFXButton btnRemove = FXUtils.newToggleButton4(SVG.CLOSE, 14);
btnRemove.setOnAction(e -> skinnable.remove());
item.setTrailing(btnRemove);
item.titleProperty().set(GameDirectoryManager.getGameDirectoryDisplayName(skinnable.getGameDirectory()));
item.subtitleProperty().set(skinnable.getGameDirectory().getPath().toString());
getChildren().setAll(container);
}
}
@@ -1,302 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.directory;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.RequiredFieldValidator;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Page used to create or edit a game directory entry.
@NotNullByDefault
public final class GameDirectoryPage extends BorderPane implements DecoratorPage {
/// Decorator title state for this page.
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
/// Editable game directory path shown in the directory chooser.
private final StringProperty location;
/// Game directory being edited, or `null` when creating a new entry.
private final @Nullable GameDirectory gameDirectory;
/// Text field used to edit the game directory display name.
private final JFXTextField txtGameDirectoryName;
/// Directory chooser bound to [#location].
private final LineFileChooserButton gameDir;
/// Switch that controls whether a saved path is converted to a relative path when possible.
private final LineSwitch switchUseRelativePath;
/// Creates a page for adding a new game directory or editing an existing game directory.
///
/// @param gameDirectory the edited game directory, or `null` when creating a new entry
public GameDirectoryPage(@Nullable GameDirectory gameDirectory) {
getStyleClass().add("gray-background");
this.gameDirectory = gameDirectory;
String gameDirectoryDisplayName = Optional.ofNullable(gameDirectory).map(GameDirectoryManager::getGameDirectoryDisplayName).orElse("");
state.set(State.fromTitle(gameDirectory == null ? i18n("game_directory.new") : i18n("game_directory") + " - " + gameDirectoryDisplayName));
location = new SimpleStringProperty(this, "location",
Optional.ofNullable(gameDirectory).map(GameDirectory::getPath).map(PortablePath::toPath).map(FileUtils::getAbsolutePath).orElse(".minecraft"));
ScrollPane scroll = new ScrollPane();
this.setCenter(scroll);
scroll.setFitToHeight(true);
scroll.setFitToWidth(true);
{
VBox rootPane = new VBox();
rootPane.setStyle("-fx-padding: 20;");
{
ComponentList componentList = new ComponentList();
{
BorderPane gameDirectoryNamePane = new BorderPane();
{
Label label = new Label(i18n("game_directory.name"));
gameDirectoryNamePane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
txtGameDirectoryName = new JFXTextField();
gameDirectoryNamePane.setRight(txtGameDirectoryName);
RequiredFieldValidator validator = new RequiredFieldValidator();
validator.setMessage(i18n("input.not_empty"));
txtGameDirectoryName.getValidators().add(validator);
BorderPane.setMargin(txtGameDirectoryName, new Insets(8, 0, 8, 0));
txtGameDirectoryName.setText(gameDirectoryDisplayName);
txtGameDirectoryName.getValidators().add(new ValidatorBase() {
{
setMessage(i18n("game_directory.already_exists"));
}
/// Rejects names used by other game directories, excluding the entry being edited.
@Override
protected void eval() {
JFXTextField control = (JFXTextField) this.getSrcControl();
hasErrors.set(GameDirectoryManager.getGameDirectories().stream()
.filter(existingGameDirectory -> existingGameDirectory != gameDirectory)
.anyMatch(existingGameDirectory -> Objects.equals(
GameDirectoryManager.getGameDirectoryCustomName(existingGameDirectory), control.getText())));
}
});
}
gameDir = new LineFileChooserButton();
gameDir.setTitle(i18n("game_directory.instance_directory"));
gameDir.setFileChooserTitle(i18n("game_directory.instance_directory.choose"));
gameDir.setType(LineFileChooserButton.Type.OPEN_DIRECTORY);
gameDir.locationProperty().bindBidirectional(location);
switchUseRelativePath = new LineSwitch();
switchUseRelativePath.setTitle(i18n("game_directory.use_relative_path"));
gameDir.convertToRelativePathProperty().bind(switchUseRelativePath.selectedProperty());
if (gameDirectory != null) {
switchUseRelativePath.setSelected(!gameDirectory.getPath().isAbsolute());
}
componentList.getContent().setAll(gameDirectoryNamePane, gameDir, switchUseRelativePath);
}
rootPane.getChildren().setAll(componentList);
}
scroll.setContent(rootPane);
}
BorderPane savePane = new BorderPane();
this.setBottom(savePane);
savePane.setPickOnBounds(false);
savePane.setStyle("-fx-padding: 20;");
StackPane.setAlignment(savePane, Pos.BOTTOM_RIGHT);
{
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
savePane.setRight(saveButton);
BorderPane.setAlignment(savePane, Pos.BOTTOM_RIGHT);
StackPane.setAlignment(saveButton, Pos.BOTTOM_RIGHT);
saveButton.setPrefSize(100, 40);
saveButton.setOnAction(e -> onSave());
String initialLocation = getLocation();
boolean initialUseRelativePath = switchUseRelativePath.isSelected();
saveButton.disableProperty().bind(Bindings.createBooleanBinding(
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation())
|| (gameDirectory != null
&& Objects.equals(txtGameDirectoryName.getText(), gameDirectoryDisplayName)
&& Objects.equals(getLocation(), initialLocation)
&& switchUseRelativePath.isSelected() == initialUseRelativePath),
txtGameDirectoryName.textProperty(), location, switchUseRelativePath.selectedProperty()));
}
if (gameDirectory == null) {
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
Path newPath;
try {
newPath = FileUtils.toAbsolute(Path.of(newValue));
} catch (InvalidPathException ignored) {
return;
}
if (!".minecraft".equals(FileUtils.getName(newPath)))
return;
@Nullable Path parent = newPath.getParent();
if (parent == null)
return;
String suggestedName = FileUtils.getName(parent);
if (!suggestedName.isBlank()) {
txtGameDirectoryName.setText(suggestedName);
}
};
locationChangeListener.changed(locationProperty(), this.location.get(), this.location.get());
locationProperty().addListener(locationChangeListener);
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
/// Stops automatic name suggestions after the user edits the name.
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (txtGameDirectoryName.isFocused()) {
txtGameDirectoryName.textProperty().removeListener(this);
locationProperty().removeListener(locationChangeListener);
}
}
});
}
}
@FXThread
private void doSave() {
if (gameDirectory != null) {
LocalizedText name = LocalizedText.plain(txtGameDirectoryName.getText());
PortablePath path = StringUtils.isNotBlank(getLocation()) ? createPortableLocation() : gameDirectory.getPath();
if (!GameDirectoryManager.canUpdateGameDirectory(gameDirectory, path)) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
GameDirectoryManager.forceOverwriteGameDirectoryFiles(gameDirectory, path);
GameDirectoryManager.updateGameDirectory(gameDirectory, name, path);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.updateGameDirectory(gameDirectory, name, path);
} else {
if (StringUtils.isBlank(getLocation())) {
gameDir.fire();
}
GameDirectory newGameDirectory = new GameDirectory(
GameDirectoryManager.newGameDirectoryId(),
LocalizedText.plain(txtGameDirectoryName.getText()),
createPortableLocation());
if (newGameDirectory.getPath().isAbsolute()) {
if (SettingsManager.isUserGameDirectoriesReadOnly()) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
SettingsManager.forceOverwriteUserGameDirectories();
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
} else {
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
SettingsManager.forceOverwriteLocalGameDirectories();
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
}
}
fireEvent(new PageCloseEvent());
}
/// Saves the edited game directory or adds a new entry to the appropriate game directory store.
@FXThread
private void onSave() {
if (Objects.equals(Path.of(getLocation()).getRoot(), Path.of(getLocation()))) {
Controllers.confirm(i18n("game_directory.root"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING, this::doSave, null);
} else doSave();
}
/// Creates the portable path for the current location according to the relative-path switch.
private PortablePath createPortableLocation() {
if (switchUseRelativePath.isSelected()) {
Path path = Path.of(getLocation());
Path absolutePath = FileUtils.toAbsolute(path);
try {
return PortablePath.fromPath(Metadata.CURRENT_DIRECTORY.relativize(absolutePath).normalize());
} catch (IllegalArgumentException ignored) {
// Keep the original path when it cannot be expressed relative to the launcher directory.
}
}
return PortablePath.of(getLocation());
}
/// Returns the decorator title state for this page.
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
/// Returns the current directory path text.
public String getLocation() {
return location.get();
}
/// Returns the editable directory path property.
public StringProperty locationProperty() {
return location;
}
/// Updates the directory path text.
///
/// @param location the new directory path text
public void setLocation(String location) {
this.location.set(location);
}
}
@@ -47,8 +47,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private final TabItem<ModListPage> modListTab;
/// The saved worlds tab bound to the current game instance.
private final TabItem<WorldListPage> worldListTab;
/// The schematics tab bound to the current game instance.
private final TabItem<SchematicsPage> schematicsTab;
/// The installed resource packs tab bound to the current game instance.
private final TabItem<ResourcePackListPage> resourcePackTab;
/// The screenshots tab bound to the current game instance.
@@ -85,7 +83,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
this.modListTab = new TabItem<>(() -> new ModListPage(instance));
this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance));
this.worldListTab = new TabItem<>(() -> new WorldListPage(instance));
this.schematicsTab = new TabItem<>(() -> new SchematicsPage(instance));
this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance));
tabController = new TabController(transitionPane,
@@ -94,7 +91,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
modListTab,
resourcePackTab,
worldListTab,
schematicsTab,
screenshotsTab);
tabController.selectTab(gameSettingsTab);
@@ -126,8 +122,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
.addNavigationDrawerTab(tabController, installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL)
.addNavigationDrawerTab(tabController, modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE)
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC)
.addNavigationDrawerTab(tabController, schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL);
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC);
PopupMenu browseList = new PopupMenu();
JFXPopup browsePopup = new JFXPopup(browseList);
@@ -138,7 +133,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> onBrowse("mods"), browsePopup),
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> onBrowse("resourcepacks"), browsePopup),
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> onBrowse("saves"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), this::onBrowseSchematics, browsePopup),
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> onBrowse("shaderpacks"), browsePopup),
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> onBrowse("screenshots"), browsePopup),
new MenuSeparator(),
@@ -275,14 +269,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub));
}
private void onBrowseSchematics() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance == null) {
return;
}
FXUtils.openFolder(gameInstance.getSchematicsDirectory());
}
private void redownloadAssetIndex() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
@@ -4,7 +4,6 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
@@ -34,13 +33,10 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.directory.GameDirectoryListItem;
import org.jackhuang.hmcl.ui.directory.GameDirectoryPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.nio.file.Path;
import java.util.List;
@@ -55,44 +51,11 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("instance.manage")));
/// Navigation drawer items for configured game directories.
@SuppressWarnings("FieldCanBeLocal")
private final ObservableList<GameDirectoryListItem> gameDirectoryListItems;
/// Creates game-directory navigation, its bottom action, and the instance list.
/// Creates the fixed-directory instance list.
public GameListPage() {
gameDirectoryListItems = MappedObservableList.create(GameDirectoryManager.getGameDirectories(), gameDirectory -> {
GameDirectoryListItem item = new GameDirectoryListItem(gameDirectory);
FXUtils.onSecondaryButtonClicked(item, () -> {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().addAll(
new IconedMenuItem(
SVG.EDIT,
i18n("button.edit"),
() -> Controllers.navigate(new GameDirectoryPage(gameDirectory)),
popup),
new IconedMenuItem(
SVG.FOLDER,
i18n("button.reveal_dir"),
() -> FXUtils.openFolder(gameDirectory.getPath().toPath()),
popup));
popup.show(item, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0);
});
return item;
});
AdvancedListItem addGameDirectoryItem = new AdvancedListItem();
addGameDirectoryItem.getStyleClass().add("navigation-drawer-item");
addGameDirectoryItem.setTitle(i18n("game_directory.new"));
addGameDirectoryItem.setLeftIcon(SVG.ADD_CIRCLE);
addGameDirectoryItem.setOnAction(e -> Controllers.navigate(new GameDirectoryPage(null)));
VBox directories = new VBox();
Bindings.bindContent(directories.getChildren(), gameDirectoryListItems);
getSidebar().add(directories).add(addGameDirectoryItem);
setSidebarBottom(new AdvancedListBox()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings));
getSidebar()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
setContent(new GameList());
@@ -1,831 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.scene.text.Text;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.schematic.LitematicFile;
import org.jackhuang.hmcl.schematic.Schematic;
import org.jackhuang.hmcl.schematic.SchematicType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.util.StringUtils;
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.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.*;
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;
/**
* @author Glavo
*/
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private static String translateAuthorName(String author) {
if (I18n.isUseChinese() && "hsds".equals(author)) {
return "黑山大叔";
}
return author;
}
private static String translateType(SchematicType type) {
return switch (type) {
case SCHEM -> i18n("schematics.info.type.schem");
case NBT_STRUCTURE -> i18n("schematics.info.type.nbt_structure");
case LITEMATIC -> i18n("schematics.info.type.litematic");
};
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path schematicsDirectory;
private final ObjectProperty<DirItem> currentDirectory = new SimpleObjectProperty<>(this, "currentDirectory", null);
private final BooleanProperty isRootProperty = new SimpleBooleanProperty(this, "isRoot", true);
/// Creates a schematics list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this,
file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file),
this::addFiles
);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new SchematicsPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.schematicsDirectory = null;
return;
}
this.schematicsDirectory = gameInstance.getSchematicsDirectory();
refresh();
}
private ObjectProperty<DirItem> currentDirectoryProperty() {
return currentDirectory;
}
public BooleanProperty isRootProperty() {
return isRootProperty;
}
public void navigateBack() {
var d = currentDirectoryProperty().get();
if (d == null) return;
if (d.parent != null) navigateTo(d.parent);
}
public void refresh() {
if (schematicsDirectory == null) return;
HMCLGameInstance currentGameInstance = this.gameInstance;
if (currentGameInstance == null) return;
setLoading(true);
DirItem currentDir = currentDirectoryProperty().get();
Task.supplyAsync(Schedulers.io(), () -> {
DirItem target = loadRoot(schematicsDirectory);
if (currentDir != null) {
loop:
for (String dirName : currentDir.relativePath) {
try {
target.preLoad();
} catch (IOException e) {
LOG.warning("Failed to preload sub-directories in " + target, e);
break;
}
for (var dirChild : target.dirChildren) {
if (dirChild.getName().equals(dirName)) {
target = dirChild;
continue loop;
}
}
break;
}
}
return target;
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (!Objects.equals(currentGameInstance, this.gameInstance)) return;
if (exception == null) {
navigateTo(result);
} else {
LOG.warning("Failed to load schematics", exception);
}
setLoading(false);
}).start();
}
public void addFiles(List<Path> files) {
if (currentDirectoryProperty().get() == null)
return;
Path dir = currentDirectoryProperty().get().getPath();
try {
// Can be executed in the background, but be careful that users can call loadVersion during this time
Files.createDirectories(dir);
for (Path file : files) {
Files.copy(file, dir.resolve(file.getFileName()));
}
refresh();
} catch (FileAlreadyExistsException ignored) {
} catch (IOException e) {
Controllers.dialog(i18n("schematics.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to add schematics to " + dir, e);
}
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("schematics.add.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
i18n("extension.schematic"), "*.litematic", "*.nbt", "*.schematic", "*.schem"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
public void onCreateDirectory() {
if (currentDirectoryProperty().get() == null) return;
Path parent = currentDirectoryProperty().get().getPath();
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(parent, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + parent, e);
existingPaths = new FileNameSet(false);
}
Controllers.prompt(
i18n("schematics.create_directory.prompt"),
(result, handler) -> {
Path targetDir = parent.resolve(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("schematics.create_directory.failed.already_exists"));
return;
}
try {
Files.createDirectories(targetDir);
handler.resolve();
refresh();
} catch (IOException e) {
LOG.warning("Failed to create directory: " + targetDir, e);
handler.reject(i18n("schematics.create_directory.failed", targetDir));
}
},
"",
new RequiredValidator(),
new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid),
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
}
public void onRevealSchematicsFolder() {
var d = currentDirectoryProperty().get();
var p = d != null ? d.getPath() : schematicsDirectory;
if (p != null) FXUtils.openFolder(p);
}
private DirItem loadRoot(Path dir) {
var item = new DirItem(dir, null);
item.load();
return item;
}
private void navigateTo(DirItem item) {
if (currentDirectoryProperty().get() == item) return;
currentDirectoryProperty().set(item);
isRootProperty().set(item.parent == null);
setLoading(true);
Task.runAsync(item::load).whenComplete(Schedulers.javafx(), exception -> {
if (currentDirectoryProperty().get() == item) {
getItems().setAll(item.children);
setLoading(false);
}
}).start();
}
abstract sealed class Item implements Comparable<Item> {
boolean isDirectory() {
return this instanceof DirItem;
}
abstract Path getPath();
abstract String getName();
abstract String getDescription();
abstract SVG getIcon();
Node getIcon(int size) {
StackPane icon = new StackPane();
icon.setPrefSize(size, size);
icon.setMaxSize(size, size);
icon.getChildren().add(getIcon().createIcon(size));
return icon;
}
abstract int order();
abstract void onClick();
abstract void onReveal();
abstract void onDelete();
@Override
public int compareTo(@NotNull SchematicsPage.Item o) {
if (this.order() != o.order())
return Integer.compare(this.order(), o.order());
return this.getName().compareTo(o.getName());
}
}
private final class DirItem extends Item {
final ReentrantLock lock = new ReentrantLock();
final Path path;
final @Nullable DirItem parent;
final List<Item> children = new ArrayList<>();
final List<DirItem> dirChildren = new ArrayList<>();
final List<String> relativePath;
int size = 0;
boolean preLoaded = false;
boolean loaded = false;
DirItem(Path path, @Nullable DirItem parent) {
this.path = path;
this.parent = parent;
if (parent != null) {
this.relativePath = new ArrayList<>(parent.relativePath);
relativePath.add(FileUtils.getName(path));
} else {
this.relativePath = Collections.emptyList();
}
}
@Override
int order() {
return 0;
}
@Override
Path getPath() {
return path;
}
@Override
public String getName() {
return FileUtils.getName(path);
}
@Override
String getDescription() {
return i18n("schematics.sub_items", size);
}
@Override
SVG getIcon() {
return SVG.FOLDER;
}
void preLoad() throws IOException {
lock.lock();
try {
if (this.preLoaded) return;
this.size = 0;
this.dirChildren.clear();
try (Stream<Path> stream = Files.list(path)) {
stream.forEach(p -> {
boolean b1 = Files.isDirectory(p);
boolean b2 = Schematic.isFileSchematic(p);
if (b1 || b2) this.size++;
if (b1) {
var child = new DirItem(p, this);
this.dirChildren.add(child);
}
});
}
this.preLoaded = true;
} finally {
lock.unlock();
}
}
void load() {
lock.lock();
try {
if (this.loaded) return;
this.children.clear();
preLoad();
for (var dir : dirChildren) {
try {
dir.preLoad();
} catch (IOException e) {
LOG.warning("Failed to pre-load schematics in " + dir.path, e);
}
this.children.add(dir);
}
try (Stream<Path> stream = Files.list(path)) {
stream.filter(Schematic::isFileSchematic)
.forEach(p -> {
try {
this.children.add(new SchematicItem(p));
} catch (IOException e) {
LOG.warning("Failed to load schematic file: " + p, e);
}
});
}
this.children.sort(Comparator.naturalOrder());
this.loaded = true;
} catch (NoSuchFileException ignored) {
} catch (IOException e) {
LOG.warning("Failed to load schematics in " + path, e);
} finally {
lock.unlock();
}
}
@Override
void onClick() {
navigateTo(this);
}
@Override
void onReveal() {
FXUtils.openFolder(path);
}
@Override
void onDelete() {
try {
FileUtils.deleteDirectory(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete directory: " + path, e);
}
}
}
private final class SchematicItem extends Item {
final Path path;
final Schematic file;
final String name;
final Image image;
private SchematicItem(Path path) throws IOException {
this.path = path;
this.file = Schematic.load(path);
if (file == null) throw new IOException("Unsupported or deleted file: " + path);
if (this.file instanceof LitematicFile lFile) {
String name = lFile.getName();
if (StringUtils.isNotBlank(name) && !"Unnamed".equals(name)) {
this.name = name;
} else {
this.name = FileUtils.getNameWithoutExtension(path);
}
} else {
this.name = FileUtils.getNameWithoutExtension(path);
}
WritableImage image = null;
int[] previewImageData = file.getPreviewImageData();
if (previewImageData != null && previewImageData.length > 0) {
int size = (int) Math.sqrt(previewImageData.length);
if ((size * size) == previewImageData.length) {
image = new WritableImage(size, size);
PixelWriter pixelWriter = image.getPixelWriter();
for (int y = 0, i = 0; y < size; ++y) {
for (int x = 0; x < size; ++x) {
pixelWriter.setArgb(x, y, previewImageData[i++]);
}
}
}
}
this.image = image;
}
@Override
int order() {
return 1;
}
@Override
Path getPath() {
return path;
}
@Override
public String getName() {
return name;
}
@Override
String getDescription() {
return path.getFileName().toString();
}
@Override
SVG getIcon() {
return SVG.SCHEMA;
}
public @Nullable Image getImage() {
return image;
}
Node getIcon(int size) {
if (image == null) {
return super.getIcon(size);
} else {
var imageView = new ImageContainer(size);
imageView.setImage(image);
return imageView;
}
}
@Override
void onClick() {
Controllers.dialog(new SchematicInfoDialog());
}
@Override
void onReveal() {
FXUtils.showFileInExplorer(path);
}
@Override
void onDelete() {
try {
Files.deleteIfExists(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete schematic file: " + path, e);
}
}
private final class SchematicInfoDialog extends JFXDialogLayout {
private final ComponentList details;
private void addDetailItem(String key, Object detail) {
BorderPane borderPane = new BorderPane();
borderPane.setLeft(new Label(key));
borderPane.setRight(new Label(detail.toString()));
details.getContent().add(borderPane);
}
private void updateContent(Schematic file) {
details.getContent().clear();
addDetailItem(i18n("schematics.info.name"), file.getName());
addDetailItem(i18n("schematics.info.type"), translateType(file.getType()));
if (StringUtils.isNotBlank(file.getAuthor()))
addDetailItem(i18n("schematics.info.schematic_author"), translateAuthorName(file.getAuthor()));
if (file.getTimeCreated() != null)
addDetailItem(i18n("schematics.info.time_created"), I18n.formatDateTime(file.getTimeCreated()));
if (file.getTimeModified() != null && !file.getTimeModified().equals(file.getTimeCreated()))
addDetailItem(i18n("schematics.info.time_modified"), I18n.formatDateTime(file.getTimeModified()));
if (file.getRegionCount().isPresent())
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount().getAsInt()));
if (file.getTotalVolume().isPresent())
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume().getAsLong());
if (file.getTotalBlocks().isPresent())
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks().getAsInt());
if (file.getEnclosingSize() != null)
addDetailItem(i18n("schematics.info.enclosing_size"),
String.format("%d x %d x %d", file.getEnclosingSize().x(),
file.getEnclosingSize().y(),
file.getEnclosingSize().z()));
if (StringUtils.isNotBlank(file.getMinecraftVersion()))
addDetailItem(i18n("schematics.info.mc_data_version"), file.getMinecraftVersion());
if (file.getVersion().isPresent())
addDetailItem(i18n("schematics.info.version"),
file.getSubVersion().isPresent()
? "%d.%d".formatted(file.getVersion().getAsInt(), file.getSubVersion().getAsInt())
: file.getVersion().getAsInt()
);
}
SchematicInfoDialog() {
HBox titleBox = new HBox(8);
{
Node icon = getIcon(40);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(getName());
title.setSubtitle(path.getFileName().toString());
titleBox.getChildren().setAll(icon, title);
setHeading(titleBox);
}
{
this.details = new ComponentList();
details.setStyle("-fx-effect: null;");
StackPane detailsContainer = new StackPane();
detailsContainer.getChildren().add(details);
ScrollPane scrollPane = new ScrollPane(detailsContainer);
scrollPane.setFitToWidth(true);
FXUtils.setOverflowHidden(scrollPane, 8);
FXUtils.smoothScrolling(scrollPane);
StackPane.setMargin(scrollPane, new Insets(10, 0, 0, 0));
setBody(scrollPane);
}
{
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.6));
this.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
updateContent(file);
}
}
}
private static final class Cell extends MDListCell<Item> {
private final StackPane left;
private final TwoLineListItem center;
private final HBox right;
private final ImageContainer iconImageView;
private final SVGContainer iconSVGView;
private final BooleanProperty isFileProperty = new SimpleBooleanProperty(this, "isFile", false);
private final BooleanProperty isDirectoryProperty = new SimpleBooleanProperty(this, "isDirectory", false);
private final Tooltip tooltip = new Tooltip();
public Cell(JFXListView<Item> listView) {
super(listView);
var box = new HBox(8);
box.setAlignment(Pos.CENTER_LEFT);
box.setPickOnBounds(false);
{
this.left = new StackPane();
left.setPadding(new Insets(0, 8, 0, 0));
this.iconImageView = new ImageContainer(32);
this.iconSVGView = new SVGContainer(32);
}
{
this.center = new TwoLineListItem();
HBox.setHgrow(center, Priority.ALWAYS);
}
{
this.right = new HBox(8);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
{
var fo = SVG.FOLDER_OPEN.createIcon();
var f = SVG.FOLDER.createIcon();
btnReveal.graphicProperty().bind(Bindings.when(isDirectoryProperty).then(fo).otherwise(f));
var tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.when(isDirectoryProperty).then(i18n("button.reveal_dir")).otherwise(i18n("reveal.in_file_manager")));
FXUtils.installFastTooltip(btnReveal, tooltip);
}
btnReveal.setOnAction(event -> {
Item item = getItem();
if (item != null) item.onReveal();
});
JFXButton btnExplore = FXUtils.newToggleButton4(SVG.EXPLORE); // Change the icon if allows editing
btnExplore.setOnAction(event -> {
Item item = getItem();
if (item instanceof SchematicItem) {
try {
Controllers.navigate(new NBTEditorPage(item.getPath()));
} catch (IOException ignored) { // Should be impossible
}
}
});
btnExplore.visibleProperty().bind(isFileProperty);
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
btnDelete.setOnAction(event -> {
Item item = getItem();
if (item != null) {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
item::onDelete, null);
}
});
right.getChildren().setAll(btnExplore, btnReveal, btnDelete);
}
box.getChildren().setAll(left, center, right);
StackPane.setMargin(box, new Insets(8));
getContainer().getChildren().setAll(box);
onClicked(() -> {
var item = getItem();
if (item != null) item.onClick();
});
}
@Override
protected void updateControl(Item item, boolean empty) {
if (empty || item == null) return;
iconImageView.setImage(null);
isFileProperty.set(item instanceof SchematicItem);
isDirectoryProperty.set(item.isDirectory());
if (item instanceof SchematicItem fileItem && fileItem.getImage() != null) {
iconImageView.setImage(fileItem.getImage());
left.getChildren().setAll(iconImageView);
} else {
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
center.setTitle(item.getName());
center.setSubtitle(item.getDescription());
Path path = item.getPath();
if (path != null) {
tooltip.setText(FileUtils.getAbsolutePath(path));
FXUtils.installSlowTooltip(left, tooltip);
} else {
Tooltip.uninstall(left, tooltip);
}
}
}
private static final class SchematicsPageSkin extends SkinBase<SchematicsPage> {
private final JFXListView<Item> listView;
SchematicsPageSkin(SchematicsPage 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.setSelectionModel(new NoneMultipleSelectionModel<>());
{
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("schematics.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
{
var toolbar = new HBox();
JFXButton btnGoBack = createToolbarButton2("", SVG.ARROW_BACK, skinnable::navigateBack);
btnGoBack.disableProperty().bind(skinnable.isRootProperty());
toolbar.getChildren().setAll(
btnGoBack,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::onRevealSchematicsFolder)
);
FXUtils.setOverflowHidden(toolbar, 8);
root.getContent().add(toolbar);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new Cell(listView));
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
Bindings.bindContent(listView.getItems(), skinnable.getItems());
// 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);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
{
var relPath = new HBox();
relPath.getStyleClass().add("jfx-tool-bar-tip");
relPath.setAlignment(Pos.CENTER_LEFT);
FXUtils.onChangeAndOperate(skinnable.currentDirectoryProperty(), currentDir -> {
relPath.getChildren().clear();
var d = currentDir;
while (d != null) {
if (d != currentDir) {
var box = new HBox(new Text(">"));
box.setPadding(new Insets(3));
relPath.getChildren().add(0, box);
}
var txt = new Text(d.getName());
var box = new HBox(txt);
box.setPadding(new Insets(3));
box.setMouseTransparent(true);
var rippler = new RipplerContainer(box);
var finalD = d;
FXUtils.onClicked(rippler, () -> skinnable.navigateTo(finalD));
relPath.getChildren().add(0, rippler);
d = d.parent;
}
});
var relPathPane = new HBox(relPath);
relPathPane.setPadding(new Insets(2));
root.getContent().add(relPathPane);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
}
}
@@ -12,26 +12,15 @@ import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.ProxyType;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -49,76 +38,9 @@ public class DownloadSettingsPage extends StackPane {
scrollPane.setFitToWidth(true);
getChildren().setAll(scrollPane);
{
var gameContent = new ComponentList();
gameContent.getStyleClass().add("card-non-transparent");
{
Function<DownloadSource, String> converter = source -> switch (source) {
case DEFAULT -> i18n("settings.launcher.download_source.auto");
case OFFICIAL -> i18n("download.provider.official");
case MIRROR -> i18n("download.provider.mirror");
};
Function<DownloadSource, String> descriptionConverter = source -> {
String bundleKey = switch (source) {
case DEFAULT -> "download.provider.balanced.desc";
case OFFICIAL -> "download.provider.official.desc";
case MIRROR -> "download.provider.mirror.desc";
};
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
};
var versionListSourcePane = new LineSelectButton<DownloadSource>();
versionListSourcePane.setTitle(i18n("settings.launcher.version_list_source"));
versionListSourcePane.setNullSafeConverter(converter);
versionListSourcePane.setDescriptionConverter(descriptionConverter);
versionListSourcePane.setItems(DownloadSource.values());
versionListSourcePane.valueProperty().bindBidirectional(settings().versionListSourceProperty());
var downloadSourcePane = new LineSelectButton<DownloadSource>();
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
downloadSourcePane.setNullSafeConverter(converter);
downloadSourcePane.setDescriptionConverter(descriptionConverter);
downloadSourcePane.setItems(DownloadSource.values());
downloadSourcePane.valueProperty().bindBidirectional(settings().fileDownloadSourceProperty());
var defaultAddonSourcePane = new LineSelectButton<String>();
defaultAddonSourcePane.setTitle(i18n("settings.launcher.default_addon_source"));
defaultAddonSourcePane.setNullSafeConverter(key -> I18n.i18n("addon." + key));
defaultAddonSourcePane.setItems("modrinth", "curseforge");
defaultAddonSourcePane.valueProperty().bindBidirectional(settings().defaultAddonSourceProperty());
gameContent.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.game_content")), gameContent);
}
{
var downloadList = new ComponentList();
ComponentSublist fileCommonLocationSublist = new ComponentSublist(() -> {
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
.setChooserTitle(i18n("launcher.cache_directory.choose"))
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY)
.bindBidirectional(settings().commonDirectoryProperty())
));
fileCommonLocation.selectedDataProperty().bindBidirectional(settings().commonDirectoryTypeProperty());
return List.of(fileCommonLocation);
});
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
fileCommonLocationSublist.setHasSubtitle(true);
fileCommonLocationSublist.descriptionProperty().bind(
Bindings.createObjectBinding(() -> Optional.ofNullable(settings().getResolvedCommonDirectory())
.orElse(i18n("launcher.cache_directory.disabled")),
settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory(cleanButton));
fileCommonLocationSublist.setHeaderRight(cleanButton);
ComponentSublist downloadThreadsSublist = new ComponentSublist(() -> {
var downloadThreadsList = new RadioChoiceList<Boolean>();
downloadThreadsList.setChoices(
@@ -129,7 +51,6 @@ public class DownloadSettingsPage extends StackPane {
HBox hbox = new HBox(8);
hbox.setViewOrder(-1);
hbox.setAlignment(Pos.CENTER);
// hbox.setPadding(new Insets(0, 0, 0, 30));
hbox.disableProperty().bind(settings().autoDownloadThreadsProperty());
JFXSlider slider = new JFXSlider(1, 256, 64);
@@ -172,7 +93,7 @@ public class DownloadSettingsPage extends StackPane {
}
}, settings().autoDownloadThreadsProperty(), settings().downloadThreadsProperty()));
downloadList.getContent().addAll(fileCommonLocationSublist, downloadThreadsSublist);
downloadList.getContent().addAll(downloadThreadsSublist);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadList);
}
@@ -335,28 +256,4 @@ public class DownloadSettingsPage extends StackPane {
}
}
@FXThread
private boolean cleaningCache = false;
private void clearCacheDirectory(JFXButton cleanButton) {
if (cleaningCache) return;
String commonDirectory = settings().getResolvedCommonDirectory();
if (commonDirectory != null) {
cleaningCache = true;
cleanButton.setMinWidth(cleanButton.getWidth());
var txt = cleanButton.getText();
cleanButton.setText("");
var spinner = new JFXSpinner();
spinner.setRadius(8);
cleanButton.setGraphic(spinner);
Task.runAsync("Clear Cache Directory", Schedulers.io(), () -> FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache")))
.whenComplete(Schedulers.javafx(), ignored -> {
cleanButton.setGraphic(null);
cleanButton.setText(txt);
cleaningCache = false;
}).setSignificance(Task.TaskSignificance.MINOR)
.start();
}
}
}
@@ -235,154 +235,6 @@ public class PersonalizationPage extends StackPane {
refresh.invalidated(null);
}
/// Returns the display name for the currently selected launcher theme.
private static String getSelectedThemeTitle() {
ThemeReference reference = settings().getSelectedThemeOrDefault();
try {
@Nullable ThemePackManager.InstalledThemePack themePack = ThemePackManager.findInstalled(reference);
if (themePack != null) {
@Nullable Theme theme = themePack.manifest().findTheme(reference.themeId());
if (theme != null) {
return getThemeTitle(themePack.manifest(), theme);
}
}
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to load selected theme", e);
}
return reference.themeId() == null
? reference.packId()
: reference.packId() + " - " + reference.themeId();
}
/// Returns the display title for one theme-pack theme.
private static String getThemeTitle(ThemePackManifest manifest, Theme theme) {
if (manifest.themes().size() == 1 && theme.id() == null) {
return manifest.displayName();
}
String themeName = Objects.requireNonNullElse(
theme.displayName(),
Objects.requireNonNullElse(theme.id(), manifest.displayName()));
return manifest.displayName() + " - " + themeName;
}
/// Sanitizes a package name for use as a file name without changing the package metadata.
private static String sanitizeThemePackFileName(String value) {
StringBuilder builder = new StringBuilder(value.length());
value.trim().codePoints().forEach(codePoint -> {
if (isUnsafeThemePackFileNameCodePoint(codePoint)) {
builder.append('_');
} else {
builder.appendCodePoint(codePoint);
}
});
String sanitized = builder.toString().replaceAll("[.\\s]+$", "");
return sanitized.isBlank() ? "theme-pack" : sanitized;
}
/// Returns `true` when the code point is unsafe in cross-platform file names.
private static boolean isUnsafeThemePackFileNameCodePoint(int codePoint) {
return !Character.isValidCodePoint(codePoint)
|| Character.isISOControl(codePoint)
|| codePoint == '/'
|| codePoint == '\\'
|| codePoint == ':'
|| codePoint == '<'
|| codePoint == '>'
|| codePoint == '"'
|| codePoint == '|'
|| codePoint == '?'
|| codePoint == '*'
|| codePoint == 0xfffd
|| codePoint == 0xfffe
|| codePoint == 0xffff;
}
/// Shows a theme-pack operation error dialog.
private static void showThemePackError(String title, Exception exception) {
Controllers.dialog(
title + "\n\n" + StringUtils.getStackTrace(exception),
i18n("message.error"),
MessageType.ERROR);
}
/// Asks for theme-pack metadata and then saves the current launcher appearance as a theme-pack file.
private void exportCurrentThemePack() {
Instant exportTimestamp = Instant.now();
String defaultPackId = "com.example.hmcl.theme-pack." + UUIDs.toCompactString(UUIDs.generateV7(exportTimestamp));
String defaultPackName = LocalDateTime.ofInstant(exportTimestamp, ZoneId.systemDefault()).format(EXPORTED_THEME_NAME_FORMATTER);
String accountName = null;
{
var currentAccount = Accounts.getSelectedAccount();
if (currentAccount != null) accountName = currentAccount.getProfileName();
}
if (StringUtils.isBlank(accountName)) accountName = System.getProperty("user.name").trim();
String defaultAuthorName = StringUtils.isBlank(accountName) ? "Unknown" : accountName;
PromptDialogPane.Builder.StringQuestion packNameQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.name"),
defaultPackName)
.setPromptText(defaultPackName);
PromptDialogPane.Builder.StringQuestion versionQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.version"),
ThemePackManager.CURRENT_THEME_PACK_VERSION)
.setPromptText(ThemePackManager.CURRENT_THEME_PACK_VERSION);
PromptDialogPane.Builder.StringQuestion authorNameQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.author"),
defaultAuthorName)
.setPromptText(defaultAuthorName);
Controllers.prompt(new PromptDialogPane.Builder(i18n("theme_pack.export.title"), (questions, handler) -> handler.resolve())
.addQuestion(packNameQuestion)
.addQuestion(versionQuestion)
.addQuestion(authorNameQuestion)).thenAccept(questions -> exportCurrentThemePack(
defaultPackId,
StringUtils.isBlank(versionQuestion.getValue())
? ThemePackManager.CURRENT_THEME_PACK_VERSION
: versionQuestion.getValue().trim(),
StringUtils.isBlank(packNameQuestion.getValue()) ? defaultPackName : packNameQuestion.getValue().trim(),
StringUtils.isBlank(authorNameQuestion.getValue()) ? defaultAuthorName : authorNameQuestion.getValue().trim()));
}
/// Saves current launcher appearance as a theme-pack file with the given package metadata.
private void exportCurrentThemePack(String packId, String version, String packName, String authorName) {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("theme_pack.export.title"));
String initialFileName = sanitizeThemePackFileName(packName);
chooser.setInitialFileName(
(FileUtils.isNameValid(initialFileName) ? initialFileName : "theme-pack")
+ (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ? "" : ThemePackExporter.FILE_EXTENSION)
);
chooser.getExtensionFilters().setAll(
new FileChooser.ExtensionFilter(i18n("theme_pack.file"), "*" + ThemePackExporter.FILE_EXTENSION));
@Nullable Path output = Controllers.showSaveDialog(chooser);
if (output == null) {
return;
}
String fileName = output.getFileName().toString();
if (!fileName.toLowerCase(Locale.ROOT).endsWith(ThemePackExporter.FILE_EXTENSION)) {
output = output.resolveSibling(fileName + ThemePackExporter.FILE_EXTENSION);
}
try {
ThemePackManager.exportCurrent(
output,
packId,
version,
packName,
authorName);
Controllers.dialog(
i18n("theme_pack.export.success", output),
i18n("message.success"),
MessageType.SUCCESS);
} catch (IOException | RuntimeException e) {
showThemePackError(i18n("theme_pack.export.failed"), e);
}
}
/// Creates the launcher appearance settings page.
public PersonalizationPage() {
VBox content = new VBox(10);
@@ -393,26 +245,6 @@ public class PersonalizationPage extends StackPane {
scrollPane.setFitToWidth(true);
getChildren().setAll(scrollPane);
ComponentList themeList = new ComponentList();
{
LineButton currentThemeButton = LineButton.createNavigationButton();
currentThemeButton.setTitle(i18n("theme_pack.theme"));
Runnable updateCurrentThemeButton = () -> currentThemeButton.setSubtitle(getSelectedThemeTitle());
FXUtils.onChange(settings().selectedThemeProperty(), ignored -> updateCurrentThemeButton.run());
updateCurrentThemeButton.run();
currentThemeButton.setOnAction(event ->
Controllers.navigateForward(new ThemePackManagementPage(updateCurrentThemeButton)));
themeList.getContent().add(currentThemeButton);
LineButton exportThemeButton = new LineButton();
exportThemeButton.setTitle(i18n("theme_pack.export"));
exportThemeButton.setSubtitle(i18n("theme_pack.export.subtitle"));
exportThemeButton.setTrailingIcon(SVG.ARCHIVE);
exportThemeButton.setOnAction(event -> exportCurrentThemePack());
themeList.getContent().add(exportThemeButton);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.theme")), themeList);
ComponentList themeAppearanceList = new ComponentList();
ComponentList backgroundLoadingList = new ComponentList();
@@ -17,17 +17,13 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem;
import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
@@ -35,7 +31,6 @@ import java.nio.file.Path;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays the home content and launcher navigation sidebar.
public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
@@ -85,7 +80,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
gameListItem.setPrepareAction(Controllers::prepareGameListPage);
}
// forth item in left sidebar
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
@@ -98,7 +92,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
}
// the left sidebar
getSidebar()
.startCategory(i18n("account").toUpperCase(Locale.ROOT))
@@ -123,21 +116,13 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
if (mainPage == null) {
MainPage mainPage = new MainPage();
FXUtils.applyDragListener(mainPage,
file -> ModpackHelper.isFileModpackByExtension(file) || NBTFileType.isNBTFileByExtension(file) || "json".equalsIgnoreCase(FileUtils.getExtension(file)),
file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getExtension(file)),
modpacks -> {
Path file = modpacks.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file),
i18n("install.modpack"));
} else if (NBTFileType.isNBTFileByExtension(file)) {
try {
Controllers.navigate(new NBTEditorPage(file));
} catch (Throwable e) {
LOG.warning("Fail to open nbt file", e);
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
@@ -1,729 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.*;
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.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.*;
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;
/// Shows installed theme packs and common theme-pack actions.
@NotNullByDefault
public final class ThemePackManagementPage extends ListPageBase<ThemePackManager.InstalledThemePack> implements DecoratorPage {
/// The icon container size used by theme-pack icons.
private static final double ICON_SIZE = 40;
/// The requested decoded icon image size used for HiDPI displays.
private static final int ICON_IMAGE_SIZE = (int) (ICON_SIZE * 2);
/// The decorator title state for this page.
private final ObjectProperty<State> state = new SimpleObjectProperty<>(State.fromTitle(i18n("theme_pack.manage")));
/// Source list containing all installed theme packs.
private final ObservableList<ThemePackManager.InstalledThemePack> sourceList =
FXCollections.observableArrayList();
/// Filtered list shown by the current search query.
private final FilteredList<ThemePackManager.InstalledThemePack> filteredList = new FilteredList<>(sourceList);
/// Page-scoped icon image cache cleared when installed theme packs are reloaded.
private final IconCache iconCache = new IconCache();
/// Callback invoked after the installed theme-pack set changes.
private final Runnable onThemePacksChanged;
/// Creates the theme-pack management page and loads installed packages.
///
/// @param onThemePacksChanged callback invoked after importing or deleting a theme pack
public ThemePackManagementPage(Runnable onThemePacksChanged) {
getStyleClass().add("gray-background");
this.onThemePacksChanged = Objects.requireNonNull(onThemePacksChanged);
setItems(filteredList);
setOnFailedAction(event -> refreshThemePacks());
refreshThemePacks();
FXUtils.applyDragListener(this, it -> "hmcl-theme".equals(FileUtils.getExtension(it)), paths -> paths.forEach(this::importThemePackImpl));
}
/// Creates the default list skin for the management page.
@Override
protected Skin<?> createDefaultSkin() {
return new ThemePackManagementPageSkin(this);
}
/// Returns the decorator title state for this page.
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
/// Reloads installed theme packs from disk.
private void refreshThemePacks() {
iconCache.clear();
try {
List<ThemePackManager.InstalledThemePack> themePacks = ThemePackManager.listInstalled();
sourceList.setAll(themePacks);
setFailedReason(themePacks.isEmpty() ? i18n("theme_pack.empty") : null);
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to load installed theme packs", e);
sourceList.clear();
setFailedReason(i18n("theme_pack.load.failed"));
}
}
/// Creates a predicate used by the theme-pack search field.
private Predicate<ThemePackManager.InstalledThemePack> createPredicate(String searchText) {
if (StringUtils.isBlank(searchText)) {
return themePack -> true;
}
String query = searchText.toLowerCase(Locale.ROOT);
return themePack -> {
ThemePackManifest manifest = themePack.manifest();
return containsIgnoreCase(manifest.displayName(), query)
|| containsIgnoreCase(manifest.id(), query)
|| containsIgnoreCase(manifest.version(), query)
|| containsIgnoreCase(manifest.displayDescription(), query)
|| manifest.authors().stream().anyMatch(author -> containsIgnoreCase(author.displayName(), query))
|| manifest.themes().stream()
.flatMap(theme -> theme.authors().stream())
.anyMatch(author -> containsIgnoreCase(author.displayName(), query));
};
}
/// Opens a theme-pack file and installs it.
private void importThemePack() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("theme_pack.import.title"));
chooser.getExtensionFilters().setAll(
new FileChooser.ExtensionFilter(i18n("theme_pack.file"), "*" + ThemePackExporter.FILE_EXTENSION));
@Nullable Path file = Controllers.showOpenDialog(chooser);
if (file == null) {
return;
}
importThemePackImpl(file);
}
private void importThemePackImpl(Path file) {
ThemePackManager.InstalledThemePack themePack;
try {
themePack = ThemePackManager.install(file);
refreshThemePacks();
} catch (IOException | RuntimeException e) {
String title = i18n("theme_pack.import.failed");
Controllers.dialog(
title + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"),
MessageType.ERROR);
return;
}
onThemePacksChanged.run();
Controllers.showToast(i18n("theme_pack.import.success", themePack.manifest().displayName()));
}
/// Shows the installed package file in the platform file manager.
private static void revealThemePack(ThemePackManager.InstalledThemePack themePack) {
if (themePack.builtin()) {
return;
}
@Nullable Path file = themePack.file();
if (file != null) {
FXUtils.showFileInExplorer(file);
}
}
/// Asks for confirmation and deletes an installed theme pack.
private void deleteThemePack(ThemePackManager.InstalledThemePack themePack) {
if (themePack.builtin()) {
return;
}
Controllers.confirm(
i18n("theme_pack.delete.confirm", themePack.manifest().displayName()),
i18n("theme_pack.delete"),
MessageType.WARNING,
() -> deleteThemePackConfirmed(themePack),
null);
}
/// Deletes an installed theme pack after confirmation.
private void deleteThemePackConfirmed(ThemePackManager.InstalledThemePack themePack) {
try {
ThemePackManager.uninstall(themePack);
refreshThemePacks();
onThemePacksChanged.run();
Controllers.showToast(i18n("theme_pack.delete.success", themePack.manifest().displayName()));
} catch (IOException | RuntimeException e) {
Controllers.dialog(
i18n("theme_pack.delete.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"),
MessageType.ERROR);
}
}
/// Handles a theme-pack row click.
private void selectThemePack(ThemePackManager.InstalledThemePack themePack) {
ThemePackManifest manifest = themePack.manifest();
if (manifest.themes().size() == 1 && manifest.themes().get(0).id() == null) {
confirmApplyTheme(themePack, manifest.themes().get(0));
} else {
Controllers.dialog(new ThemePackInfoDialog(this, themePack));
}
}
/// Asks for confirmation before applying one theme.
private void confirmApplyTheme(ThemePackManager.InstalledThemePack themePack, Theme theme) {
Controllers.confirm(
i18n("theme_pack.apply.confirm", getThemeTitle(themePack.manifest(), theme)),
i18n("theme_pack.apply"),
MessageType.QUESTION,
() -> applyTheme(themePack, theme),
null);
}
/// Applies one theme after confirmation.
private void applyTheme(ThemePackManager.InstalledThemePack themePack, Theme theme) {
try {
if (Themes.shouldWaitForThemeBackground()) {
Controllers.taskDialog(
Themes.applyTheme(themePack, theme),
i18n("launcher.background.loading"),
TaskCancellationAction.NO_CANCEL);
} else {
ThemePackManager.apply(themePack, theme);
}
onThemePacksChanged.run();
} catch (IOException | RuntimeException e) {
Controllers.dialog(
i18n("theme_pack.apply.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"),
MessageType.ERROR);
}
}
/// Returns comma-separated author display names.
private static String getAuthorDisplayNames(List<ThemePackAuthor> authors) {
return authors.stream()
.map(ThemePackAuthor::displayName)
.filter(author -> !StringUtils.isBlank(author))
.collect(Collectors.joining(", "));
}
/// Returns the display title for one theme-pack theme.
private static String getThemeTitle(ThemePackManifest manifest, Theme theme) {
if (manifest.themes().size() == 1 && theme.id() == null) {
return manifest.displayName();
}
return manifest.displayName() + " - " + getThemeDisplayName(manifest, theme);
}
/// Returns the display name for one theme inside a theme list.
private static String getThemeDisplayName(ThemePackManifest manifest, Theme theme) {
return Objects.requireNonNullElse(
theme.displayName(),
Objects.requireNonNullElse(theme.id(), manifest.displayName()));
}
/// Returns whether a nullable value contains the lower-cased query.
private static boolean containsIgnoreCase(@Nullable String value, String query) {
return value != null && value.toLowerCase(Locale.ROOT).contains(query);
}
/// Creates an icon node for a theme-pack asset path.
private Node createIconNode(
ThemePackManager.InstalledThemePack themePack,
@Nullable String icon,
SVG fallback) {
ImageContainer imageContainer = new ImageContainer(ICON_SIZE);
SVGContainer fallbackIcon = createFallbackIcon(fallback);
updateIcon(imageContainer, fallbackIcon, themePack, icon);
StackPane container = new StackPane(imageContainer, fallbackIcon);
container.setMouseTransparent(true);
return container;
}
/// Creates a vector fallback icon.
private static SVGContainer createFallbackIcon(SVG fallback) {
SVGContainer fallbackIcon = new SVGContainer(fallback, ICON_SIZE);
fallbackIcon.setMouseTransparent(true);
return fallbackIcon;
}
/// Updates reusable icon nodes for the supplied theme pack.
private void updateIcon(
ImageContainer imageContainer,
SVGContainer fallbackIcon,
ThemePackManager.InstalledThemePack themePack,
@Nullable String icon) {
@Nullable Image image = getIconImage(themePack, icon);
imageContainer.setImage(image);
fallbackIcon.setVisible(image == null);
}
/// Returns the icon image for a fixed-size icon container.
private @Nullable Image getIconImage(
ThemePackManager.InstalledThemePack themePack,
@Nullable String icon) {
@Nullable Image image = null;
if (!StringUtils.isBlank(icon)) {
image = iconCache.getThemePackIcon(themePack.location(), icon);
}
return image != null || !themePack.builtin() ? image : iconCache.getDefaultBuiltinIcon();
}
/// Cache key for one decoded icon image.
///
/// @param location the source theme-pack location
/// @param icon the theme-pack asset path
@NotNullByDefault
private record IconCacheKey(
ThemePackManager.ThemePackLocation location,
String icon) {
/// Creates an icon cache key.
///
/// @param location the source theme-pack location
/// @param icon the theme-pack asset path
private IconCacheKey {
Objects.requireNonNull(location);
Objects.requireNonNull(icon);
}
}
/// Page-scoped cache for decoded icon images.
@NotNullByDefault
private static final class IconCache {
/// Decoded theme-pack icons keyed by location and asset path.
private final Map<IconCacheKey, Optional<Image>> icons = new HashMap<>();
/// Decoded built-in fallback icon.
private @Nullable Image defaultBuiltinIcon;
/// Clears all decoded images held by this cache.
private void clear() {
icons.clear();
defaultBuiltinIcon = null;
}
/// Returns a theme-pack icon image, or `null` when it cannot be loaded.
private @Nullable Image getThemePackIcon(
ThemePackManager.ThemePackLocation location,
String icon) {
IconCacheKey key = new IconCacheKey(location, icon);
return icons.computeIfAbsent(key, this::loadThemePackIcon).orElse(null);
}
/// Returns the built-in default icon for built-in theme packs.
private Image getDefaultBuiltinIcon() {
if (defaultBuiltinIcon == null) {
defaultBuiltinIcon = FXUtils.newBuiltinImage(
"/assets/img/[email protected]",
ICON_IMAGE_SIZE,
ICON_IMAGE_SIZE,
true,
true);
}
return defaultBuiltinIcon;
}
/// Loads and decodes one theme-pack icon image.
private Optional<Image> loadThemePackIcon(IconCacheKey key) {
try {
ThemePackResource resource = ThemePackManager.resolveInstalledAsset(key.location(), key.icon());
return Optional.of(FXUtils.loadImage(
resource.openStream(),
resource.name(),
ICON_IMAGE_SIZE,
ICON_IMAGE_SIZE,
true,
true));
} catch (Exception e) {
LOG.warning("Failed to load theme-pack icon: " + key.icon(), e);
return Optional.empty();
}
}
}
/// Dialog showing all themes declared by one installed theme pack.
@NotNullByDefault
private static final class ThemePackInfoDialog extends JFXDialogLayout {
/// Creates the theme-pack information dialog.
///
/// @param themePack the installed theme pack to display
private ThemePackInfoDialog(ThemePackManagementPage page, ThemePackManager.InstalledThemePack themePack) {
ThemePackManifest manifest = themePack.manifest();
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
Node icon = page.createIconNode(themePack, manifest.icon(), SVG.PACKAGE2);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(manifest.displayName());
title.setSubtitle(manifest.id());
title.addTag(i18n("theme_pack.version", manifest.version()));
title.addTag(i18n("theme_pack.themes", manifest.themes().size()));
String authors = getAuthorDisplayNames(manifest.authors());
if (!StringUtils.isBlank(authors)) {
title.addTag(i18n("archive.author") + ": " + authors);
}
TwoLineListItem.setMargin(icon, new Insets(0, 8, 0, 0));
title.setLeading(icon);
setHeading(title);
ComponentList themes = new ComponentList();
themes.getStyleClass().add("no-padding");
for (Theme theme : manifest.themes()) {
TwoLineListItem item = new TwoLineListItem();
item.setTitle(getThemeDisplayName(manifest, theme));
@Nullable String description = theme.displayDescription();
item.setSubtitle(StringUtils.isBlank(description) ? null : description);
if (theme.id() != null) {
item.addTag(i18n("theme_pack.theme.id", theme.id()));
}
List<ThemePackAuthor> themeAuthors = theme.authors().isEmpty() ? manifest.authors() : theme.authors();
String authorNames = getAuthorDisplayNames(themeAuthors);
if (!StringUtils.isBlank(authorNames)) {
item.addTag(i18n("archive.author") + ": " + authorNames);
}
HBox row = new HBox(8);
row.setAlignment(Pos.CENTER_LEFT);
row.setPadding(new Insets(10, 16, 10, 16));
Node themeIcon = page.createIconNode(themePack, theme.icon(), SVG.STYLE);
HBox.setHgrow(item, Priority.ALWAYS);
item.setMouseTransparent(true);
row.getChildren().setAll(themeIcon, item);
var rowRippler = new RipplerContainer(row);
rowRippler.setCursor(Cursor.HAND);
ComponentList.setNoPadding(rowRippler);
FXUtils.onClicked(rowRippler, () -> {
fireEvent(new DialogCloseEvent());
page.confirmApplyTheme(themePack, theme);
});
themes.getContent().add(rowRippler);
}
ScrollPane scrollPane = new ScrollPane(themes);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPrefViewportWidth(520);
scrollPane.setPrefViewportHeight(Math.min(360, manifest.themes().size() * 86));
scrollPane.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.55));
StackPane body = new StackPane(scrollPane);
body.setPadding(new Insets(10, 0, 0, 0));
setBody(body);
JFXButton okButton = new JFXButton(i18n("button.ok"));
okButton.getStyleClass().add("dialog-accept");
okButton.setOnAction(event -> fireEvent(new DialogCloseEvent()));
setActions(okButton);
onEscPressed(this, okButton::fire);
}
}
/// Skin for the theme-pack management list page.
@NotNullByDefault
private static final class ThemePackManagementPageSkin extends SkinBase<ThemePackManagementPage> {
/// Toolbar container used to switch between normal and search actions.
private final TransitionPane toolbarPane = new TransitionPane();
/// Search toolbar.
private final HBox searchBar = new HBox();
/// Toolbar shown during normal browsing.
private final HBox toolbarNormal = new HBox();
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Search input.
private final JFXTextField searchField = new JFXTextField();
/// List of installed theme packs.
private final JFXListView<ThemePackManager.InstalledThemePack> listView = new JFXListView<>();
/// Creates the management page skin.
///
/// @param skinnable the page controlled by this skin
private ThemePackManagementPageSkin(ThemePackManagementPage 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");
initializeToolbar(skinnable, root);
initializeList(skinnable, root);
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
/// Initializes toolbar actions and search.
private void initializeToolbar(ThemePackManagementPage skinnable, ComponentList root) {
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(event ->
skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
toolbarNormal.setPickOnBounds(false);
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshThemePacks),
createToolbarButton2(i18n("theme_pack.import"), SVG.FILE_OPEN, skinnable::importThemePack),
createToolbarButton2(i18n("theme_pack.directory"), SVG.FOLDER_OPEN,
() -> FXUtils.openFolder(ThemePackManager.THEME_PACKS_DIRECTORY)),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
/// Initializes the installed theme-pack list.
private void initializeList(ThemePackManagementPage skinnable, ComponentList root) {
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
center.failedReasonProperty().bind(skinnable.failedReasonProperty());
center.onFailedActionProperty().bind(skinnable.onFailedActionProperty());
listView.setPadding(Insets.EMPTY);
listView.setCellFactory(x -> new ThemePackItemCell(skinnable));
listView.setItems(skinnable.getItems());
listView.getStyleClass().add("no-horizontal-scrollbar");
ignoreEvent(listView, KeyEvent.KEY_PRESSED, event -> event.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
/// Switches the visible toolbar.
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
}
/// List cell that renders one installed theme pack.
@NotNullByDefault
private static final class ThemePackItemCell extends ListCell<ThemePackManager.@Nullable InstalledThemePack> {
/// The owning management page.
private final ThemePackManagementPage page;
/// Root graphic reused by this cell.
private final RipplerContainer graphic;
/// The text content shown for the current theme pack.
private final TwoLineListItem content = new TwoLineListItem();
/// Left-side package icon container.
private final StackPane icon = new StackPane();
/// Reused icon image node.
private final ImageContainer iconImage = new ImageContainer(ICON_SIZE);
/// Reused fallback icon.
private final SVGContainer iconFallback = createFallbackIcon(SVG.PACKAGE2);
/// Right-side action container.
private final HBox right = new HBox();
/// Shows the package file.
private final JFXButton revealButton;
/// Deletes the package.
private final JFXButton deleteButton;
/// Creates a reusable theme-pack list cell.
///
/// @param page the owning management page
private ThemePackItemCell(ThemePackManagementPage page) {
this.page = page;
content.getStyleClass().add("md-list-cell");
content.setPadding(new Insets(8));
content.setCursor(Cursor.HAND);
var ripplerContainer = new RipplerContainer(content);
this.graphic = ripplerContainer;
icon.setMouseTransparent(true);
icon.getChildren().setAll(iconImage, iconFallback);
TwoLineListItem.setMargin(icon, new Insets(0, 8, 0, 0));
content.setLeading(icon);
FXUtils.onClicked(ripplerContainer, () -> {
ThemePackManager.@Nullable InstalledThemePack themePack = getItem();
if (themePack != null) {
page.selectThemePack(themePack);
}
});
right.setAlignment(Pos.CENTER_RIGHT);
right.setPickOnBounds(false);
content.setTrailing(right);
revealButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
revealButton.setOnAction(event -> {
ThemePackManager.@Nullable InstalledThemePack themePack = getItem();
if (themePack != null) {
revealThemePack(themePack);
}
});
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
deleteButton = FXUtils.newToggleButton4(SVG.DELETE);
deleteButton.setOnAction(event -> {
ThemePackManager.@Nullable InstalledThemePack themePack = getItem();
if (themePack != null) {
page.deleteThemePack(themePack);
}
});
FXUtils.installFastTooltip(deleteButton, i18n("theme_pack.delete"));
}
/// Updates this cell for one installed theme pack.
@Override
protected void updateItem(ThemePackManager.@Nullable InstalledThemePack themePack, boolean empty) {
ThemePackManager.@Nullable InstalledThemePack currentItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(themePack, empty);
if (Objects.equals(getItem(), currentItem) && oldEmpty == empty) return;
this.graphic.resetFeedback();
iconImage.setImage(null);
iconFallback.setVisible(false);
if (empty || themePack == null) {
content.getTags().clear();
setGraphic(null);
return;
}
setGraphic(graphic);
ThemePackManifest manifest = themePack.manifest();
page.updateIcon(iconImage, iconFallback, themePack, manifest.icon());
content.setTitle(manifest.displayName());
@Nullable String description = manifest.displayDescription();
content.setSubtitle(StringUtils.isBlank(description) ? manifest.id() : description);
var tags = new ArrayList<Tag>(3);
if (themePack.builtin()) {
tags.add(new Tag(i18n("theme_pack.builtin")));
}
tags.add(new Tag(i18n("theme_pack.version", manifest.version())));
tags.add(new Tag(i18n("theme_pack.themes", manifest.themes().size())));
content.getTags().setAll(tags);
if (themePack.builtin()) {
right.getChildren().clear();
} else {
right.getChildren().setAll(revealButton, deleteButton);
}
}
}
}
@@ -1,123 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Skin;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.control.skin.TreeViewSkin;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Glavo
*/
public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state;
private final Path file;
private final NBTFileType type;
private final BorderPane root = new BorderPane();
public NBTEditorPage(Path file) throws IOException {
getStyleClass().add("gray-background");
this.state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("nbt.title", file.toString())));
this.file = file;
//noinspection DataFlowIssue
this.type = NBTFileType.ofFile(file);
if (type == null) {
throw new IOException("Unknown type of file " + file);
}
setContent(root);
setLoading(true);
HBox actions = new HBox(8);
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
saveButton.setOnAction(e -> {
try {
save();
} catch (IOException ex) {
LOG.warning("Failed to save NBT file", ex);
Controllers.dialog(i18n("nbt.save.failed") + "\n\n" + StringUtils.getStackTrace(ex));
}
});
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
actions.getChildren().setAll(saveButton, cancelButton);
Task.supplyAsync(() -> type.read(file))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
setLoading(false);
NBTTreeItem root = new NBTTreeItem(result, FileUtils.getName(file));
var view = new TreeView<>(root) {
@Override
protected Skin<?> createDefaultSkin() {
return new TreeViewSkin<>(this) {
{
FXUtils.smoothScrolling(getVirtualFlow());
}
};
}
};
view.setCellFactory(ignored -> new NBTTreeCell());
view.addEventHandler(TreeItem.<NBTElement>branchExpandedEvent(), event -> {
TreeItem<NBTElement> item = event.getTreeItem();
if (item.getValue() instanceof Tag && item.getChildren().size() == 1)
item.getChildren().get(0).setExpanded(true);
});
root.setExpanded(true);
BorderPane.setMargin(view, new Insets(10));
onEscPressed(view, cancelButton::fire);
this.root.setCenter(view);
} else {
LOG.warning("Fail to open nbt file", exception);
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(exception), null, MessageDialogPane.MessageType.WARNING, cancelButton::fire);
}
}).start();
}
public void save() throws IOException {
// TODO
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
}
@@ -1,62 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.chunk.ChunkRegion;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
/// @author Glavo
public enum NBTFileType {
COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") {
@Override
public Tag read(Path file) throws IOException {
return NBTCodec.of().readTag(file);
}
},
ANVIL("mca") {
@Override
public NBTElement read(Path file) throws IOException {
return NBTCodec.of().readRegion(file);
}
},
REGION("mcr") {
@Override
public ChunkRegion read(Path file) throws IOException {
return NBTCodec.of().readRegion(file);
}
};
static final NBTFileType[] types = values();
public static boolean isNBTFileByExtension(Path file) {
return NBTFileType.ofFile(file) != null;
}
public static @Nullable NBTFileType ofFile(Path file) {
String ext = FileUtils.getExtension(file);
for (NBTFileType type : types) {
for (String extension : type.extensions) {
if (extension.equals(ext))
return type;
}
}
return null;
}
private final String[] extensions;
NBTFileType(String... extensions) {
this.extensions = extensions;
}
public abstract NBTElement read(Path file) throws IOException;
}
@@ -1,114 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import javafx.scene.control.TreeCell;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.chunk.Chunk;
import org.glavo.nbt.chunk.ChunkRegion;
import org.glavo.nbt.tag.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class NBTTreeCell extends TreeCell<@Nullable NBTElement> {
private static @Nullable Image getIcon(NBTElement element) {
if (element instanceof Tag tag) {
TagType<?> type = tag.getType();
if (type == TagType.BYTE) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Byte.png");
} else if (type == TagType.SHORT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Short.png");
} else if (type == TagType.INT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Int.png");
} else if (type == TagType.LONG) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Long.png");
} else if (type == TagType.FLOAT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Float.png");
} else if (type == TagType.DOUBLE) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Double.png");
} else if (type == TagType.STRING) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_String.png");
} else if (type == TagType.BYTE_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Byte_Array.png");
} else if (type == TagType.INT_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Int_Array.png");
} else if (type == TagType.LONG_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Long_Array.png");
} else if (type == TagType.LIST) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_List.png");
} else if (type == TagType.COMPOUND) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Compound.png");
} else {
return null;
}
} else if (element instanceof ChunkRegion)
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_List.png");
else if (element instanceof Chunk)
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Compound.png");
else
return null;
}
private final ImageView imageView;
public NBTTreeCell() {
imageView = new ImageView();
imageView.setFitHeight(16);
imageView.setFitWidth(16);
setGraphic(imageView);
}
private NBTTreeItem getNBTTreeItem() {
return (NBTTreeItem) getTreeItem();
}
@Override
public void updateItem(@Nullable NBTElement item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
imageView.setImage(null);
setText(null);
return;
}
imageView.setImage(getIcon(item));
String name = getNBTTreeItem().getOverrideName();
if (name == null) {
NBTElement value = getNBTTreeItem().getValue();
if (value instanceof Tag tag) {
if (tag.getParent() instanceof ListTag<?>) {
name = Integer.toString(tag.getIndex());
} else {
name = tag.getName();
}
} else if (value instanceof Chunk chunk) {
name = "Chunk (" + chunk.getLocalX() + ", " + chunk.getLocalZ() + ")";
} else {
name = "";
}
}
String text;
if (item instanceof ParentTag<?> parentTag) {
text = i18n("nbt.entries", parentTag.size());
} else if (item instanceof ValueTag<?> valueTag) {
text = valueTag.getAsString();
} else {
text = null;
}
if (text == null) {
setText(name);
} else {
setText(name + ": " + text);
}
}
}
@@ -1,53 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import javafx.collections.ObservableList;
import javafx.scene.control.TreeItem;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.NBTParent;
import org.glavo.nbt.chunk.Chunk;
import org.glavo.nbt.tag.*;
import org.jetbrains.annotations.Nullable;
/// @author Glavo
public final class NBTTreeItem extends TreeItem<NBTElement> {
private final @Nullable String overrideName;
public NBTTreeItem(NBTElement value) {
this(value, null);
}
public NBTTreeItem(NBTElement value, @Nullable String overrideName) {
super(value);
this.overrideName = overrideName;
}
@Override
public boolean isLeaf() {
return !(getValue() instanceof NBTParent<?> parent) || parent.isEmpty();
}
private boolean needInitChildren = true;
@Override
public ObservableList<TreeItem<NBTElement>> getChildren() {
ObservableList<TreeItem<NBTElement>> children = super.getChildren();
if (needInitChildren) {
needInitChildren = false;
if (getValue() instanceof Chunk chunk) {
if (chunk.getRootTag() != null) {
children.setAll(chunk.getRootTag().stream().map(NBTTreeItem::new).toList());
}
} else if (getValue() instanceof NBTParent<?> parent) {
children.setAll(parent.stream().map(NBTTreeItem::new).toList());
}
}
return children;
}
public @Nullable String getOverrideName() {
return overrideName;
}
}
@@ -1,7 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
@NotNullByDefault
package org.jackhuang.hmcl.ui.nbt;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,6 +1,6 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.countly;
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.StringUtils;
@@ -5,7 +5,6 @@ package org.jackhuang.hmcl.util;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import org.jackhuang.hmcl.countly.CrashReport;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.CrashWindow;
@@ -346,18 +346,14 @@ public final class GameDirectoriesTest {
GameDirectoryManager.init();
GameDirectory localGameDirectory = assertSingleDefaultGameDirectory(localDirectories, PortablePath.of(".minecraft"));
GameDirectory userGameDirectory = assertSingleDefaultGameDirectory(
userDirectories,
PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY));
assertTrue(userDirectories.getGameDirectories().isEmpty());
assertEquals(GameDirectoryManager.DEFAULT_GAME_DIRECTORY_ID, localGameDirectory.getId());
assertEquals(GameDirectoryManager.HOME_GAME_DIRECTORY_ID, userGameDirectory.getId());
assertEquals(2, GameDirectoryManager.getGameDirectories().size());
assertEquals(1, GameDirectoryManager.getGameDirectories().size());
assertTrue(GameDirectoryManager.getGameDirectories().contains(localGameDirectory));
assertTrue(GameDirectoryManager.getGameDirectories().contains(userGameDirectory));
}
}
/// Tests that newly created stores use the stable IDs for their built-in game directories.
/// Tests that newly created stores use the stable ID for the fixed workspace game directory.
@Test
public void createsBuiltInGameDirectoriesWithStableIdsForNewStores() throws ReflectiveOperationException {
GameDirectories userDirectories = new GameDirectories();
@@ -371,11 +367,8 @@ public final class GameDirectoriesTest {
GameDirectoryManager.init();
GameDirectory localGameDirectory = assertSingleDefaultGameDirectory(localDirectories, PortablePath.of(".minecraft"));
GameDirectory userGameDirectory = assertSingleDefaultGameDirectory(
userDirectories,
PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY));
assertTrue(userDirectories.getGameDirectories().isEmpty());
assertEquals(GameDirectoryManager.DEFAULT_GAME_DIRECTORY_ID, localGameDirectory.getId());
assertEquals(GameDirectoryManager.HOME_GAME_DIRECTORY_ID, userGameDirectory.getId());
}
}