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
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:
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
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user