Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdacd65473 | ||
|
|
f176fbd1fd | ||
|
|
9d43a6fc0f | ||
|
|
23811dad7f | ||
|
|
e792bfc554 | ||
|
|
d6b4af2d96 | ||
|
|
2a915460ae | ||
|
|
e9b577f66f | ||
|
|
e12c7e53f6 | ||
|
|
61caf8a925 | ||
|
|
a7123a389e | ||
|
|
3a6e80da0e | ||
|
|
2c8446fa33 | ||
|
|
21fcac1b0c | ||
|
|
095d71fea7 | ||
|
|
07ff244cd2 | ||
|
|
0269e54b95 | ||
|
|
06ef27f0e0 | ||
|
|
b3f649c026 | ||
|
|
0531d0cbc2 | ||
|
|
84b6d50421 | ||
|
|
dfdad64e93 | ||
|
|
323d09b104 | ||
|
|
0f85782312 | ||
|
|
7b79945391 | ||
|
|
441ce7220f | ||
|
|
e8df66dcc0 | ||
|
|
f7420e284d | ||
|
|
a2fa7ed5ab |
No files matched your search
@@ -1,21 +0,0 @@
|
||||
name: Check Codes Comment
|
||||
|
||||
# Disabled on this Gitea fork: the upstream flow depends on reviewdog
|
||||
# (missing from git.nest.potatolab.uk) and a checkstyle-artifact produced by
|
||||
# check-codes.yml. Re-enable only after those actions are mirrored or the
|
||||
# comment path is rewritten without reviewdog.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: false
|
||||
steps:
|
||||
- name: Disabled
|
||||
run: echo "check-codes-comment is disabled on this fork"
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Check Codes
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '**.java'
|
||||
- '**.properties'
|
||||
- '.github/workflows/check-codes.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.java'
|
||||
- '**.properties'
|
||||
- '.github/workflows/check-codes.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-cleanup: never
|
||||
# Keep only Gradle checks. reviewdog/action-setup is not mirrored on
|
||||
# git.nest.potatolab.uk (404), so PR-comment artifact steps are omitted
|
||||
# for Gitea Actions / act runners.
|
||||
- name: Check Codes
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
||||
@@ -1,11 +1,22 @@
|
||||
name: Java CI
|
||||
|
||||
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
|
||||
# (once for the push, once for the pull request), which kept the runners queued.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
# A newer run for the same pull request or branch replaces the older one.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -23,12 +34,26 @@ jobs:
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-cleanup: never
|
||||
# Only main writes the dependency cache; pull requests read it.
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
# The repository's wrapper downloads Gradle itself, so cache that download too.
|
||||
- name: Cache Gradle distribution
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
.gradle/wrapper-neo
|
||||
key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
|
||||
- name: Install Xvfb
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb
|
||||
- name: Build with Gradle
|
||||
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
|
||||
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
|
||||
# only refresh the package index when a plain install fails.
|
||||
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|
||||
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
|
||||
# One Gradle run builds, tests and runs the code and translation checks, so the
|
||||
# project is configured and compiled once instead of in two workflows.
|
||||
- name: Build and check with Gradle
|
||||
run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
|
||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||
|
||||
@@ -77,3 +77,6 @@ language-subtag-registry
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.gradle-*/
|
||||
|
||||
# Managed Java downloaded by the launcher when it is run from the repository root
|
||||
/java/
|
||||
@@ -141,4 +141,43 @@ public final class Metadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
|
||||
public static final String DEFAULT_CLIENT_FILE_API =
|
||||
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest";
|
||||
|
||||
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
|
||||
///
|
||||
/// Override with system property `hmcl.client.fileApi` or jar attribute `hmcl.client.fileApi`.
|
||||
public static String getClientFileApi() {
|
||||
String fromProperty = System.getProperty("hmcl.client.fileApi", "").trim();
|
||||
if (StringUtils.isNotBlank(fromProperty)) {
|
||||
return StringUtils.removeSuffix(fromProperty, "/");
|
||||
}
|
||||
String fromJar = JarUtils.getAttribute("hmcl.client.fileApi", null);
|
||||
if (StringUtils.isNotBlank(fromJar)) {
|
||||
return StringUtils.removeSuffix(fromJar.trim(), "/");
|
||||
}
|
||||
return DEFAULT_CLIENT_FILE_API;
|
||||
}
|
||||
|
||||
/// Absolute URL of the client `server-manifest.json` used for first install and differential updates.
|
||||
public static String getClientManifestUrl() {
|
||||
return getClientFileApi() + "/server-manifest.json";
|
||||
}
|
||||
|
||||
/// Server address used when the installed modpack manifest does not provide one.
|
||||
///
|
||||
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
|
||||
public static String getDefaultServerAddress() {
|
||||
return System.getProperty("hmcl.server.address", "").trim();
|
||||
}
|
||||
|
||||
/// The server name shown in the title bar.
|
||||
///
|
||||
/// Can be set with the `hmcl.server.name` system property; defaults to `TDLM`.
|
||||
public static String getServerName() {
|
||||
String name = System.getProperty("hmcl.server.name", "TDLM").trim();
|
||||
return name.isEmpty() ? "TDLM" : name;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -131,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
String html;
|
||||
try {
|
||||
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
|
||||
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
||||
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
||||
.replace("%lang%", Locale.getDefault().toLanguageTag())
|
||||
.replace("%success%", i18n("message.success"))
|
||||
.replace("%ok%", i18n("button.ok"))
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Resolves the routes that the one-click join button can connect through.
|
||||
@NotNullByDefault
|
||||
public final class ServerEndpoint {
|
||||
private ServerEndpoint() {
|
||||
}
|
||||
|
||||
/// One selectable connection route.
|
||||
///
|
||||
/// @param name the display name
|
||||
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
|
||||
public record Route(String name, String address) {
|
||||
}
|
||||
|
||||
/// Lists the routes configured for an instance.
|
||||
///
|
||||
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
|
||||
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
|
||||
/// used, each as a single route.
|
||||
///
|
||||
/// @param configurationFile the instance's `modpack.json` path
|
||||
/// @return the routes in display order; empty when nothing is configured
|
||||
public static @Unmodifiable List<Route> routes(Path configurationFile) {
|
||||
List<Route> result = new ArrayList<>();
|
||||
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
|
||||
if (manifest != null) {
|
||||
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
|
||||
if (StringUtils.isNotBlank(route.getAddress())) {
|
||||
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
|
||||
}
|
||||
}
|
||||
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
|
||||
result.add(new Route("default", manifest.getServerAddress().trim()));
|
||||
}
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
String fallback = Metadata.getDefaultServerAddress();
|
||||
if (!fallback.isEmpty()) {
|
||||
result.add(new Route("default", fallback));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
|
||||
///
|
||||
/// @param configurationFile the instance's `modpack.json` path
|
||||
/// @return the route to connect through, or `null` when nothing is configured
|
||||
public static @Nullable Route selected(Path configurationFile) {
|
||||
List<Route> routes = routes(configurationFile);
|
||||
if (routes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@Nullable String saved = readSelectedName(configurationFile);
|
||||
for (Route route : routes) {
|
||||
if (route.name().equals(saved)) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return routes.get(0);
|
||||
}
|
||||
|
||||
/// Remembers the player's route choice next to the instance's modpack configuration.
|
||||
///
|
||||
/// @param configurationFile the instance's `modpack.json` path
|
||||
/// @param route the chosen route
|
||||
public static void select(Path configurationFile, Route route) {
|
||||
try {
|
||||
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to save the selected route", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path selectionFile(Path configurationFile) {
|
||||
return configurationFile.resolveSibling("server-route.txt");
|
||||
}
|
||||
|
||||
private static @Nullable String readSelectedName(Path configurationFile) {
|
||||
try {
|
||||
Path file = selectionFile(configurationFile);
|
||||
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
|
||||
if (!Files.isRegularFile(configurationFile)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ModpackConfiguration<ServerModpackManifest> configuration =
|
||||
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
|
||||
return configuration != null ? configuration.getManifest() : null;
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Unable to read server routes from " + configurationFile, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,28 @@ public final class FontManager {
|
||||
|
||||
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
|
||||
|
||||
/// Bundled OFL fonts: Noto Sans SC for headings and controls, Noto Serif SC for body text.
|
||||
private static final String[] BUNDLED_FONTS = {
|
||||
"/assets/fonts/NotoSansSC-Regular.ttf",
|
||||
"/assets/fonts/NotoSansSC-Bold.ttf",
|
||||
"/assets/fonts/NotoSerifSC-Regular.ttf"
|
||||
};
|
||||
|
||||
/// Registers the bundled fonts with JavaFX so style sheets can refer to them by family name.
|
||||
private static void loadBundledFonts() {
|
||||
for (String resource : BUNDLED_FONTS) {
|
||||
try (var in = FontManager.class.getResourceAsStream(resource)) {
|
||||
if (in == null || Font.loadFont(in, DEFAULT_FONT_SIZE) == null) {
|
||||
LOG.warning("Failed to load bundled font " + resource);
|
||||
}
|
||||
} catch (java.io.IOException e) {
|
||||
LOG.warning("Failed to load bundled font " + resource, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
loadBundledFonts();
|
||||
updateFont();
|
||||
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
|
||||
import org.jackhuang.hmcl.theme.BuiltinBackground;
|
||||
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.ThemeReference;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
@@ -59,7 +58,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
|
||||
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
|
||||
|
||||
/// Default launcher theme used when no stored theme reference is available.
|
||||
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
|
||||
|
||||
/// Theme appearance override key for theme brightness mode.
|
||||
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
|
||||
@@ -225,23 +223,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
|
||||
|
||||
// UI
|
||||
|
||||
// Theme selection
|
||||
|
||||
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
|
||||
@SerializedName("selectedTheme")
|
||||
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
|
||||
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
|
||||
|
||||
/// Returns the selected installed theme property.
|
||||
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
/// Returns the selected installed theme, falling back to the built-in default theme.
|
||||
public ThemeReference getSelectedThemeOrDefault() {
|
||||
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
|
||||
}
|
||||
|
||||
// Theme appearance overrides
|
||||
|
||||
/// Theme appearance setting keys overridden by the launcher.
|
||||
|
||||
@@ -6,12 +6,6 @@ import javafx.beans.binding.Bindings;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.glavo.monetfx.Brightness;
|
||||
import org.glavo.monetfx.ColorRole;
|
||||
import org.glavo.monetfx.ColorScheme;
|
||||
import org.jackhuang.hmcl.theme.ResolvedTheme;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -100,55 +94,14 @@ public final class StyleSheets {
|
||||
}
|
||||
|
||||
private static String getBrightnessStyleSheet() {
|
||||
return Themes.getColorScheme().getBrightness() == Brightness.LIGHT
|
||||
? "/assets/css/brightness-light.css"
|
||||
: "/assets/css/brightness-dark.css";
|
||||
}
|
||||
|
||||
private static void addColor(StringBuilder builder, String name, Color color) {
|
||||
builder.append(" ").append(name)
|
||||
.append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n");
|
||||
}
|
||||
|
||||
private static void addColor(StringBuilder builder, String name, Color color, double opacity) {
|
||||
builder.append(" ").append(name)
|
||||
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(color, opacity)).append(";\n");
|
||||
}
|
||||
|
||||
private static void addColor(StringBuilder builder, ColorScheme scheme, ColorRole role, double opacity) {
|
||||
builder.append(" ").append(role.getVariableName()).append("-transparent-%02d".formatted((int) (100 * opacity)))
|
||||
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(scheme.getColor(role), opacity))
|
||||
.append(";\n");
|
||||
return "/assets/css/brightness-light.css";
|
||||
}
|
||||
|
||||
/// Returns the launcher color style sheet.
|
||||
///
|
||||
/// The launcher ships a single warm palette; user-selected themes are no longer applied.
|
||||
private static String getThemeStyleSheet() {
|
||||
final String blueCss = "/assets/css/blue.css";
|
||||
|
||||
if (ResolvedTheme.DEFAULT.equals(Themes.getTheme()))
|
||||
return blueCss;
|
||||
|
||||
ColorScheme scheme = Themes.getColorScheme();
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("* {\n");
|
||||
for (ColorRole colorRole : ColorRole.ALL) {
|
||||
addColor(builder, colorRole.getVariableName(), scheme.getColor(colorRole));
|
||||
}
|
||||
|
||||
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
|
||||
|
||||
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
|
||||
addColor(builder, scheme, ColorRole.PRIMARY, 0.8);
|
||||
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
|
||||
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
|
||||
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
|
||||
addColor(builder, scheme, ColorRole.ON_SURFACE_VARIANT, 0.38);
|
||||
addColor(builder, scheme, ColorRole.SURFACE_CONTAINER_LOW, 0.8);
|
||||
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.8);
|
||||
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
|
||||
|
||||
builder.append("}\n");
|
||||
return toStyleSheetUri(builder.toString());
|
||||
return "/assets/css/warm.css";
|
||||
}
|
||||
|
||||
public static void init(Scene scene) {
|
||||
|
||||
@@ -1,24 +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.theme;
|
||||
|
||||
import javafx.scene.layout.Background;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// A launcher background and the opacity applied by its rendering node.
|
||||
///
|
||||
/// @param background the JavaFX background rendered by the launcher
|
||||
/// @param opacity the node opacity in the range `[0, 1]`
|
||||
@NotNullByDefault
|
||||
public record LauncherBackground(Background background, double opacity) {
|
||||
/// Creates a launcher background value.
|
||||
public LauncherBackground {
|
||||
Objects.requireNonNull(background);
|
||||
if (!Double.isFinite(opacity) || opacity < 0.0 || opacity > 1.0) {
|
||||
throw new IllegalArgumentException("Launcher background opacity must be between 0 and 1: " + opacity);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +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.theme;
|
||||
|
||||
import org.glavo.monetfx.*;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/// Concrete launcher theme values resolved for MonetFX.
|
||||
///
|
||||
/// @param primaryColorSeed Color seed used to generate the MonetFX color scheme.
|
||||
/// @param brightness Brightness used by the generated color scheme.
|
||||
/// @param colorStyle MonetFX color style used by the generated color scheme.
|
||||
/// @param contrast MonetFX contrast level used by the generated color scheme.
|
||||
@NotNullByDefault
|
||||
public record ResolvedTheme(ThemeColor primaryColorSeed,
|
||||
Brightness brightness,
|
||||
ColorStyle colorStyle,
|
||||
Contrast contrast) {
|
||||
|
||||
/// Default launcher theme used when no custom theme values are configured.
|
||||
public static final ResolvedTheme DEFAULT = new ResolvedTheme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
|
||||
|
||||
/// Converts these resolved values to a MonetFX color scheme.
|
||||
///
|
||||
/// @return the generated color scheme
|
||||
public ColorScheme toColorScheme() {
|
||||
return ColorScheme.newBuilder()
|
||||
.setPrimaryColorSeed(primaryColorSeed.color())
|
||||
.setColorStyle(colorStyle)
|
||||
.setBrightness(brightness)
|
||||
.setSpecVersion(ColorSpecVersion.SPEC_2025)
|
||||
.setContrast(contrast)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,229 +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.theme;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// One selectable theme inside a theme pack.
|
||||
///
|
||||
/// The theme object's top-level appearance fields provide default values. Each
|
||||
/// matching override is applied in array order to produce the resolved appearance.
|
||||
///
|
||||
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
|
||||
/// @param name the localized display name, or `null` for an unnamed single-theme pack
|
||||
/// @param authors authors of this specific theme
|
||||
/// @param description the optional localized description
|
||||
/// @param icon the optional theme-pack relative icon path
|
||||
/// @param appearance the default appearance fields
|
||||
/// @param overrides conditional appearance patches applied in declaration order
|
||||
@NotNullByDefault
|
||||
public record Theme(
|
||||
@Nullable String id,
|
||||
@Nullable LocalizedText name,
|
||||
@Unmodifiable List<ThemePackAuthor> authors,
|
||||
@Nullable LocalizedText description,
|
||||
@Nullable String icon,
|
||||
ThemeAppearance appearance,
|
||||
@Unmodifiable List<ThemeOverride> overrides) {
|
||||
|
||||
/// Creates a theme.
|
||||
///
|
||||
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
|
||||
/// @param name the localized display name, or `null` for an unnamed single-theme pack
|
||||
/// @param authors authors of this specific theme
|
||||
/// @param description the optional localized description
|
||||
/// @param icon the optional theme-pack relative icon path
|
||||
/// @param appearance the default appearance fields
|
||||
/// @param overrides conditional appearance patches applied in declaration order
|
||||
public Theme {
|
||||
if (id != null) {
|
||||
id = ThemePackManifest.requireThemeId(id);
|
||||
}
|
||||
if (name != null && name.mayBeEmpty()) {
|
||||
throw new IllegalArgumentException("Theme name is empty: " + name);
|
||||
}
|
||||
if (icon != null) {
|
||||
icon = ThemePackAsset.normalizeEntryName(icon);
|
||||
}
|
||||
authors = List.copyOf(authors);
|
||||
Objects.requireNonNull(appearance);
|
||||
overrides = List.copyOf(overrides);
|
||||
}
|
||||
|
||||
/// Parses a theme from JSON.
|
||||
///
|
||||
/// @param object the theme JSON object
|
||||
/// @param requireIdentity whether the theme must declare an explicit ID and name
|
||||
/// @return the parsed theme
|
||||
/// @throws JsonParseException if required identity fields are missing or malformed
|
||||
static Theme fromJson(JsonObject object, boolean requireIdentity) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
@Nullable String id = JsonUtils.getString(object, "id");
|
||||
if (id != null) {
|
||||
try {
|
||||
id = ThemePackManifest.requireThemeId(id);
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (requireIdentity) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
LOG.warning("Ignored invalid theme id: " + id, e);
|
||||
id = null;
|
||||
}
|
||||
}
|
||||
if (id == null && requireIdentity) {
|
||||
throw new JsonParseException("Theme is missing the id");
|
||||
}
|
||||
@Nullable LocalizedText name;
|
||||
try {
|
||||
name = LocalizedText.fromJson(object.get("name"));
|
||||
if (name != null && name.mayBeEmpty()) {
|
||||
throw new JsonParseException("Theme name is empty: " + name);
|
||||
}
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
if (requireIdentity) {
|
||||
throw e;
|
||||
}
|
||||
LOG.warning("Ignored invalid theme name", e);
|
||||
name = null;
|
||||
}
|
||||
if (name == null && requireIdentity) {
|
||||
throw new JsonParseException("Theme is missing required localized text field: " + "name");
|
||||
}
|
||||
|
||||
List<ThemePackAuthor> authors;
|
||||
try {
|
||||
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to parse authors", e);
|
||||
authors = List.of();
|
||||
}
|
||||
|
||||
@Nullable LocalizedText description;
|
||||
|
||||
try {
|
||||
description = LocalizedText.fromJson(object.get("description"));
|
||||
if (description != null) {
|
||||
description = ThemePackManifest.requireLocalizedText(description, "description");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to parse description", e);
|
||||
description = null;
|
||||
}
|
||||
|
||||
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
|
||||
|
||||
JsonElement overridesJson = object.get("overrides");
|
||||
List<ThemeOverride> overrides;
|
||||
|
||||
if (overridesJson == null || overridesJson.isJsonNull()) {
|
||||
overrides = List.of();
|
||||
} else if (overridesJson instanceof JsonArray array) {
|
||||
overrides = new ArrayList<>(array.size());
|
||||
|
||||
for (JsonElement overrideJson : array) {
|
||||
try {
|
||||
ThemeOverride override = ThemeOverride.fromJson(overrideJson);
|
||||
if (override != null)
|
||||
overrides.add(override);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Invalid theme override", e);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
LOG.warning("Invalid theme overrides: expected an array, got " + overridesJson);
|
||||
overrides = List.of();
|
||||
}
|
||||
|
||||
@Nullable String icon = JsonUtils.getString(object, "icon");
|
||||
if (icon != null) {
|
||||
try {
|
||||
icon = ThemePackAsset.normalizeEntryName(icon);
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme icon: " + icon, e);
|
||||
icon = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new Theme(id, name, authors, description,
|
||||
icon,
|
||||
appearance, overrides);
|
||||
}
|
||||
|
||||
/// Returns the theme display name in the current locale.
|
||||
///
|
||||
/// @return the localized display name, or `null` when this theme has no matching display name
|
||||
public @Nullable String displayName() {
|
||||
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
|
||||
}
|
||||
|
||||
/// Returns the theme description in the current locale.
|
||||
///
|
||||
/// @return the localized description, or `null` when this theme has no matching description
|
||||
public @Nullable String displayDescription() {
|
||||
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
|
||||
}
|
||||
|
||||
/// Resolves this theme against a context by applying matching overrides.
|
||||
///
|
||||
/// @param context the resolution context
|
||||
/// @return the resolved appearance
|
||||
public ThemeAppearance resolve(ThemeResolveContext context) {
|
||||
Objects.requireNonNull(context);
|
||||
|
||||
ThemeAppearance resolved = appearance;
|
||||
for (ThemeOverride override : overrides) {
|
||||
if (override.matches(context)) {
|
||||
resolved = resolved.merge(override.appearance());
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// Converts this theme to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this theme
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
if (id != null) {
|
||||
object.addProperty("id", id);
|
||||
}
|
||||
if (name != null) {
|
||||
object.add("name", JsonUtils.GSON.toJsonTree(name, LocalizedText.class));
|
||||
}
|
||||
object.add("authors", ThemePackAuthor.toJson(authors));
|
||||
if (description != null) {
|
||||
object.add("description", JsonUtils.GSON.toJsonTree(description, LocalizedText.class));
|
||||
}
|
||||
if (icon != null) {
|
||||
object.addProperty("icon", icon);
|
||||
}
|
||||
appearance.addToJsonObject(object);
|
||||
|
||||
if (!overrides.isEmpty()) {
|
||||
JsonArray array = new JsonArray();
|
||||
for (ThemeOverride override : overrides) {
|
||||
array.add(override.toJsonObject());
|
||||
}
|
||||
object.add("overrides", array);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,374 +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.theme;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.glavo.monetfx.Brightness;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.glavo.monetfx.Contrast;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Appearance values contributed by a theme or override.
|
||||
///
|
||||
/// All fields are optional so the same type can represent both a default appearance
|
||||
/// and a conditional patch.
|
||||
///
|
||||
/// @param color the color seed source, or `null` when inherited
|
||||
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
|
||||
/// @param colorStyle the MonetFX color style, or `null` when inherited
|
||||
/// @param contrast the MonetFX contrast level, or `null` when inherited
|
||||
/// @param background the background settings, or `null` when inherited
|
||||
/// @param titleBar the title-bar settings, or `null` when inherited
|
||||
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
|
||||
@NotNullByDefault
|
||||
public record ThemeAppearance(
|
||||
@Nullable ThemeColorSource color,
|
||||
@Nullable Brightness brightness,
|
||||
@Nullable ColorStyle colorStyle,
|
||||
@Nullable Contrast contrast,
|
||||
@Nullable ThemeBackgroundSettings background,
|
||||
@Nullable ThemeTitleBar titleBar,
|
||||
@Nullable Boolean windowTransparent) {
|
||||
|
||||
/// JSON member name for the color seed.
|
||||
static final String FIELD_COLOR = "color";
|
||||
|
||||
/// JSON member name for the controlled launcher brightness.
|
||||
static final String FIELD_BRIGHTNESS = "brightness";
|
||||
|
||||
/// JSON member name for the MonetFX color style.
|
||||
static final String FIELD_COLOR_STYLE = "colorStyle";
|
||||
|
||||
/// JSON member name for the MonetFX contrast level.
|
||||
static final String FIELD_CONTRAST = "contrast";
|
||||
|
||||
/// JSON member name for background settings.
|
||||
static final String FIELD_BACKGROUND = "background";
|
||||
|
||||
/// JSON member name for title-bar settings.
|
||||
static final String FIELD_TITLE_BAR = "titleBar";
|
||||
|
||||
/// JSON member name for window transparency.
|
||||
static final String FIELD_WINDOW_TRANSPARENT = "windowTransparent";
|
||||
|
||||
/// Creates an appearance patch.
|
||||
///
|
||||
/// @param color the color seed source, or `null` when inherited
|
||||
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
|
||||
/// @param colorStyle the MonetFX color style, or `null` when inherited
|
||||
/// @param contrast the MonetFX contrast level, or `null` when inherited
|
||||
/// @param background the background settings, or `null` when inherited
|
||||
/// @param titleBar the title-bar settings, or `null` when inherited
|
||||
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
|
||||
public ThemeAppearance {
|
||||
if (background != null && background.isEmpty()) {
|
||||
background = null;
|
||||
}
|
||||
if (titleBar != null && titleBar.isEmpty()) {
|
||||
titleBar = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses known appearance fields from a JSON object.
|
||||
///
|
||||
/// @param object the JSON object containing appearance fields
|
||||
/// @return the parsed appearance patch
|
||||
static ThemeAppearance fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
return new ThemeAppearance(
|
||||
readColor(object),
|
||||
readBrightness(object),
|
||||
readColorStyle(object),
|
||||
readContrast(object),
|
||||
readBackground(object),
|
||||
readTitleBar(object),
|
||||
readWindowTransparent(object));
|
||||
}
|
||||
|
||||
/// Returns whether this appearance contains no concrete fields.
|
||||
///
|
||||
/// @return `true` when every appearance field is inherited
|
||||
public boolean isEmpty() {
|
||||
return color == null
|
||||
&& brightness == null
|
||||
&& colorStyle == null
|
||||
&& contrast == null
|
||||
&& background == null
|
||||
&& titleBar == null
|
||||
&& windowTransparent == null;
|
||||
}
|
||||
|
||||
/// Adds this appearance patch's concrete fields to a JSON object.
|
||||
///
|
||||
/// @param object the target JSON object
|
||||
void addToJsonObject(JsonObject object) {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
if (color != null) {
|
||||
object.add(FIELD_COLOR, color.toJsonElement());
|
||||
}
|
||||
if (brightness != null) {
|
||||
object.addProperty(FIELD_BRIGHTNESS, toJsonBrightness(brightness));
|
||||
}
|
||||
if (colorStyle != null) {
|
||||
object.addProperty(FIELD_COLOR_STYLE, colorStyle.name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (contrast != null && !contrast.equals(Contrast.DEFAULT)) {
|
||||
addContrast(object, contrast);
|
||||
}
|
||||
if (background != null) {
|
||||
object.add(FIELD_BACKGROUND, background.toJsonObject());
|
||||
}
|
||||
if (titleBar != null) {
|
||||
object.add(FIELD_TITLE_BAR, titleBar.toJsonObject());
|
||||
}
|
||||
if (windowTransparent != null) {
|
||||
object.addProperty(FIELD_WINDOW_TRANSPARENT, windowTransparent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts this appearance patch to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this appearance patch
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
addToJsonObject(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Applies the given patch over this appearance.
|
||||
///
|
||||
/// @param patch the patch to apply
|
||||
/// @return the merged appearance
|
||||
public ThemeAppearance merge(ThemeAppearance patch) {
|
||||
Objects.requireNonNull(patch);
|
||||
|
||||
return new ThemeAppearance(
|
||||
patch.color != null ? patch.color : color,
|
||||
patch.brightness != null ? patch.brightness : brightness,
|
||||
patch.colorStyle != null ? patch.colorStyle : colorStyle,
|
||||
patch.contrast != null ? patch.contrast : contrast,
|
||||
background != null && patch.background != null
|
||||
? background.merge(patch.background)
|
||||
: patch.background != null ? patch.background : background,
|
||||
titleBar != null && patch.titleBar != null
|
||||
? titleBar.merge(patch.titleBar)
|
||||
: patch.titleBar != null ? patch.titleBar : titleBar,
|
||||
patch.windowTransparent != null ? patch.windowTransparent : windowTransparent);
|
||||
}
|
||||
|
||||
/// Converts this appearance to concrete launcher theme values.
|
||||
///
|
||||
/// @param context the resolution context that provides fallback brightness when this appearance does not control it
|
||||
/// @return the concrete theme used by MonetFX
|
||||
public ResolvedTheme toResolvedTheme(ThemeResolveContext context) {
|
||||
Objects.requireNonNull(context);
|
||||
|
||||
ThemeColor resolvedColor = color != null ? color.resolveFallback() : ResolvedTheme.DEFAULT.primaryColorSeed();
|
||||
Brightness resolvedBrightness = brightness != null ? brightness : context.brightness();
|
||||
ColorStyle resolvedColorStyle = colorStyle != null ? colorStyle : ResolvedTheme.DEFAULT.colorStyle();
|
||||
Contrast resolvedContrast = contrast != null ? contrast : Contrast.DEFAULT;
|
||||
|
||||
return new ResolvedTheme(resolvedColor, resolvedBrightness, resolvedColorStyle, resolvedContrast);
|
||||
}
|
||||
|
||||
/// Reads the optional color field.
|
||||
private static @Nullable ThemeColorSource readColor(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_COLOR);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ThemeColorSource.fromJson(element);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme color: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the optional controlled brightness field.
|
||||
private static @Nullable Brightness readBrightness(JsonObject object) {
|
||||
try {
|
||||
@Nullable String value = readString(object, FIELD_BRIGHTNESS);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return parseBrightness(value);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme brightness: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the optional color style field.
|
||||
private static @Nullable ColorStyle readColorStyle(JsonObject object) {
|
||||
try {
|
||||
@Nullable String value = readString(object, FIELD_COLOR_STYLE);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalized = value.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT);
|
||||
if ("DEFAULT".equals(normalized)) {
|
||||
return ColorStyle.DEFAULT;
|
||||
}
|
||||
return ColorStyle.valueOf(normalized);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme colorStyle: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the optional contrast field.
|
||||
private static @Nullable Contrast readContrast(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_CONTRAST);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive)) {
|
||||
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
return readContrastValue(primitive.getAsDouble());
|
||||
}
|
||||
if (!primitive.isString()) {
|
||||
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
|
||||
return null;
|
||||
}
|
||||
|
||||
String value = primitive.getAsString();
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "low" -> Contrast.LOW;
|
||||
case "standard" -> Contrast.DEFAULT;
|
||||
case "medium" -> Contrast.MEDIUM;
|
||||
case "high" -> Contrast.HIGH;
|
||||
default -> {
|
||||
try {
|
||||
yield readContrastValue(Double.parseDouble(normalized));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new JsonParseException("Invalid theme contrast: " + value, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme contrast: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the optional background object.
|
||||
private static @Nullable ThemeBackgroundSettings readBackground(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_BACKGROUND);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonObject background)) {
|
||||
LOG.warning("Ignored invalid theme background: expected an object, got " + element);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ThemeBackgroundSettings.fromJson(background);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme background: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the optional title-bar object.
|
||||
private static @Nullable ThemeTitleBar readTitleBar(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_TITLE_BAR);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonObject titleBar)) {
|
||||
LOG.warning("Ignored invalid theme titleBar: expected an object, got " + element);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ThemeTitleBar.fromJson(titleBar);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme titleBar: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the optional window transparency field.
|
||||
private static @Nullable Boolean readWindowTransparent(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_WINDOW_TRANSPARENT);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
|
||||
LOG.warning("Ignored invalid theme windowTransparent: expected a boolean, got " + element);
|
||||
return null;
|
||||
}
|
||||
return primitive.getAsBoolean();
|
||||
}
|
||||
|
||||
/// Reads an optional string field.
|
||||
private static @Nullable String readString(JsonObject object, String field) {
|
||||
JsonElement element = object.get(field);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Theme field must be a string: " + field);
|
||||
}
|
||||
return primitive.getAsString();
|
||||
}
|
||||
|
||||
/// Returns a validated contrast value.
|
||||
private static Contrast readContrastValue(double value) {
|
||||
try {
|
||||
return Contrast.of(value);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException("Theme contrast value must be between -1 and 1: " + value, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a canonical contrast value to a JSON object.
|
||||
private static void addContrast(JsonObject object, Contrast contrast) {
|
||||
if (contrast.equals(Contrast.LOW)) {
|
||||
object.addProperty(FIELD_CONTRAST, "low");
|
||||
} else if (contrast.equals(Contrast.DEFAULT)) {
|
||||
object.addProperty(FIELD_CONTRAST, "standard");
|
||||
} else if (contrast.equals(Contrast.MEDIUM)) {
|
||||
object.addProperty(FIELD_CONTRAST, "medium");
|
||||
} else if (contrast.equals(Contrast.HIGH)) {
|
||||
object.addProperty(FIELD_CONTRAST, "high");
|
||||
} else {
|
||||
object.addProperty(FIELD_CONTRAST, contrast.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a serialized theme brightness value.
|
||||
private static Brightness parseBrightness(String value) {
|
||||
return switch (value.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "light" -> Brightness.LIGHT;
|
||||
case "dark" -> Brightness.DARK;
|
||||
default -> throw new IllegalArgumentException("Unsupported theme brightness: " + value);
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts a controlled brightness value to its canonical JSON string.
|
||||
private static String toJsonBrightness(Brightness brightness) {
|
||||
return switch (brightness) {
|
||||
case LIGHT -> "light";
|
||||
case DARK -> "dark";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,215 +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.theme;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Background source contributed by a theme-pack appearance.
|
||||
///
|
||||
/// The sealed implementations describe where the wallpaper comes from. Rendering
|
||||
/// parameters such as opacity are stored by [ThemeBackgroundSettings].
|
||||
@NotNullByDefault
|
||||
public sealed interface ThemeBackground
|
||||
permits ThemeBackground.Default, ThemeBackground.Builtin, ThemeBackground.Image,
|
||||
ThemeBackground.Paint, ThemeBackground.ThemeColor {
|
||||
/// JSON member name for the background type.
|
||||
String FIELD_TYPE = "type";
|
||||
|
||||
/// JSON member name for the built-in wallpaper ID.
|
||||
String FIELD_ID = "id";
|
||||
|
||||
/// JSON member name for the local theme-pack image path.
|
||||
String FIELD_PATH = "path";
|
||||
|
||||
/// JSON member name for a serialized paint value.
|
||||
String FIELD_PAINT = "paint";
|
||||
|
||||
/// Adds this background source's concrete fields to a JSON object.
|
||||
///
|
||||
/// @param object the target JSON object
|
||||
void addToJsonObject(JsonObject object);
|
||||
|
||||
/// Converts this background source to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this background source
|
||||
default JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
addToJsonObject(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Parses a background source from a JSON object.
|
||||
///
|
||||
/// @param object the JSON object
|
||||
/// @return the parsed background source, or `null` when no source field is present
|
||||
/// @throws JsonParseException if a known field is malformed
|
||||
static @Nullable ThemeBackground fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
@Nullable String type = readString(object, FIELD_TYPE);
|
||||
@Nullable String id = readString(object, FIELD_ID);
|
||||
@Nullable String path = readString(object, FIELD_PATH);
|
||||
@Nullable String paint = readString(object, FIELD_PAINT);
|
||||
|
||||
if (type == null) {
|
||||
int sourceFields = 0;
|
||||
if (id != null) {
|
||||
sourceFields++;
|
||||
}
|
||||
if (path != null) {
|
||||
sourceFields++;
|
||||
}
|
||||
if (paint != null) {
|
||||
sourceFields++;
|
||||
}
|
||||
if (sourceFields > 1) {
|
||||
throw new JsonParseException("Theme background without type must contain only one source field");
|
||||
}
|
||||
if (id != null) {
|
||||
return new Builtin(id);
|
||||
}
|
||||
if (path != null) {
|
||||
return new Image(path);
|
||||
}
|
||||
if (paint != null) {
|
||||
return new Paint(paint);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (type.trim().replace('-', '_').toUpperCase(Locale.ROOT)) {
|
||||
case "DEFAULT" -> new Default();
|
||||
case "BUILTIN" -> new Builtin(id);
|
||||
case "IMAGE" -> new Image(path);
|
||||
case "PAINT" -> new Paint(paint);
|
||||
case "THEME_COLOR" -> new ThemeColor();
|
||||
default -> throw new JsonParseException("Unsupported theme background type: " + type);
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads an optional string field.
|
||||
private static @Nullable String readString(JsonObject object, String field) {
|
||||
JsonElement element = object.get(field);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Theme background field must be a string: " + field);
|
||||
}
|
||||
return primitive.getAsString();
|
||||
}
|
||||
|
||||
/// Returns a required non-blank string value.
|
||||
private static String requireNonBlank(@Nullable String value, String field) {
|
||||
if (value == null) {
|
||||
throw new JsonParseException("Theme background field is missing: " + field);
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new JsonParseException("Theme background field is blank: " + field);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// A source that delegates to HMCL's launcher default background resolution.
|
||||
@NotNullByDefault
|
||||
record Default() implements ThemeBackground {
|
||||
/// Adds this source to a JSON object.
|
||||
@Override
|
||||
public void addToJsonObject(JsonObject object) {
|
||||
object.addProperty(FIELD_TYPE, "default");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A source that uses a launcher built-in wallpaper.
|
||||
///
|
||||
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
|
||||
@NotNullByDefault
|
||||
record Builtin(@Nullable String id) implements ThemeBackground {
|
||||
/// Creates a source that uses the fallback built-in wallpaper.
|
||||
public Builtin() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/// Creates a built-in wallpaper source.
|
||||
///
|
||||
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
|
||||
public Builtin {
|
||||
if (id != null) {
|
||||
id = requireNonBlank(id, FIELD_ID);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds this source to a JSON object.
|
||||
@Override
|
||||
public void addToJsonObject(JsonObject object) {
|
||||
object.addProperty(FIELD_TYPE, "builtin");
|
||||
if (id != null) {
|
||||
object.addProperty(FIELD_ID, id);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A source that uses an image stored inside the theme pack.
|
||||
///
|
||||
/// @param path the theme-pack relative image path
|
||||
@NotNullByDefault
|
||||
record Image(String path) implements ThemeBackground {
|
||||
/// Creates an image background source.
|
||||
///
|
||||
/// @param path the theme-pack relative image path
|
||||
public Image {
|
||||
path = requireNonBlank(path, FIELD_PATH);
|
||||
}
|
||||
|
||||
/// Adds this source to a JSON object.
|
||||
@Override
|
||||
public void addToJsonObject(JsonObject object) {
|
||||
object.addProperty(FIELD_TYPE, "image");
|
||||
object.addProperty(FIELD_PATH, path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A source that uses a JavaFX paint string.
|
||||
///
|
||||
/// @param paint the serialized paint value
|
||||
@NotNullByDefault
|
||||
record Paint(String paint) implements ThemeBackground {
|
||||
/// Creates a paint background source.
|
||||
///
|
||||
/// @param paint the serialized paint value
|
||||
public Paint {
|
||||
paint = requireNonBlank(paint, FIELD_PAINT);
|
||||
}
|
||||
|
||||
/// Adds this source to a JSON object.
|
||||
@Override
|
||||
public void addToJsonObject(JsonObject object) {
|
||||
object.addProperty(FIELD_TYPE, "paint");
|
||||
object.addProperty(FIELD_PAINT, paint);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A source that follows the current launcher theme color.
|
||||
@NotNullByDefault
|
||||
record ThemeColor() implements ThemeBackground {
|
||||
/// Adds this source to a JSON object.
|
||||
@Override
|
||||
public void addToJsonObject(JsonObject object) {
|
||||
object.addProperty(FIELD_TYPE, "theme_color");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,103 +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.theme;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Background settings contributed by a theme-pack appearance.
|
||||
///
|
||||
/// @param source the background source, or `null` when inherited
|
||||
/// @param opacity the background opacity override, or `null` when inherited
|
||||
@NotNullByDefault
|
||||
public record ThemeBackgroundSettings(
|
||||
@Nullable ThemeBackground source,
|
||||
@Nullable Double opacity) {
|
||||
/// JSON member name for the background opacity.
|
||||
private static final String FIELD_OPACITY = "opacity";
|
||||
|
||||
/// Creates background settings.
|
||||
///
|
||||
/// @param source the background source, or `null` when inherited
|
||||
/// @param opacity the background opacity override, or `null` when inherited
|
||||
public ThemeBackgroundSettings {
|
||||
if (opacity != null && (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity))) {
|
||||
throw new IllegalArgumentException("Theme background opacity must be between 0 and 1: " + opacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses background settings from a JSON object.
|
||||
///
|
||||
/// @param object the JSON object
|
||||
/// @return the parsed background settings
|
||||
static ThemeBackgroundSettings fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
return new ThemeBackgroundSettings(
|
||||
ThemeBackground.fromJson(object),
|
||||
readOpacity(object));
|
||||
}
|
||||
|
||||
/// Converts these settings to their JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing these background settings
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = source != null ? source.toJsonObject() : new JsonObject();
|
||||
if (opacity != null) {
|
||||
object.addProperty(FIELD_OPACITY, opacity);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns whether these settings contain no concrete fields.
|
||||
///
|
||||
/// @return `true` when every background setting is inherited
|
||||
public boolean isEmpty() {
|
||||
return source == null && opacity == null;
|
||||
}
|
||||
|
||||
/// Returns settings that apply the given patch over these settings.
|
||||
///
|
||||
/// @param patch the patch to apply
|
||||
/// @return the merged settings
|
||||
public ThemeBackgroundSettings merge(ThemeBackgroundSettings patch) {
|
||||
Objects.requireNonNull(patch);
|
||||
|
||||
@Nullable ThemeBackground mergedSource = patch.source != null ? patch.source : source;
|
||||
@Nullable Double mergedOpacity = patch.opacity != null ? patch.opacity : opacity;
|
||||
return new ThemeBackgroundSettings(mergedSource, mergedOpacity);
|
||||
}
|
||||
|
||||
/// Reads the optional opacity field.
|
||||
private static @Nullable Double readOpacity(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_OPACITY);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isNumber()) {
|
||||
LOG.warning("Ignored invalid theme background opacity: expected a number, got " + element);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
double opacity = primitive.getAsDouble();
|
||||
if (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity)) {
|
||||
LOG.warning("Ignored invalid theme background opacity: expected a number between 0 and 1, got "
|
||||
+ opacity);
|
||||
return null;
|
||||
}
|
||||
return opacity;
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Ignored invalid theme background opacity: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +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.theme;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Describes how a theme-pack appearance chooses its Monet seed color.
|
||||
@NotNullByDefault
|
||||
public sealed interface ThemeColorSource permits ThemeColorSource.Default, ThemeColorSource.Custom, ThemeColorSource.Wallpaper {
|
||||
/// JSON member name for the source type.
|
||||
String FIELD_SOURCE = "source";
|
||||
|
||||
/// The launcher default color source.
|
||||
ThemeColorSource DEFAULT = new Default();
|
||||
|
||||
/// Creates a custom color source.
|
||||
///
|
||||
/// @param color the custom color
|
||||
/// @return the custom color source
|
||||
static ThemeColorSource custom(ThemeColor color) {
|
||||
return new Custom(color);
|
||||
}
|
||||
|
||||
/// Creates a wallpaper color source.
|
||||
///
|
||||
/// @return the wallpaper color source
|
||||
static ThemeColorSource wallpaper() {
|
||||
return new Wallpaper();
|
||||
}
|
||||
|
||||
/// Parses a color source from a manifest JSON value.
|
||||
///
|
||||
/// @param element the JSON value
|
||||
/// @return the parsed color source
|
||||
/// @throws JsonParseException if the color source is malformed
|
||||
static ThemeColorSource fromJson(JsonElement element) throws JsonParseException {
|
||||
Objects.requireNonNull(element);
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
String value = primitive.getAsString();
|
||||
if ("default".equals(value.trim().replace('-', '_').toLowerCase(Locale.ROOT))) {
|
||||
return DEFAULT;
|
||||
}
|
||||
@Nullable ThemeColor color = ThemeColor.of(value);
|
||||
if (color == null) {
|
||||
throw new JsonParseException("Invalid theme color: " + value);
|
||||
}
|
||||
return custom(color);
|
||||
}
|
||||
if (!(element instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Theme color must be a string or object");
|
||||
}
|
||||
|
||||
JsonElement sourceElement = object.get(FIELD_SOURCE);
|
||||
if (sourceElement == null) {
|
||||
throw new JsonParseException("Theme color source is missing required field: " + FIELD_SOURCE);
|
||||
}
|
||||
if (!(sourceElement instanceof JsonPrimitive sourcePrimitive) || !sourcePrimitive.isString()) {
|
||||
throw new JsonParseException("Theme color source field must be a string: " + FIELD_SOURCE);
|
||||
}
|
||||
String source = sourcePrimitive.getAsString();
|
||||
String normalized = source.trim().replace('-', '_').toUpperCase(Locale.ROOT);
|
||||
if ("DEFAULT".equals(normalized)) {
|
||||
return DEFAULT;
|
||||
}
|
||||
if ("WALLPAPER".equals(normalized)) {
|
||||
return wallpaper();
|
||||
}
|
||||
throw new JsonParseException("Unsupported theme color source: " + source);
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
///
|
||||
/// @return the color source JSON value
|
||||
JsonElement toJsonElement();
|
||||
|
||||
/// Returns the best available color without accessing wallpaper pixels.
|
||||
///
|
||||
/// @return the custom color or launcher default color
|
||||
ThemeColor resolveFallback();
|
||||
|
||||
/// The launcher default color seed.
|
||||
@NotNullByDefault
|
||||
record Default() implements ThemeColorSource {
|
||||
/// Creates a default color source.
|
||||
public Default {
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
@Override
|
||||
public JsonElement toJsonElement() {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty(FIELD_SOURCE, "default");
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns the launcher default seed color.
|
||||
@Override
|
||||
public ThemeColor resolveFallback() {
|
||||
return ThemeColor.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed color seed supplied by the manifest.
|
||||
///
|
||||
/// @param color the custom seed color
|
||||
@NotNullByDefault
|
||||
record Custom(ThemeColor color) implements ThemeColorSource {
|
||||
/// Creates a custom color source.
|
||||
///
|
||||
/// @param color the custom seed color
|
||||
public Custom {
|
||||
Objects.requireNonNull(color);
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
@Override
|
||||
public JsonElement toJsonElement() {
|
||||
return new JsonPrimitive(color.name());
|
||||
}
|
||||
|
||||
/// Returns the custom seed color.
|
||||
@Override
|
||||
public ThemeColor resolveFallback() {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
/// A dynamic seed color extracted from the effective wallpaper.
|
||||
@NotNullByDefault
|
||||
record Wallpaper() implements ThemeColorSource {
|
||||
/// Creates a wallpaper color source.
|
||||
public Wallpaper {
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
@Override
|
||||
public JsonElement toJsonElement() {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty(FIELD_SOURCE, "wallpaper");
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns the launcher default color used before wallpaper pixels are available.
|
||||
@Override
|
||||
public ThemeColor resolveFallback() {
|
||||
return ThemeColor.DEFAULT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +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.theme;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/// A simple JSON-object condition used by theme-pack overrides.
|
||||
///
|
||||
/// A condition is an AND of all members. A string value requires equality, and
|
||||
/// an array value requires the context value to match any listed value. Unknown
|
||||
/// condition keys are accepted for forward compatibility, but they do not match
|
||||
/// contexts produced by this implementation.
|
||||
///
|
||||
/// @param requirements normalized accepted values keyed by condition name
|
||||
@NotNullByDefault
|
||||
public record ThemeCondition(@Unmodifiable Map<String, @Unmodifiable Set<String>> requirements) {
|
||||
/// Condition key for the effective light or dark mode.
|
||||
static final String KEY_BRIGHTNESS = "brightness";
|
||||
|
||||
/// Condition key for the current operating system.
|
||||
static final String KEY_OS = "os";
|
||||
|
||||
/// Condition key for the current UI language.
|
||||
static final String KEY_LANGUAGE = "language";
|
||||
|
||||
/// Supported operating system condition values.
|
||||
private static final Set<String> SUPPORTED_OS_VALUES = Set.of("windows", "macos", "linux", "freebsd", "unknown");
|
||||
|
||||
/// Creates a condition from normalized accepted values.
|
||||
///
|
||||
/// @param requirements normalized accepted values keyed by condition name
|
||||
public ThemeCondition {
|
||||
Objects.requireNonNull(requirements);
|
||||
|
||||
LinkedHashMap<String, Set<String>> copy = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
|
||||
String key = normalizeKey(entry.getKey());
|
||||
Set<String> values = entry.getValue();
|
||||
if (values.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme condition field has no accepted values: " + key);
|
||||
}
|
||||
|
||||
LinkedHashSet<String> valueCopy = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
valueCopy.add(normalizeValue(key, value));
|
||||
}
|
||||
copy.put(key, Collections.unmodifiableSet(valueCopy));
|
||||
}
|
||||
requirements = Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
/// Parses a theme condition from a JSON object.
|
||||
///
|
||||
/// @param object the condition object
|
||||
/// @return the parsed condition
|
||||
/// @throws JsonParseException if the condition contains malformed values
|
||||
public static ThemeCondition fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
LinkedHashMap<String, Set<String>> requirements = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
|
||||
String key = normalizeKey(entry.getKey());
|
||||
requirements.put(key, readAcceptedValues(key, entry.getValue()));
|
||||
}
|
||||
return new ThemeCondition(requirements);
|
||||
}
|
||||
|
||||
/// Returns whether this condition matches the given resolution context.
|
||||
///
|
||||
/// @param context the context to test
|
||||
/// @return `true` if every condition member matches the context
|
||||
public boolean matches(ThemeResolveContext context) {
|
||||
Objects.requireNonNull(context);
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
|
||||
String value = context.conditionValue(entry.getKey());
|
||||
if (value == null || !entry.getValue().contains(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Converts this condition to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this condition
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
|
||||
if (entry.getValue().size() == 1) {
|
||||
object.addProperty(entry.getKey(), entry.getValue().iterator().next());
|
||||
} else {
|
||||
JsonArray array = new JsonArray();
|
||||
for (String value : entry.getValue()) {
|
||||
array.add(value);
|
||||
}
|
||||
object.add(entry.getKey(), array);
|
||||
}
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Reads one condition field value.
|
||||
private static Set<String> readAcceptedValues(String key, JsonElement element) throws JsonParseException {
|
||||
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
values.add(normalizeValue(key, primitive.getAsString()));
|
||||
} else if (element instanceof JsonArray array) {
|
||||
if (array.isEmpty()) {
|
||||
throw new JsonParseException("Theme condition array is empty: " + key);
|
||||
}
|
||||
|
||||
for (JsonElement item : array) {
|
||||
if (!(item instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Theme condition array must contain strings: " + key);
|
||||
}
|
||||
values.add(normalizeValue(key, primitive.getAsString()));
|
||||
}
|
||||
} else {
|
||||
throw new JsonParseException("Theme condition value must be a string or string array: " + key);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/// Normalizes and validates a condition key.
|
||||
private static String normalizeKey(String key) {
|
||||
Objects.requireNonNull(key);
|
||||
|
||||
String normalized = key.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new JsonParseException("Theme condition key is blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// Normalizes and validates one condition value.
|
||||
private static String normalizeValue(String key, String value) {
|
||||
Objects.requireNonNull(key);
|
||||
Objects.requireNonNull(value);
|
||||
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new JsonParseException("Empty theme condition value for " + key);
|
||||
}
|
||||
String normalized = trimmed.toLowerCase(Locale.ROOT);
|
||||
|
||||
return switch (key) {
|
||||
case KEY_BRIGHTNESS -> switch (normalized) {
|
||||
case "light", "dark" -> normalized;
|
||||
default -> throw new JsonParseException("Unsupported brightness condition value: " + value);
|
||||
};
|
||||
case KEY_OS -> normalizeOperatingSystemValue(normalized, value);
|
||||
case KEY_LANGUAGE -> normalized;
|
||||
default -> trimmed;
|
||||
};
|
||||
}
|
||||
|
||||
/// Normalizes an operating system condition value.
|
||||
private static String normalizeOperatingSystemValue(String normalized, String original) {
|
||||
String value = switch (normalized) {
|
||||
case "win", "windows" -> "windows";
|
||||
case "mac", "macos", "osx" -> "macos";
|
||||
case "linux" -> "linux";
|
||||
case "freebsd" -> "freebsd";
|
||||
case "unknown", "universal" -> "unknown";
|
||||
default -> normalized;
|
||||
};
|
||||
|
||||
if (!SUPPORTED_OS_VALUES.contains(value)) {
|
||||
throw new JsonParseException("Unsupported os condition value: " + original);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,76 +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.theme;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// A conditional appearance patch in a theme.
|
||||
///
|
||||
/// @param condition the condition required for this override to apply
|
||||
/// @param appearance the appearance fields applied when the condition matches
|
||||
@NotNullByDefault
|
||||
public record ThemeOverride(ThemeCondition condition, ThemeAppearance appearance) {
|
||||
/// JSON member name for an override condition.
|
||||
private static final String FIELD_CONDITION = "condition";
|
||||
|
||||
/// Creates a conditional theme override.
|
||||
///
|
||||
/// @param condition the condition required for this override to apply
|
||||
/// @param appearance the appearance fields applied when the condition matches
|
||||
public ThemeOverride {
|
||||
Objects.requireNonNull(condition);
|
||||
Objects.requireNonNull(appearance);
|
||||
if (appearance.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme override does not define any appearance fields");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a theme override from JSON.
|
||||
///
|
||||
/// @param element the override object
|
||||
/// @return the parsed override
|
||||
/// @throws JsonParseException if the override is malformed
|
||||
static @Nullable ThemeOverride fromJson(@Nullable JsonElement element) throws JsonParseException {
|
||||
if (element == null || element.isJsonNull())
|
||||
return null;
|
||||
|
||||
if (!(element instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Invalid theme override");
|
||||
}
|
||||
|
||||
JsonElement conditionElement = object.get(FIELD_CONDITION);
|
||||
if (!(conditionElement instanceof JsonObject conditionObject)) {
|
||||
throw new JsonParseException("Theme override must define an object condition");
|
||||
}
|
||||
|
||||
ThemeCondition condition = ThemeCondition.fromJson(conditionObject);
|
||||
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
|
||||
if (appearance.isEmpty()) {
|
||||
throw new JsonParseException("Theme override does not define any appearance fields");
|
||||
}
|
||||
return new ThemeOverride(condition, appearance);
|
||||
}
|
||||
|
||||
/// Returns whether this override matches the given resolution context.
|
||||
///
|
||||
/// @param context the context to test
|
||||
/// @return `true` when the override should be applied
|
||||
public boolean matches(ThemeResolveContext context) {
|
||||
return condition.matches(context);
|
||||
}
|
||||
|
||||
/// Converts this override to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this override
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = appearance.toJsonObject();
|
||||
object.add(FIELD_CONDITION, condition.toJsonObject());
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +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.theme;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/// One resource copied into an exported theme-pack zip.
|
||||
///
|
||||
/// @param source the resource to copy
|
||||
/// @param entryName the normalized zip entry name under `assets/`
|
||||
@NotNullByDefault
|
||||
public record ThemePackAsset(ThemePackResource source, String entryName) {
|
||||
|
||||
/// Required prefix for theme-pack asset entries.
|
||||
private static final String ASSETS_PREFIX = "assets/";
|
||||
|
||||
/// Creates a theme-pack asset from a local file.
|
||||
///
|
||||
/// @param source the local file to copy
|
||||
/// @param entryName the normalized zip entry name under `assets/`
|
||||
public ThemePackAsset(Path source, String entryName) {
|
||||
this(new ThemePackResource.File(source), entryName);
|
||||
}
|
||||
|
||||
/// Creates a theme-pack asset entry.
|
||||
///
|
||||
/// @param source the resource to copy
|
||||
/// @param entryName the normalized zip entry name under `assets/`
|
||||
public ThemePackAsset {
|
||||
Objects.requireNonNull(source);
|
||||
entryName = normalizeEntryName(entryName);
|
||||
}
|
||||
|
||||
/// Normalizes and validates a zip entry name.
|
||||
///
|
||||
/// @param entryName the entry name to validate
|
||||
/// @return the normalized entry name
|
||||
/// @throws IllegalArgumentException if the entry name is unsafe or outside `assets/`
|
||||
static String normalizeEntryName(String entryName) {
|
||||
Objects.requireNonNull(entryName);
|
||||
|
||||
String normalized = entryName.trim().replace('\\', '/');
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry is empty");
|
||||
}
|
||||
if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry must be relative: " + entryName);
|
||||
}
|
||||
if (!normalized.startsWith(ASSETS_PREFIX)) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry must be under assets/: " + entryName);
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry must be a file: " + entryName);
|
||||
}
|
||||
|
||||
for (String segment : normalized.split("/")) {
|
||||
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry contains an unsafe segment: " + entryName);
|
||||
}
|
||||
for (int i = 0; i < segment.length(); i++) {
|
||||
char ch = segment.charAt(i);
|
||||
if (Character.isISOControl(ch) || ch == '\0') {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry contains a control character: " + entryName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +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.theme;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Author metadata declared by a theme pack.
|
||||
///
|
||||
/// @param name the localized author display name
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
@JsonAdapter(ThemePackAuthor.Adapter.class)
|
||||
public record ThemePackAuthor(LocalizedText name) {
|
||||
|
||||
/// Parses author metadata from a JSON array.
|
||||
static @Unmodifiable List<ThemePackAuthor> parseAuthors(@Nullable JsonElement element) throws JsonParseException {
|
||||
if (element == null || element.isJsonNull()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (!(element instanceof JsonArray jsonArray)) {
|
||||
throw new JsonParseException("Theme-pack authors must be an array");
|
||||
}
|
||||
|
||||
ArrayList<ThemePackAuthor> authors = new ArrayList<>(jsonArray.size());
|
||||
int index = 0;
|
||||
for (JsonElement authorJson : jsonArray) {
|
||||
try {
|
||||
ThemePackAuthor author = fromJson(authorJson);
|
||||
if (author != null) {
|
||||
authors.add(author);
|
||||
}
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack author at authors[" + index + "]: " + authorJson, e);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return List.copyOf(authors);
|
||||
}
|
||||
|
||||
/// Converts author metadata to a JSON array.
|
||||
static JsonArray toJson(List<ThemePackAuthor> authors) {
|
||||
JsonArray array = new JsonArray();
|
||||
for (ThemePackAuthor author : authors) {
|
||||
array.add(author.toJsonObject());
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/// Parses author metadata from a JSON object or a plain string.
|
||||
///
|
||||
/// @param json the author metadata JSON
|
||||
/// @return the parsed author metadata, or `null` when `json` is `null`
|
||||
/// @throws JsonParseException if `json` is neither an object nor a string author name
|
||||
public static @Nullable ThemePackAuthor fromJson(@Nullable JsonElement json) throws JsonParseException {
|
||||
if (json == null || json instanceof JsonNull)
|
||||
return null;
|
||||
|
||||
if (json instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
try {
|
||||
return new ThemePackAuthor(LocalizedText.plain(primitive.getAsString()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(json instanceof JsonObject jsonObject)) {
|
||||
throw new JsonParseException("Theme-pack author must be an object or a string");
|
||||
}
|
||||
|
||||
JsonElement nameJson = jsonObject.get("name");
|
||||
if (nameJson == null) {
|
||||
throw new JsonParseException("Missing author name: " + json);
|
||||
}
|
||||
|
||||
LocalizedText name = LocalizedText.fromJson(nameJson);
|
||||
if (name == null) {
|
||||
throw new JsonParseException("The author name is null");
|
||||
}
|
||||
|
||||
try {
|
||||
return new ThemePackAuthor(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates theme-pack author metadata.
|
||||
///
|
||||
/// @param name the localized author display name
|
||||
public ThemePackAuthor {
|
||||
if (name.mayBeEmpty()) {
|
||||
throw new IllegalArgumentException("The author name cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the author display name in the current locale.
|
||||
///
|
||||
/// @return the localized author display name
|
||||
public String displayName() {
|
||||
return name.getText(I18n.getLocale().getCandidateLocales());
|
||||
}
|
||||
|
||||
/// Converts this author to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this author
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
object.add("name", name.toJsonElement());
|
||||
return object;
|
||||
}
|
||||
|
||||
static final class Adapter implements JsonSerializer<@Nullable ThemePackAuthor>, JsonDeserializer<@Nullable ThemePackAuthor> {
|
||||
@Override
|
||||
public @Nullable ThemePackAuthor deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return ThemePackAuthor.fromJson(json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable ThemePackAuthor src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
return src != null ? src.toJsonObject() : JsonNull.INSTANCE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +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.theme;
|
||||
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/// Writes theme-pack manifests and assets into zip-compatible files.
|
||||
@NotNullByDefault
|
||||
public final class ThemePackExporter {
|
||||
|
||||
/// Recommended file extension for theme-pack files.
|
||||
public static final String FILE_EXTENSION = ".hmcl-theme";
|
||||
|
||||
/// Manifest entry name used by every exported theme pack.
|
||||
public static final String MANIFEST_ENTRY = "manifest.json";
|
||||
|
||||
/// Prevents instantiation.
|
||||
private ThemePackExporter() {
|
||||
}
|
||||
|
||||
/// Exports a theme pack to a zip-compatible file.
|
||||
///
|
||||
/// @param manifest the manifest to write
|
||||
/// @param assets asset files to copy into the zip
|
||||
/// @param outputFile the target theme-pack file
|
||||
/// @throws IOException if a source file cannot be read or the zip cannot be written
|
||||
/// @throws IllegalArgumentException if an asset entry is duplicated or unsafe
|
||||
public static void export(
|
||||
ThemePackManifest manifest,
|
||||
@Unmodifiable List<ThemePackAsset> assets,
|
||||
Path outputFile) throws IOException {
|
||||
Objects.requireNonNull(manifest);
|
||||
Objects.requireNonNull(assets);
|
||||
Objects.requireNonNull(outputFile);
|
||||
|
||||
validateAssets(assets);
|
||||
|
||||
Path output = outputFile.toAbsolutePath().normalize();
|
||||
Path parent = output.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
|
||||
Path temporaryFile = FileUtils.tmpSaveFile(output);
|
||||
try {
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
try (Zipper zipper = new Zipper(temporaryFile)) {
|
||||
zipper.putTextFile(JsonUtils.GSON.toJson(manifest), MANIFEST_ENTRY);
|
||||
for (ThemePackAsset asset : assets) {
|
||||
try (InputStream input = asset.source().openStream()) {
|
||||
zipper.putStream(input, asset.entryName());
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.move(temporaryFile, output, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
try {
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
} catch (IOException suppressed) {
|
||||
e.addSuppressed(suppressed);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates all asset entries and source files before the zip is written.
|
||||
private static void validateAssets(List<ThemePackAsset> assets) throws IOException {
|
||||
Set<String> entries = new HashSet<>();
|
||||
entries.add(MANIFEST_ENTRY);
|
||||
|
||||
for (ThemePackAsset asset : assets) {
|
||||
Objects.requireNonNull(asset);
|
||||
@Nullable Path sourceFile = asset.source().file();
|
||||
if (sourceFile != null && !Files.isRegularFile(sourceFile)) {
|
||||
throw new IOException("Theme-pack asset source is not a regular file: " + sourceFile);
|
||||
}
|
||||
try (InputStream ignored = asset.source().openStream()) {
|
||||
// Validate readability before writing the target zip.
|
||||
}
|
||||
if (!entries.add(asset.entryName())) {
|
||||
throw new IllegalArgumentException("Duplicate theme-pack zip entry: " + asset.entryName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,345 +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.theme;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Parsed metadata and themes from a theme-pack manifest.
|
||||
///
|
||||
/// @param id the stable package identifier
|
||||
/// @param version the package version string
|
||||
/// @param name the localized display name
|
||||
/// @param authors the package authors
|
||||
/// @param icon the optional theme-pack relative icon path
|
||||
/// @param description the optional localized package description
|
||||
/// @param themes selectable themes declared by the package
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
@JsonAdapter(ThemePackManifest.Adapter.class)
|
||||
public record ThemePackManifest(
|
||||
String id,
|
||||
String version,
|
||||
LocalizedText name,
|
||||
@Unmodifiable List<ThemePackAuthor> authors,
|
||||
@Nullable String icon,
|
||||
@Nullable LocalizedText description,
|
||||
@Unmodifiable List<Theme> themes) {
|
||||
|
||||
/// JSON schema for the current manifest format.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("theme-pack", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Package and theme ID format used by theme-pack manifests.
|
||||
private static final Pattern PACKAGE_ID_PATTERN = Pattern.compile(
|
||||
"[A-Za-z0-9][A-Za-z0-9_-]*(\\.[A-Za-z0-9][A-Za-z0-9_-]*)*"
|
||||
);
|
||||
|
||||
/// Creates a theme-pack manifest.
|
||||
///
|
||||
/// @param id the stable package identifier
|
||||
/// @param version the package version string
|
||||
/// @param name the localized display name
|
||||
/// @param authors the package authors
|
||||
/// @param icon the optional theme-pack relative icon path
|
||||
/// @param description the optional localized package description
|
||||
/// @param themes selectable themes declared by the package
|
||||
public ThemePackManifest {
|
||||
id = requirePackageId(id);
|
||||
version = requireNonBlank(version, "version");
|
||||
name = requireLocalizedText(name, "name");
|
||||
authors = List.copyOf(authors);
|
||||
if (icon != null) {
|
||||
icon = ThemePackAsset.normalizeEntryName(icon);
|
||||
}
|
||||
if (description != null) {
|
||||
description = requireLocalizedText(description, "description");
|
||||
}
|
||||
themes = List.copyOf(themes);
|
||||
if (themes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme pack must declare at least one theme");
|
||||
}
|
||||
checkThemeIdentities(themes);
|
||||
}
|
||||
|
||||
public boolean isSimpleThemePack() {
|
||||
if (themes.size() == 1) {
|
||||
Theme theme = themes.get(0);
|
||||
return theme.id() == null && theme.name() == null;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the package display name in the current locale.
|
||||
///
|
||||
/// @return the localized display name, or the package ID when no localized text matches
|
||||
public String displayName() {
|
||||
return Objects.requireNonNullElse(name.getText(I18n.getLocale().getCandidateLocales()), id);
|
||||
}
|
||||
|
||||
/// Returns the package description in the current locale.
|
||||
///
|
||||
/// @return the localized description, or `null` when no localized text matches
|
||||
public @Nullable String displayDescription() {
|
||||
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
|
||||
}
|
||||
|
||||
/// Returns the first theme with the given ID.
|
||||
///
|
||||
/// A `null` theme ID matches only a single-theme manifest.
|
||||
///
|
||||
/// @param themeId the theme ID to find, or `null` for an unnamed single-theme manifest
|
||||
/// @return the matching theme, or `null` when no theme matches
|
||||
public @Nullable Theme findTheme(@Nullable String themeId) {
|
||||
if (themeId == null) {
|
||||
return themes.size() == 1 ? themes.get(0) : null;
|
||||
}
|
||||
|
||||
for (Theme theme : themes) {
|
||||
if (themeId.equals(theme.id())) {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Reads the required theme declaration.
|
||||
private static List<Theme> readThemes(JsonObject object) {
|
||||
boolean hasSingleTheme = object.has("theme");
|
||||
boolean hasMultipleThemes = object.has("themes");
|
||||
if (hasSingleTheme == hasMultipleThemes) {
|
||||
throw new JsonParseException("Theme-pack manifest must declare exactly one of theme or themes");
|
||||
}
|
||||
|
||||
if (hasSingleTheme) {
|
||||
JsonElement element = object.get("theme");
|
||||
if (!(element instanceof JsonObject themeObject)) {
|
||||
throw new JsonParseException("Theme-pack theme must be an object");
|
||||
}
|
||||
return List.of(Theme.fromJson(themeObject, false));
|
||||
}
|
||||
|
||||
JsonElement element = object.get("themes");
|
||||
if (!(element instanceof JsonArray array)) {
|
||||
throw new JsonParseException("Theme-pack manifest is missing themes array");
|
||||
}
|
||||
if (array.isEmpty()) {
|
||||
throw new JsonParseException("Theme-pack themes array must declare at least one theme");
|
||||
}
|
||||
|
||||
ArrayList<Theme> themes = new ArrayList<>(array.size());
|
||||
for (JsonElement item : array) {
|
||||
if (!(item instanceof JsonObject themeObject)) {
|
||||
throw new JsonParseException("Theme-pack theme must be an object");
|
||||
}
|
||||
themes.add(Theme.fromJson(themeObject, true));
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
/// Checks that theme IDs and names are present whenever the manifest needs them for disambiguation.
|
||||
private static void checkThemeIdentities(List<Theme> themes) {
|
||||
if (themes.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Theme theme : themes) {
|
||||
if (theme.id() == null) {
|
||||
throw new IllegalArgumentException("Theme ID is required when a theme pack declares multiple themes");
|
||||
}
|
||||
if (theme.name() == null) {
|
||||
throw new IllegalArgumentException("Theme name is required when a theme pack declares multiple themes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a required string member.
|
||||
private static String requireMemberString(JsonObject object, String fieldName) {
|
||||
JsonElement element = object.get(fieldName);
|
||||
if (element == null) {
|
||||
throw new JsonParseException("Theme-pack manifest is missing " + fieldName);
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Theme-pack manifest field must be a string: " + fieldName);
|
||||
}
|
||||
return requireNonBlank(primitive.getAsString(), fieldName);
|
||||
}
|
||||
|
||||
/// Parses a localized text value.
|
||||
static LocalizedText parseLocalizedText(JsonElement element, String field) {
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
return LocalizedText.plain(requireNonBlank(primitive.getAsString(), field));
|
||||
}
|
||||
if (element instanceof JsonObject localizedObject) {
|
||||
if (localizedObject.isEmpty()) {
|
||||
throw new JsonParseException("Localized text field is empty: " + field);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> localizedValues = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : localizedObject.entrySet()) {
|
||||
JsonElement value = entry.getValue();
|
||||
if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Localized text values must be strings: " + field);
|
||||
}
|
||||
localizedValues.put(
|
||||
requireNonBlank(entry.getKey(), field),
|
||||
requireNonBlank(primitive.getAsString(), field));
|
||||
}
|
||||
return new LocalizedText(localizedValues);
|
||||
}
|
||||
throw new JsonParseException("Theme-pack localized text must be a string or object: " + field);
|
||||
}
|
||||
|
||||
/// Returns a validated localized text value.
|
||||
static LocalizedText requireLocalizedText(LocalizedText value, String field) {
|
||||
Objects.requireNonNull(value);
|
||||
|
||||
JsonElement element = JsonUtils.GSON.toJsonTree(value, LocalizedText.class);
|
||||
return parseLocalizedText(element, field);
|
||||
}
|
||||
|
||||
/// Returns a non-blank string value.
|
||||
private static String requireNonBlank(String value, String field) {
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme-pack manifest field is blank: " + field);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// Returns a package ID matching the theme-pack ID format.
|
||||
static String requirePackageId(String value) {
|
||||
return requireId(value, "id", "Theme-pack manifest ID must follow package ID format: ");
|
||||
}
|
||||
|
||||
/// Returns a theme ID matching the theme-pack ID format.
|
||||
static String requireThemeId(String value) {
|
||||
return requireId(value, "theme id", "Theme ID must follow package ID format: ");
|
||||
}
|
||||
|
||||
/// Returns an ID matching the theme-pack ID format.
|
||||
private static String requireId(String value, String field, String messagePrefix) {
|
||||
String id = requireNonBlank(value, field);
|
||||
if (!PACKAGE_ID_PATTERN.matcher(id).matches()) {
|
||||
throw new IllegalArgumentException(messagePrefix + value);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
static final class Adapter implements JsonSerializer<@Nullable ThemePackManifest>,
|
||||
JsonDeserializer<@Nullable ThemePackManifest> {
|
||||
|
||||
@Override
|
||||
public @Nullable ThemePackManifest deserialize(
|
||||
@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json == null || json instanceof JsonNull) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(json instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Theme-pack manifest is not a JsonObject");
|
||||
}
|
||||
|
||||
if (!JsonSchema.check(object, CURRENT_SCHEMA).readable()) {
|
||||
throw new JsonParseException("Unsupported theme-pack schema: " + object.get(JsonSchema.PROPERTY_SCHEMA));
|
||||
}
|
||||
|
||||
String id = requireMemberString(object, "id");
|
||||
String version = requireMemberString(object, "version");
|
||||
|
||||
LocalizedText name = context.deserialize(object.get("name"), LocalizedText.class);
|
||||
if (name == null) {
|
||||
throw new JsonParseException("Theme-pack manifest is missing name");
|
||||
}
|
||||
|
||||
@Nullable LocalizedText description;
|
||||
try {
|
||||
description = context.deserialize(object.get("description"), LocalizedText.class);
|
||||
if (description != null) {
|
||||
description = requireLocalizedText(description, "description");
|
||||
}
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack description", e);
|
||||
description = null;
|
||||
}
|
||||
|
||||
List<ThemePackAuthor> authors;
|
||||
try {
|
||||
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack authors", e);
|
||||
authors = List.of();
|
||||
}
|
||||
|
||||
@Nullable String icon = JsonUtils.getString(object, "icon");
|
||||
if (icon != null) {
|
||||
try {
|
||||
icon = ThemePackAsset.normalizeEntryName(icon);
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack icon: " + icon, e);
|
||||
icon = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new ThemePackManifest(
|
||||
id,
|
||||
version,
|
||||
name,
|
||||
authors,
|
||||
icon,
|
||||
description,
|
||||
readThemes(object));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable ThemePackManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null) {
|
||||
return JsonNull.INSTANCE;
|
||||
}
|
||||
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty(JsonSchema.PROPERTY_SCHEMA, CURRENT_SCHEMA.url());
|
||||
object.addProperty("id", src.id);
|
||||
object.addProperty("version", src.version);
|
||||
object.add("name", src.name.toJsonElement());
|
||||
object.add("authors", ThemePackAuthor.toJson(src.authors));
|
||||
|
||||
if (src.description != null) {
|
||||
object.add("description", src.description.toJsonElement());
|
||||
}
|
||||
if (src.icon != null) {
|
||||
object.addProperty("icon", src.icon);
|
||||
}
|
||||
|
||||
if (src.isSimpleThemePack()) {
|
||||
object.add("theme", src.themes.get(0).toJsonObject());
|
||||
} else {
|
||||
JsonArray themeArray = new JsonArray();
|
||||
for (Theme theme : src.themes) {
|
||||
themeArray.add(theme.toJsonObject());
|
||||
}
|
||||
object.add("themes", themeArray);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,200 +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.theme;
|
||||
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/// A readable resource contributed by a theme pack.
|
||||
@NotNullByDefault
|
||||
public sealed interface ThemePackResource
|
||||
permits ThemePackResource.File, ThemePackResource.Zip, ThemePackResource.Builtin {
|
||||
/// Returns a stable resource name used for display and extension detection.
|
||||
///
|
||||
/// @return the resource name
|
||||
String name();
|
||||
|
||||
/// Opens a fresh input stream for this resource.
|
||||
///
|
||||
/// @return a new stream for reading the resource content
|
||||
/// @throws IOException if the resource cannot be opened
|
||||
InputStream openStream() throws IOException;
|
||||
|
||||
/// Returns the backing file when this resource is a regular filesystem file.
|
||||
///
|
||||
/// @return the backing file, or `null` when the resource is not a direct file
|
||||
default @Nullable Path file() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A resource stored as a regular filesystem file.
|
||||
///
|
||||
/// @param path the resource file path
|
||||
/// @param name the stable resource name
|
||||
@NotNullByDefault
|
||||
record File(Path path, String name) implements ThemePackResource {
|
||||
/// Creates a file-backed theme-pack resource named after its file name.
|
||||
///
|
||||
/// @param path the resource file path
|
||||
public File(Path path) {
|
||||
this(path, Objects.requireNonNull(path).getFileName().toString());
|
||||
}
|
||||
|
||||
/// Creates a file-backed theme-pack resource.
|
||||
///
|
||||
/// @param path the resource file path
|
||||
/// @param name the stable resource name
|
||||
public File {
|
||||
path = Objects.requireNonNull(path).toAbsolutePath().normalize();
|
||||
name = Objects.requireNonNull(name).trim();
|
||||
if (name.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme-pack resource name is blank");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the file.
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
return Files.newInputStream(path);
|
||||
}
|
||||
|
||||
/// Returns the backing file.
|
||||
@Override
|
||||
public Path file() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource stored as an entry inside a zip theme-pack file.
|
||||
///
|
||||
/// @param zipFile the zip theme-pack file
|
||||
/// @param entryName the normalized zip entry name
|
||||
@NotNullByDefault
|
||||
record Zip(Path zipFile, String entryName) implements ThemePackResource {
|
||||
/// Creates a zip-entry theme-pack resource.
|
||||
///
|
||||
/// @param zipFile the zip theme-pack file
|
||||
/// @param entryName the normalized zip entry name
|
||||
public Zip {
|
||||
zipFile = Objects.requireNonNull(zipFile).toAbsolutePath().normalize();
|
||||
entryName = ThemePackAsset.normalizeEntryName(entryName);
|
||||
}
|
||||
|
||||
/// Returns the zip entry name.
|
||||
@Override
|
||||
public String name() {
|
||||
return entryName;
|
||||
}
|
||||
|
||||
/// Opens the zip entry and closes the zip file when the returned stream is closed.
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
ZipArchiveReader zip = new ZipArchiveReader(zipFile, StandardCharsets.UTF_8);
|
||||
boolean success = false;
|
||||
try {
|
||||
ZipArchiveEntry entry = zip.getEntry(entryName);
|
||||
if (entry == null || entry.isDirectory()) {
|
||||
throw new IOException("Theme-pack asset is missing: " + entryName);
|
||||
}
|
||||
InputStream input = zip.getInputStream(entry);
|
||||
success = true;
|
||||
return new ZipEntryInputStream(input, zip);
|
||||
} finally {
|
||||
if (!success) {
|
||||
zip.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource stored in launcher-bundled classpath resources.
|
||||
///
|
||||
/// @param resourcePath the classpath resource path
|
||||
/// @param entryName the theme-pack entry name
|
||||
@NotNullByDefault
|
||||
record Builtin(String resourcePath, String entryName) implements ThemePackResource {
|
||||
/// Creates a classpath-backed theme-pack resource.
|
||||
///
|
||||
/// @param resourcePath the classpath resource path
|
||||
/// @param entryName the theme-pack entry name
|
||||
public Builtin {
|
||||
resourcePath = Objects.requireNonNull(resourcePath);
|
||||
entryName = ThemePackAsset.normalizeEntryName(entryName);
|
||||
}
|
||||
|
||||
/// Returns the theme-pack entry name.
|
||||
@Override
|
||||
public String name() {
|
||||
return entryName;
|
||||
}
|
||||
|
||||
/// Opens the classpath resource.
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
@Nullable InputStream input = ThemePackResource.class.getResourceAsStream(resourcePath);
|
||||
if (input == null) {
|
||||
throw new IOException("Built-in theme-pack asset is missing: " + entryName);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/// An input stream that owns a zip file handle.
|
||||
@NotNullByDefault
|
||||
final class ZipEntryInputStream extends FilterInputStream {
|
||||
/// The zip file that must stay open while this stream is read.
|
||||
private final ZipArchiveReader zipFile;
|
||||
|
||||
/// Whether this stream has already been closed.
|
||||
private boolean closed;
|
||||
|
||||
/// Creates a zip-entry stream.
|
||||
///
|
||||
/// @param input the zip entry input stream
|
||||
/// @param zipFile the owning zip file
|
||||
private ZipEntryInputStream(InputStream input, ZipArchiveReader zipFile) {
|
||||
super(input);
|
||||
this.zipFile = Objects.requireNonNull(zipFile);
|
||||
}
|
||||
|
||||
/// Closes both the entry stream and the owning zip file.
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
|
||||
@Nullable IOException exception = null;
|
||||
try {
|
||||
super.close();
|
||||
} catch (IOException e) {
|
||||
exception = e;
|
||||
}
|
||||
|
||||
try {
|
||||
zipFile.close();
|
||||
} catch (IOException e) {
|
||||
if (exception != null) {
|
||||
exception.addSuppressed(e);
|
||||
} else {
|
||||
exception = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (exception != null) {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +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.theme;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Identifies one theme declared by an installed theme pack.
|
||||
///
|
||||
/// @param packId the theme-pack identifier
|
||||
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public record ThemeReference(
|
||||
@SerializedName("packId") String packId,
|
||||
@SerializedName("themeId") @Nullable String themeId) {
|
||||
|
||||
/// Creates a theme reference.
|
||||
///
|
||||
/// @param packId the theme-pack identifier
|
||||
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
|
||||
public ThemeReference {
|
||||
packId = ThemePackManifest.requirePackageId(packId);
|
||||
if (themeId != null) {
|
||||
themeId = ThemePackManifest.requireThemeId(themeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +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.theme;
|
||||
|
||||
import org.glavo.monetfx.Brightness;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Environment values used to resolve conditional theme-pack overrides.
|
||||
///
|
||||
/// @param brightness the effective light or dark mode after resolving launcher and system settings
|
||||
/// @param os the normalized operating system name used by theme conditions
|
||||
/// @param language the normalized UI language subtag used by theme conditions
|
||||
@NotNullByDefault
|
||||
public record ThemeResolveContext(
|
||||
Brightness brightness,
|
||||
String os,
|
||||
String language) {
|
||||
|
||||
/// Creates a theme resolution context.
|
||||
///
|
||||
/// @param brightness the effective light or dark mode after resolving launcher and system settings
|
||||
/// @param os the normalized operating system name used by theme conditions
|
||||
/// @param language the normalized UI language subtag used by theme conditions
|
||||
public ThemeResolveContext {
|
||||
Objects.requireNonNull(brightness);
|
||||
os = normalizeToken(os, "os");
|
||||
language = normalizeToken(language, "language");
|
||||
}
|
||||
|
||||
/// Creates a context for the current process platform.
|
||||
///
|
||||
/// @param brightness the effective light or dark mode after resolving launcher and system settings
|
||||
/// @return a context containing the current OS and default language
|
||||
public static ThemeResolveContext current(Brightness brightness) {
|
||||
return new ThemeResolveContext(
|
||||
brightness,
|
||||
normalizeOperatingSystem(OperatingSystem.CURRENT_OS),
|
||||
Locale.getDefault().getLanguage());
|
||||
}
|
||||
|
||||
/// Returns the normalized value for one supported condition key.
|
||||
///
|
||||
/// @param key the condition key
|
||||
/// @return the normalized context value, or `null` when the key is unsupported
|
||||
@Nullable String conditionValue(String key) {
|
||||
return switch (key) {
|
||||
case ThemeCondition.KEY_BRIGHTNESS -> brightness.name().toLowerCase(Locale.ROOT);
|
||||
case ThemeCondition.KEY_OS -> os;
|
||||
case ThemeCondition.KEY_LANGUAGE -> language;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the normalized name for an operating system.
|
||||
///
|
||||
/// @param operatingSystem the operating system to normalize
|
||||
/// @return the normalized operating system name
|
||||
static String normalizeOperatingSystem(OperatingSystem operatingSystem) {
|
||||
Objects.requireNonNull(operatingSystem);
|
||||
|
||||
return operatingSystem == OperatingSystem.UNKNOWN ? "unknown" : operatingSystem.getCheckedName();
|
||||
}
|
||||
|
||||
/// Normalizes one condition context token.
|
||||
private static String normalizeToken(String value, String name) {
|
||||
Objects.requireNonNull(value, name);
|
||||
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty theme condition context value: " + name);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +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.theme;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Title-bar settings contributed by a theme-pack appearance.
|
||||
///
|
||||
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
|
||||
@NotNullByDefault
|
||||
public record ThemeTitleBar(@Nullable Boolean transparent) {
|
||||
/// JSON member name for title-bar transparency.
|
||||
private static final String FIELD_TRANSPARENT = "transparent";
|
||||
|
||||
/// Creates a title-bar patch.
|
||||
///
|
||||
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
|
||||
public ThemeTitleBar {
|
||||
}
|
||||
|
||||
/// Parses title-bar settings from a JSON object.
|
||||
///
|
||||
/// @param object the title-bar JSON object
|
||||
/// @return the parsed title-bar patch
|
||||
static ThemeTitleBar fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
return new ThemeTitleBar(readTransparent(object));
|
||||
}
|
||||
|
||||
/// Converts this title-bar patch to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this title-bar patch
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
if (transparent != null) {
|
||||
object.addProperty(FIELD_TRANSPARENT, transparent);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns whether this title-bar object contains no concrete fields.
|
||||
///
|
||||
/// @return `true` when every field is inherited
|
||||
public boolean isEmpty() {
|
||||
return transparent == null;
|
||||
}
|
||||
|
||||
/// Returns a title-bar patch that applies the given patch over this title-bar patch.
|
||||
///
|
||||
/// @param patch the patch to apply
|
||||
/// @return the merged title-bar patch
|
||||
public ThemeTitleBar merge(ThemeTitleBar patch) {
|
||||
Objects.requireNonNull(patch);
|
||||
|
||||
return new ThemeTitleBar(patch.transparent != null ? patch.transparent : transparent);
|
||||
}
|
||||
|
||||
/// Reads the optional transparent field.
|
||||
private static @Nullable Boolean readTransparent(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_TRANSPARENT);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
|
||||
LOG.warning("Ignored invalid theme titleBar.transparent: expected a boolean, got " + element);
|
||||
return null;
|
||||
}
|
||||
return primitive.getAsBoolean();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,80 +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.theme;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.glavo.monetfx.ColorScheme;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Extracts a MonetFX seed color from wallpaper images.
|
||||
@NotNullByDefault
|
||||
public final class WallpaperColorExtractor {
|
||||
/// Prevents instantiation.
|
||||
private WallpaperColorExtractor() {
|
||||
}
|
||||
|
||||
/// Extracts a theme color from an image file.
|
||||
///
|
||||
/// @param imageFile the image file
|
||||
/// @param fallback the fallback color used when extraction fails
|
||||
/// @return the extracted color, or `fallback` when no suitable color is found
|
||||
/// @throws IOException if the image file cannot be read
|
||||
public static ThemeColor extract(Path imageFile, ThemeColor fallback) throws IOException {
|
||||
Objects.requireNonNull(imageFile);
|
||||
Objects.requireNonNull(fallback);
|
||||
|
||||
Image image;
|
||||
try {
|
||||
image = FXUtils.loadImage(imageFile);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IOException ioException) {
|
||||
throw ioException;
|
||||
}
|
||||
throw new IOException("Failed to load wallpaper image: " + imageFile, e);
|
||||
}
|
||||
|
||||
return extract(image, fallback);
|
||||
}
|
||||
|
||||
/// Extracts a theme color from a theme-pack resource.
|
||||
///
|
||||
/// @param resource the theme-pack resource
|
||||
/// @param fallback the fallback color used when extraction fails
|
||||
/// @return the extracted color, or `fallback` when no suitable color is found
|
||||
/// @throws IOException if the resource cannot be read
|
||||
static ThemeColor extract(ThemePackResource resource, ThemeColor fallback) throws IOException {
|
||||
Objects.requireNonNull(resource);
|
||||
Objects.requireNonNull(fallback);
|
||||
|
||||
Image image;
|
||||
try {
|
||||
image = FXUtils.loadImage(resource.openStream(), resource.name());
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IOException ioException) {
|
||||
throw ioException;
|
||||
}
|
||||
throw new IOException("Failed to load wallpaper image: " + resource.name(), e);
|
||||
}
|
||||
|
||||
return extract(image, fallback);
|
||||
}
|
||||
|
||||
/// Extracts a theme color from a loaded image.
|
||||
///
|
||||
/// @param image the loaded image
|
||||
/// @param fallback the fallback color used when extraction fails
|
||||
/// @return the extracted color, or `fallback` when no suitable color is found
|
||||
public static ThemeColor extract(Image image, ThemeColor fallback) {
|
||||
Objects.requireNonNull(image);
|
||||
Objects.requireNonNull(fallback);
|
||||
|
||||
Color extracted = ColorScheme.extractColor(image, fallback.color());
|
||||
return ThemeColor.of(extracted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.theme;
|
||||
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// The fixed warm cream palette used by window chrome that is painted from Java.
|
||||
///
|
||||
/// Values match `/assets/css/warm.css`, which supplies the same colors to style sheets.
|
||||
@NotNullByDefault
|
||||
public final class WarmPalette {
|
||||
private WarmPalette() {
|
||||
}
|
||||
|
||||
/// The window and title-bar background.
|
||||
public static final Color WINDOW = Color.web("#F3EEE6");
|
||||
|
||||
/// The title-bar text and icon color.
|
||||
public static final Color INK = Color.web("#2A2622");
|
||||
}
|
||||
@@ -27,11 +27,16 @@ import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.GetTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.ui.account.AccountListPage;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
@@ -39,10 +44,8 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.decorator.Decorator;
|
||||
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
|
||||
import org.jackhuang.hmcl.ui.main.RootPage;
|
||||
import org.jackhuang.hmcl.ui.instances.GameListPage;
|
||||
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
@@ -88,31 +91,12 @@ public final class Controllers {
|
||||
|
||||
/// Returns the shared launcher-title property.
|
||||
///
|
||||
/// For official builds, first access checks integrity, asynchronously if no result is cached.
|
||||
/// Failed verification changes the title to indicate a modified build; asynchronous results
|
||||
/// are applied on the JavaFX application thread.
|
||||
/// The title is the configured server name, or the generic launcher name when none is set.
|
||||
public static ReadOnlyStringProperty launcherTitleProperty() {
|
||||
if (launcherTitle == null) {
|
||||
launcherTitle = new SimpleStringProperty(Controllers.class, "launcherTitle", Metadata.OFFICIAL_BUILD
|
||||
? i18n("launcher.title", Metadata.VERSION)
|
||||
: i18n("launcher.title.unofficial", Metadata.VERSION));
|
||||
if (Metadata.OFFICIAL_BUILD) {
|
||||
@Nullable Boolean selfVerified = IntegrityChecker.getSelfVerified();
|
||||
if (selfVerified != null) {
|
||||
if (!selfVerified) {
|
||||
launcherTitle.set(i18n("launcher.title.modified", Metadata.VERSION));
|
||||
}
|
||||
} else {
|
||||
Schedulers.io().execute(() -> {
|
||||
if (!IntegrityChecker.isSelfVerified()) {
|
||||
FXUtils.runInFX(() -> {
|
||||
//noinspection DataFlowIssue
|
||||
launcherTitle.set(i18n("launcher.title.modified", Metadata.VERSION));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
String serverName = Metadata.getServerName();
|
||||
launcherTitle = new SimpleStringProperty(Controllers.class, "launcherTitle",
|
||||
serverName.isEmpty() ? i18n("launcher.name") : serverName);
|
||||
}
|
||||
|
||||
return launcherTitle;
|
||||
@@ -147,28 +131,6 @@ public final class Controllers {
|
||||
return gameInstancePage;
|
||||
}
|
||||
|
||||
/// The cached game-list page.
|
||||
private static @Nullable GameListPage gameListPage;
|
||||
|
||||
/// Creates and attaches the game-list page for a layout pulse unless already cached.
|
||||
@FXThread
|
||||
public static void prepareGameListPage() {
|
||||
if (gameListPage == null) {
|
||||
LOG.info("Preparing game list page");
|
||||
gameListPage = getDecorator().prewarmPage(new GameListPage());
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cached game-list page, creating it if necessary.
|
||||
@FXThread
|
||||
public static GameListPage getGameListPage() {
|
||||
if (gameListPage == null) {
|
||||
gameListPage = new GameListPage();
|
||||
}
|
||||
return gameListPage;
|
||||
}
|
||||
|
||||
|
||||
/// The cached root page.
|
||||
private static @Nullable RootPage rootPage;
|
||||
|
||||
@@ -397,35 +359,59 @@ public final class Controllers {
|
||||
);
|
||||
}
|
||||
|
||||
/// Installs the package found by [Metadata#findBundledModpackFile()] asynchronously, if present.
|
||||
///
|
||||
/// Shows progress without a cancel action and attempts to delete the package after success.
|
||||
/// Failures are logged; a retained package may be retried at the next startup.
|
||||
/// Installs a launcher-bundled modpack when present; otherwise, when the repository has no
|
||||
/// instances yet, pulls the org client [Metadata#getClientManifestUrl] and opens the install wizard.
|
||||
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
|
||||
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
|
||||
if (modpackFile == null) {
|
||||
if (modpackFile != null) {
|
||||
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
|
||||
|
||||
Controllers.taskDialog(
|
||||
Task.composeAsync(Schedulers.io(), () -> {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
|
||||
return ModpackHelper.getInstallTask(
|
||||
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to install bundled modpack", exception);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(modpackFile);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
|
||||
}
|
||||
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
|
||||
if (repository.getDisplayInstances().findAny().isPresent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String manifestUrl = Metadata.getClientManifestUrl();
|
||||
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
|
||||
Controllers.taskDialog(
|
||||
Task.composeAsync(Schedulers.io(), () -> {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
|
||||
return ModpackHelper.getInstallTask(
|
||||
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
|
||||
new GetTask(WebURL.parse(manifestUrl))
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to install bundled modpack", exception);
|
||||
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(modpackFile);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
|
||||
ServerModpackManifest manifest =
|
||||
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
|
||||
if (manifest == null) {
|
||||
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
|
||||
return;
|
||||
}
|
||||
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
|
||||
Controllers.getDecorator().startWizard(
|
||||
new ModpackInstallWizardProvider(repository, manifest),
|
||||
i18n("install.modpack"));
|
||||
}),
|
||||
i18n("message.downloading"),
|
||||
TaskCancellationAction.NO_CANCEL
|
||||
);
|
||||
}
|
||||
|
||||
@@ -713,7 +699,6 @@ public final class Controllers {
|
||||
onApplicationStop();
|
||||
rootPage = null;
|
||||
gameInstancePage = null;
|
||||
gameListPage = null;
|
||||
accountListPage = null;
|
||||
settingsPage = null;
|
||||
decorator = null;
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
|
||||
@@ -78,7 +77,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
|
||||
getSidebar().clear();
|
||||
|
||||
getSidebar()
|
||||
.startCategory(i18n("account.create").toUpperCase(Locale.ROOT))
|
||||
.startCategory(i18n("account.create"))
|
||||
.add(offlineItem)
|
||||
.add(microsoftItem);
|
||||
|
||||
|
||||
@@ -11,11 +11,17 @@ import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class AccountListPopupMenu extends StackPane {
|
||||
@@ -31,12 +37,26 @@ public final class AccountListPopupMenu extends StackPane {
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private final InvalidationListener listener;
|
||||
|
||||
/// Creates a footer entry shown below the account list.
|
||||
private static AdvancedListItem footerItem(SVG icon, String title) {
|
||||
AdvancedListItem item = new AdvancedListItem();
|
||||
item.setLeftIcon(icon);
|
||||
item.setTitle(title);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Closes the popup that currently hosts this menu, if any.
|
||||
private void hidePopup() {
|
||||
if (getScene() != null && getScene().getWindow() instanceof JFXPopup popup)
|
||||
popup.hide();
|
||||
}
|
||||
|
||||
public AccountListPopupMenu() {
|
||||
AdvancedListBox box = new AdvancedListBox();
|
||||
box.getStyleClass().add("no-padding");
|
||||
box.setPrefWidth(220);
|
||||
box.setPrefHeight(-1);
|
||||
box.setMaxHeight(260);
|
||||
box.setMaxHeight(320);
|
||||
|
||||
listener = o -> {
|
||||
box.clear();
|
||||
@@ -61,9 +81,31 @@ public final class AccountListPopupMenu extends StackPane {
|
||||
Label placeholder = new Label(i18n("account.empty"));
|
||||
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
|
||||
|
||||
FXUtils.onChangeAndOperate(isEmpty, empty -> {
|
||||
getChildren().setAll(empty ? placeholder : box);
|
||||
// Footer actions stay visible even when there is no account yet: add one, or open the full account page.
|
||||
AdvancedListItem addItem = footerItem(SVG.ADD_CIRCLE, i18n("account.add"));
|
||||
addItem.setOnAction(e -> {
|
||||
hidePopup();
|
||||
// The first account uses the short name dialog; later ones can pick any login method.
|
||||
if (Accounts.getAccounts().isEmpty()) {
|
||||
Controllers.dialog(new FirstRunAccountDialog());
|
||||
} else {
|
||||
Controllers.dialog(new CreateAccountPane());
|
||||
}
|
||||
});
|
||||
AdvancedListItem manageItem = footerItem(SVG.SETTINGS, i18n("account.open_manage"));
|
||||
manageItem.setOnAction(e -> {
|
||||
hidePopup();
|
||||
Controllers.navigate(Controllers.getAccountListPage());
|
||||
});
|
||||
VBox footer = new VBox(addItem, manageItem);
|
||||
footer.setPrefWidth(220);
|
||||
|
||||
VBox content = new VBox();
|
||||
FXUtils.onChangeAndOperate(isEmpty, empty -> {
|
||||
footer.getChildren().setAll(empty ? List.of(addItem) : List.of(addItem, manageItem));
|
||||
content.getChildren().setAll(empty ? List.of(placeholder, footer) : List.of(box, footer));
|
||||
});
|
||||
getChildren().setAll(content);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -340,15 +339,6 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
|
||||
int rowIndex = 0;
|
||||
|
||||
if (!IntegrityChecker.isOfficial() && !(factory instanceof OfflineAccountFactory)) {
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hintPane.setSegment(i18n("unofficial.hint"));
|
||||
GridPane.setColumnSpan(hintPane, 2);
|
||||
add(hintPane, 0, rowIndex);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
if (factory instanceof BoundAuthlibInjectorAccountFactory) {
|
||||
this.server = ((BoundAuthlibInjectorAccountFactory) factory).getServer();
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.ui.account;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogAware;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// A small dialog shown when the player has no account yet.
|
||||
///
|
||||
/// The player either types a game name, which creates an offline account, or chooses to sign in
|
||||
/// with a Microsoft account instead. Authentication itself is left to the server.
|
||||
@NotNullByDefault
|
||||
public final class FirstRunAccountDialog extends JFXDialogLayout implements DialogAware {
|
||||
/// Names accepted by the game: letters, digits and underscores, at most 16 characters.
|
||||
private static final Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,16}$");
|
||||
|
||||
private final JFXTextField nameField = new JFXTextField();
|
||||
private final Label hint = new Label();
|
||||
private final JFXButton acceptButton = new JFXButton(i18n("first_run.accept"));
|
||||
|
||||
/// Creates the dialog with its name field and the two actions.
|
||||
public FirstRunAccountDialog() {
|
||||
Label heading = new Label(i18n("first_run.title"));
|
||||
setHeading(heading);
|
||||
|
||||
Label description = new Label(i18n("first_run.description"));
|
||||
description.setWrapText(true);
|
||||
nameField.setPromptText(i18n("first_run.name"));
|
||||
hint.setStyle("-fx-text-fill: -monet-error;");
|
||||
hint.setWrapText(true);
|
||||
VBox body = new VBox(13, description, nameField, hint);
|
||||
body.setPrefWidth(377);
|
||||
setBody(body);
|
||||
|
||||
JFXButton microsoftButton = new JFXButton(i18n("first_run.microsoft"));
|
||||
microsoftButton.getStyleClass().add("dialog-cancel");
|
||||
microsoftButton.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT));
|
||||
});
|
||||
|
||||
acceptButton.getStyleClass().add("dialog-accept");
|
||||
acceptButton.setOnAction(e -> createAccount());
|
||||
nameField.setOnAction(e -> acceptButton.fire());
|
||||
setActions(microsoftButton, acceptButton);
|
||||
}
|
||||
|
||||
/// Validates the typed name and creates and selects the offline account.
|
||||
private void createAccount() {
|
||||
String name = nameField.getText() == null ? "" : nameField.getText().trim();
|
||||
if (!NAME_PATTERN.matcher(name).matches()) {
|
||||
hint.setText(i18n("first_run.invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
hint.setText("");
|
||||
acceptButton.setDisable(true);
|
||||
Task.supplyAsync(() -> Accounts.FACTORY_OFFLINE.create(
|
||||
new CreateAccountPane.DialogCharacterSelector(), name, null, null,
|
||||
new OfflineAccountFactory.AdditionalData(null, null)))
|
||||
.whenComplete(Schedulers.javafx(), account -> {
|
||||
if (!Accounts.getAccounts().contains(account)) {
|
||||
Accounts.getAccounts().add(account);
|
||||
}
|
||||
Accounts.setSelectedAccount(account);
|
||||
fireEvent(new DialogCloseEvent());
|
||||
}, exception -> {
|
||||
acceptButton.setDisable(false);
|
||||
hint.setText(Accounts.localizeErrorMessage(exception));
|
||||
}).start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialogShown() {
|
||||
nameField.requestFocus();
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
|
||||
/// Animates forward navigation with temporary incoming-page caching.
|
||||
@Override
|
||||
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
|
||||
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
|
||||
double offset = 12.0;
|
||||
return new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
|
||||
@@ -152,7 +152,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
|
||||
/// Animates backward navigation with temporary incoming-page caching.
|
||||
@Override
|
||||
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
|
||||
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
|
||||
double offset = 12.0;
|
||||
return new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
|
||||
|
||||
@@ -23,10 +23,10 @@ import org.jetbrains.annotations.Nullable;
|
||||
public class TransitionPane extends StackPane {
|
||||
|
||||
/// The default easing curve for page transitions.
|
||||
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE;
|
||||
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE_OUT_CUBIC;
|
||||
|
||||
/// The default duration for page transitions.
|
||||
public static final Duration DEFAULT_DURATION = Motion.SHORT4;
|
||||
public static final Duration DEFAULT_DURATION = Duration.millis(320);
|
||||
|
||||
/// Half the default duration for transitions with two phases.
|
||||
public static final Duration DEFAULT_DURATION_HALF = DEFAULT_DURATION.divide(2);
|
||||
|
||||
@@ -135,6 +135,7 @@ public class ComponentList extends Control implements NoPaddingComponent {
|
||||
|
||||
{
|
||||
Label advanced = new Label(title);
|
||||
advanced.getStyleClass().add("component-list-title");
|
||||
node.getChildren().setAll(advanced);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +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.construct;
|
||||
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
public class DoubleValidator extends ValidatorBase {
|
||||
private final boolean nullable;
|
||||
|
||||
public DoubleValidator() {
|
||||
this(false);
|
||||
}
|
||||
|
||||
public DoubleValidator(@NamedArg("nullable") boolean nullable) {
|
||||
this.nullable = nullable;
|
||||
}
|
||||
|
||||
public DoubleValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
|
||||
super(message);
|
||||
this.nullable = nullable;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void eval() {
|
||||
if (srcControl.get() instanceof TextInputControl) {
|
||||
evalTextInputField();
|
||||
}
|
||||
}
|
||||
|
||||
private void evalTextInputField() {
|
||||
TextInputControl textField = ((TextInputControl) srcControl.get());
|
||||
|
||||
if (StringUtils.isBlank(textField.getText()))
|
||||
hasErrors.set(!nullable);
|
||||
else
|
||||
hasErrors.set(Lang.toDoubleOrNull(textField.getText()) == null);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +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.construct;
|
||||
|
||||
import static javafx.collections.FXCollections.emptyObservableList;
|
||||
import static javafx.collections.FXCollections.observableList;
|
||||
import static javafx.collections.FXCollections.singletonObservableList;
|
||||
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.controls.JFXListCell;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.scene.text.Font;
|
||||
|
||||
public final class FontComboBox extends JFXComboBox<String> {
|
||||
|
||||
private boolean loaded = false;
|
||||
|
||||
public FontComboBox() {
|
||||
setMinWidth(260);
|
||||
|
||||
styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\""));
|
||||
|
||||
setCellFactory(listView -> new JFXListCell<>() {
|
||||
@Override
|
||||
public void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (!empty) {
|
||||
setText(item);
|
||||
setGraphic(null);
|
||||
setStyle("-fx-font-family: \"" + item + "\"");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
itemsProperty().bind(BindingMapping.of(valueProperty())
|
||||
.map(value -> value == null ? emptyObservableList() : singletonObservableList(value)));
|
||||
|
||||
FXUtils.onClicked(this, () -> {
|
||||
if (loaded)
|
||||
return;
|
||||
itemsProperty().unbind();
|
||||
setItems(observableList(Font.getFamilies()));
|
||||
loaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,53 +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.construct;
|
||||
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.util.Callback;
|
||||
|
||||
/// @author Glavo
|
||||
public final class JFXCheckBoxTableCell<S, T> extends TableCell<S, T> {
|
||||
public static <S> Callback<TableColumn<S, Boolean>, TableCell<S, Boolean>> forTableColumn(
|
||||
final TableColumn<S, Boolean> column) {
|
||||
return list -> new JFXCheckBoxTableCell<>();
|
||||
}
|
||||
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
private BooleanProperty booleanProperty;
|
||||
|
||||
public JFXCheckBoxTableCell() {
|
||||
this.getStyleClass().add("jfx-checkbox-table-cell");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (empty) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
checkBox.disableProperty().unbind();
|
||||
} else {
|
||||
setGraphic(checkBox);
|
||||
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) {
|
||||
booleanProperty = obsValue;
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty);
|
||||
}
|
||||
|
||||
checkBox.disableProperty().bind(Bindings.not(
|
||||
getTableView().editableProperty().and(
|
||||
getTableColumn().editableProperty()).and(
|
||||
editableProperty())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,227 +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.construct;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.DirectoryChooser;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Window;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class LineFileChooserButton extends LineButton {
|
||||
private static final String DEFAULT_STYLE_CLASS = "line-file-select-button";
|
||||
|
||||
public LineFileChooserButton() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
setTrailingIcon(SVG.EDIT);
|
||||
}
|
||||
|
||||
/// Converts the given path to absolute/relative(if possible) path according to [#convertToRelativePathProperty()].
|
||||
private String processPath(Path path) {
|
||||
if (isConvertToRelativePath() && path.isAbsolute()) {
|
||||
try {
|
||||
return Metadata.CURRENT_DIRECTORY.relativize(path).normalize().toString();
|
||||
} catch (IllegalArgumentException e) {
|
||||
// the given path can't be relativized against current path
|
||||
}
|
||||
}
|
||||
return path.normalize().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fire() {
|
||||
super.fire();
|
||||
|
||||
Scene scene = this.getScene();
|
||||
|
||||
Window owner = scene != null ? scene.getWindow() : null; // TODO: Allow user to set owner stage
|
||||
String windowTitle = getFileChooserTitle();
|
||||
|
||||
Path initialDirectory = null;
|
||||
if (getLocation() != null) {
|
||||
Path file;
|
||||
try {
|
||||
file = FileUtils.toAbsolute(Path.of(getLocation()));
|
||||
if (Files.exists(file)) {
|
||||
if (Files.isRegularFile(file))
|
||||
initialDirectory = file.getParent();
|
||||
else if (Files.isDirectory(file))
|
||||
initialDirectory = file;
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Failed to resolve path: " + getLocation());
|
||||
}
|
||||
}
|
||||
|
||||
Path path;
|
||||
Type type = getType();
|
||||
if (type == Type.OPEN_DIRECTORY) {
|
||||
var directoryChooser = new DirectoryChooser();
|
||||
if (windowTitle != null)
|
||||
directoryChooser.setTitle(windowTitle);
|
||||
if (initialDirectory != null)
|
||||
directoryChooser.setInitialDirectory(initialDirectory.toFile());
|
||||
|
||||
path = FileUtils.toPath(directoryChooser.showDialog(owner));
|
||||
} else {
|
||||
var fileChooser = new FileChooser();
|
||||
if (windowTitle != null)
|
||||
fileChooser.setTitle(windowTitle);
|
||||
if (initialDirectory != null)
|
||||
fileChooser.setInitialDirectory(initialDirectory.toFile());
|
||||
|
||||
if (extensionFilters != null)
|
||||
fileChooser.getExtensionFilters().setAll(extensionFilters);
|
||||
|
||||
fileChooser.setInitialFileName(getInitialFileName());
|
||||
|
||||
path = FileUtils.toPath(switch (type) {
|
||||
case OPEN_FILE -> fileChooser.showOpenDialog(owner);
|
||||
case SAVE_FILE -> fileChooser.showSaveDialog(owner);
|
||||
default -> throw new AssertionError("Unknown Type: " + type);
|
||||
});
|
||||
}
|
||||
|
||||
if (path != null) {
|
||||
setLocation(processPath(path));
|
||||
}
|
||||
}
|
||||
|
||||
private final StringProperty location = new StringPropertyBase() {
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return LineFileChooserButton.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "location";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
setTrailingText(get());
|
||||
}
|
||||
};
|
||||
|
||||
public StringProperty locationProperty() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return locationProperty().get();
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
locationProperty().set(location);
|
||||
}
|
||||
|
||||
private final StringProperty fileChooserTitle = new SimpleStringProperty(this, "fileChooserTitle");
|
||||
|
||||
public StringProperty fileChooserTitleProperty() {
|
||||
return fileChooserTitle;
|
||||
}
|
||||
|
||||
public String getFileChooserTitle() {
|
||||
return fileChooserTitleProperty().get();
|
||||
}
|
||||
|
||||
public void setFileChooserTitle(String fileChooserTitle) {
|
||||
fileChooserTitleProperty().set(fileChooserTitle);
|
||||
}
|
||||
|
||||
private ObjectProperty<Type> type;
|
||||
|
||||
public ObjectProperty<Type> typeProperty() {
|
||||
if (type == null) {
|
||||
type = new SimpleObjectProperty<>(this, "type", Type.OPEN_FILE);
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type != null ? type.get() : Type.OPEN_FILE;
|
||||
}
|
||||
|
||||
public void setType(Type type) {
|
||||
typeProperty().set(type);
|
||||
}
|
||||
|
||||
private ObjectProperty<String> initialFileName;
|
||||
|
||||
public final ObjectProperty<String> initialFileNameProperty() {
|
||||
if (initialFileName == null)
|
||||
initialFileName = new SimpleObjectProperty<>(this, "initialFileName");
|
||||
|
||||
return initialFileName;
|
||||
}
|
||||
|
||||
public final String getInitialFileName() {
|
||||
return initialFileName != null ? initialFileName.get() : null;
|
||||
}
|
||||
|
||||
public final void setInitialFileName(String value) {
|
||||
initialFileNameProperty().set(value);
|
||||
}
|
||||
|
||||
private ObservableList<FileChooser.ExtensionFilter> extensionFilters;
|
||||
|
||||
public ObservableList<FileChooser.ExtensionFilter> getExtensionFilters() {
|
||||
if (extensionFilters == null)
|
||||
extensionFilters = FXCollections.observableArrayList();
|
||||
return extensionFilters;
|
||||
}
|
||||
|
||||
private BooleanProperty convertToRelativePath;
|
||||
|
||||
public BooleanProperty convertToRelativePathProperty() {
|
||||
if (convertToRelativePath == null)
|
||||
convertToRelativePath = new BooleanPropertyBase(false) {
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return LineFileChooserButton.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "convertToRelativePath";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
String location = getLocation();
|
||||
if (location == null)
|
||||
return;
|
||||
try {
|
||||
setLocation(processPath(FileUtils.toAbsolute(Path.of(getLocation()))));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
};
|
||||
return convertToRelativePath;
|
||||
}
|
||||
|
||||
public boolean isConvertToRelativePath() {
|
||||
return convertToRelativePath != null && convertToRelativePath.get();
|
||||
}
|
||||
|
||||
public void setConvertToRelativePath(boolean convertToRelativePath) {
|
||||
convertToRelativePathProperty().set(convertToRelativePath);
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
OPEN_FILE,
|
||||
OPEN_DIRECTORY,
|
||||
SAVE_FILE
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ public final class LineInheritableSwitch extends LineButtonBase {
|
||||
this.inheritButton = new JFXButton();
|
||||
inheritButton.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS);
|
||||
inheritButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
|
||||
// Hidden: the launcher has a single settings page, so there is no inherited/overridden choice to make.
|
||||
inheritButton.setVisible(false);
|
||||
inheritButton.setManaged(false);
|
||||
inheritButton.setGraphic(SVG.PUBLIC.createIcon(INHERIT_BUTTON_ICON_SIZE));
|
||||
this.inheritTooltip = new Tooltip();
|
||||
FXUtils.installFastTooltip(inheritButton, inheritTooltip);
|
||||
|
||||
@@ -1,77 +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.construct;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.MultipleSelectionModel;
|
||||
|
||||
public class NoneMultipleSelectionModel<T> extends MultipleSelectionModel<T> {
|
||||
|
||||
public NoneMultipleSelectionModel() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObservableList<Integer> getSelectedIndices() {
|
||||
return FXCollections.emptyObservableList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObservableList<T> getSelectedItems() {
|
||||
return FXCollections.emptyObservableList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectIndices(int index, int... indices) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectAll() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearAndSelect(int index) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void select(int index) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void select(T obj) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearSelection(int index) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearSelection() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSelected(int index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectPrevious() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectNext() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectFirst() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectLast() {
|
||||
}
|
||||
}
|
||||
@@ -81,10 +81,10 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
@NotNullByDefault
|
||||
public final class Decorator {
|
||||
/// The minimum width, in pixels, of the visible window content.
|
||||
private static final double MIN_CONTENT_WIDTH = 800.0;
|
||||
private static final double MIN_CONTENT_WIDTH = 987.0;
|
||||
|
||||
/// The minimum height, in pixels, including the 450-pixel background and 40-pixel title bar.
|
||||
private static final double MIN_CONTENT_HEIGHT = 450.0 + 40.0;
|
||||
private static final double MIN_CONTENT_HEIGHT = 570.0 + 40.0;
|
||||
|
||||
/// The minimum visible overlap, in pixels, used to accept a persisted window position.
|
||||
private static final double MIN_VISIBLE_WINDOW_AREA = 20.0;
|
||||
|
||||
@@ -21,12 +21,11 @@ import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
import org.glavo.monetfx.ColorRole;
|
||||
import org.glavo.monetfx.ColorScheme;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.theme.WarmPalette;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
@@ -81,7 +80,7 @@ final class MainWindowPane extends StackPane {
|
||||
() -> Themes.windowTransparentProperty().get()
|
||||
? null
|
||||
: new Background(new BackgroundFill(
|
||||
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
|
||||
WarmPalette.WINDOW,
|
||||
CornerRadii.EMPTY,
|
||||
Insets.EMPTY)),
|
||||
Themes.windowTransparentProperty(),
|
||||
@@ -101,7 +100,11 @@ final class MainWindowPane extends StackPane {
|
||||
titleBar = new BorderPane();
|
||||
titleBar.setPickOnBounds(false);
|
||||
titleBar.getStyleClass().add("jfx-tool-bar");
|
||||
titleBar.setRight(windowButtons);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
titleBar.setLeft(windowButtons);
|
||||
} else {
|
||||
titleBar.setRight(windowButtons);
|
||||
}
|
||||
|
||||
navBarPane = new TransitionPane();
|
||||
titleBar.setCenter(navBarPane);
|
||||
@@ -111,7 +114,6 @@ final class MainWindowPane extends StackPane {
|
||||
updateTitleBarBackground();
|
||||
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
|
||||
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
|
||||
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
|
||||
|
||||
InvalidationListener navBarListener = observable -> updateNavBar();
|
||||
decorator.stateProperty().addListener(holder.weak(navBarListener));
|
||||
@@ -153,16 +155,7 @@ final class MainWindowPane extends StackPane {
|
||||
Region backgroundNode = new Region();
|
||||
backgroundNode.setMouseTransparent(true);
|
||||
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
|
||||
holder.onWeakChangeAndOperate(Themes.backgroundProperty(), background -> {
|
||||
double opacity = background.opacity();
|
||||
if (opacity >= 0.001) {
|
||||
backgroundNode.setBackground(background.background());
|
||||
backgroundNode.setOpacity(opacity);
|
||||
} else {
|
||||
backgroundNode.setBackground(null);
|
||||
backgroundNode.setOpacity(0);
|
||||
}
|
||||
});
|
||||
// The launcher paints a flat cream window; wallpapers are no longer used.
|
||||
return backgroundNode;
|
||||
}
|
||||
|
||||
@@ -171,21 +164,39 @@ final class MainWindowPane extends StackPane {
|
||||
/// @return a size-limited top-right button row
|
||||
private HBox createWindowButtons() {
|
||||
HBox buttons = new HBox();
|
||||
buttons.setAlignment(Pos.TOP_RIGHT);
|
||||
decorator.capableDraggingWindow(buttons);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
// macOS style: round close/minimize buttons on the left. Zoom is shown but disabled.
|
||||
buttons.setAlignment(Pos.CENTER_LEFT);
|
||||
buttons.setSpacing(8);
|
||||
buttons.setPadding(new Insets(0, 0, 0, 20));
|
||||
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
|
||||
|
||||
JFXButton closeButton = macButton("mac-close", decorator::closeWindow);
|
||||
JFXButton minimizeButton = macButton("mac-minimize", decorator::minimizeWindow);
|
||||
JFXButton zoomButton = macButton("mac-zoom", () -> {
|
||||
});
|
||||
zoomButton.setDisable(true);
|
||||
buttons.getChildren().setAll(closeButton, minimizeButton, zoomButton);
|
||||
return buttons;
|
||||
}
|
||||
|
||||
// Windows and Linux style: flat rectangular buttons on the right.
|
||||
buttons.setAlignment(Pos.TOP_RIGHT);
|
||||
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
|
||||
|
||||
JFXButton minimizeButton = new JFXButton();
|
||||
minimizeButton.setFocusTraversable(false);
|
||||
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
|
||||
minimizeButton.getStyleClass().add("jfx-decorator-button");
|
||||
minimizeButton.getStyleClass().add("window-button");
|
||||
minimizeButton.setOnAction(event -> decorator.minimizeWindow());
|
||||
decorator.forbidDraggingWindow(minimizeButton);
|
||||
|
||||
JFXButton closeButton = new JFXButton();
|
||||
closeButton.setFocusTraversable(false);
|
||||
closeButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
|
||||
closeButton.getStyleClass().add("jfx-decorator-button");
|
||||
closeButton.getStyleClass().addAll("window-button", "window-close-button");
|
||||
closeButton.setOnAction(event -> decorator.closeWindow());
|
||||
decorator.forbidDraggingWindow(closeButton);
|
||||
|
||||
@@ -193,23 +204,25 @@ final class MainWindowPane extends StackPane {
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/// Creates one round macOS-style window button.
|
||||
///
|
||||
/// @param styleClass the class that selects the button color
|
||||
/// @param action the action to run when the button is pressed
|
||||
private JFXButton macButton(String styleClass, Runnable action) {
|
||||
JFXButton button = new JFXButton();
|
||||
button.setFocusTraversable(false);
|
||||
button.getStyleClass().addAll("mac-window-button", styleClass);
|
||||
button.setOnAction(event -> action.run());
|
||||
decorator.forbidDraggingWindow(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
/// Recomputes the title-bar background for the current theme and transparency preference.
|
||||
private void updateTitleBarBackground() {
|
||||
@Nullable ColorScheme colorScheme = Themes.getColorScheme();
|
||||
if (colorScheme == null) {
|
||||
titleBar.setBackground(null);
|
||||
} else if (Themes.titleBarTransparentProperty().get()) {
|
||||
Color surface = colorScheme.getSurface();
|
||||
titleBar.setBackground(new Background(new BackgroundFill(
|
||||
new Color(surface.getRed(), surface.getGreen(), surface.getBlue(), 0.5),
|
||||
CornerRadii.EMPTY,
|
||||
Insets.EMPTY)));
|
||||
} else {
|
||||
titleBar.setBackground(new Background(new BackgroundFill(
|
||||
colorScheme.getPrimaryContainer(),
|
||||
CornerRadii.EMPTY,
|
||||
Insets.EMPTY)));
|
||||
}
|
||||
titleBar.setBackground(new Background(new BackgroundFill(
|
||||
WarmPalette.WINDOW,
|
||||
CornerRadii.EMPTY,
|
||||
Insets.EMPTY)));
|
||||
}
|
||||
|
||||
/// Rebuilds the navigation bar from the current page state.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.util.FileNameSet;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Helpers for downloading a remote addon file into an instance subdirectory.
|
||||
///
|
||||
/// The former free-install / CurseForge-Modrinth browse hub was removed for the
|
||||
/// server-client launcher; only install-into-folder callbacks remain for update flows.
|
||||
public final class DownloadPage {
|
||||
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
|
||||
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
|
||||
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
|
||||
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
|
||||
|
||||
private DownloadPage() {
|
||||
}
|
||||
|
||||
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
|
||||
@Nullable HMCLGameInstance instance = instanceId != null
|
||||
? repository.findInstance(instanceId)
|
||||
: repository.getSelectedInstance();
|
||||
Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory();
|
||||
|
||||
var targetPath = runDirectory.resolve(subdirectoryName);
|
||||
|
||||
FileNameSet existingPaths;
|
||||
try {
|
||||
existingPaths = FileNameSet.list(targetPath, null);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to list folders in " + targetPath, e);
|
||||
existingPaths = new FileNameSet(false);
|
||||
}
|
||||
|
||||
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
|
||||
Path dest = targetPath.resolve(result);
|
||||
|
||||
Controllers.taskDialog(Task.composeAsync(() -> {
|
||||
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
|
||||
task.setName(file.name());
|
||||
return task;
|
||||
}).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
if (!(exception instanceof CancellationException)) {
|
||||
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
} else {
|
||||
Controllers.showToast(i18n("install.success"));
|
||||
}
|
||||
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
|
||||
handler.resolve();
|
||||
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.InstallerItem;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
|
||||
import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import static javafx.beans.binding.Bindings.createBooleanBinding;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
private boolean isNameModifiedByUser = false;
|
||||
|
||||
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
|
||||
super(controller, gameVersion, downloadProvider);
|
||||
|
||||
txtName.getValidators().addAll(
|
||||
new RequiredValidator(),
|
||||
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
|
||||
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
|
||||
installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty()));
|
||||
|
||||
txtName.textProperty().addListener((obs, oldText, newText) -> isNameModifiedByUser = true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
|
||||
}
|
||||
|
||||
private String getVersion(String id) {
|
||||
return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id));
|
||||
}
|
||||
|
||||
protected void reload() {
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
String libraryId = component.getComponentType().getPatchId();
|
||||
if (controller.getSettings().containsKey(libraryId)) {
|
||||
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
|
||||
} else {
|
||||
component.versionProperty().set(null);
|
||||
}
|
||||
}
|
||||
if (!isNameModifiedByUser) {
|
||||
setTxtNameWithLoaders();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(SettingsMap settings) {
|
||||
}
|
||||
|
||||
private static boolean checkName(String name) {
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
if (!(c >= '0' && c <= '9')
|
||||
&& !(c >= 'a' && c <= 'z')
|
||||
&& !(c >= 'A' && c <= 'Z')
|
||||
&& c != '-' && c != '_' && c != '.'
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void onInstall() {
|
||||
GameInstanceID instanceId = new GameInstanceID(txtName.getText());
|
||||
|
||||
if (!checkName(instanceId.id())) {
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("install.name.invalid"),
|
||||
i18n("message.warning"),
|
||||
MessageDialogPane.MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
controller.getSettings().put(INSTANCE_ID, instanceId);
|
||||
controller.onFinish();
|
||||
}, () -> {
|
||||
// The user selects Cancel and does nothing.
|
||||
})
|
||||
.build());
|
||||
} else {
|
||||
controller.getSettings().put(INSTANCE_ID, instanceId);
|
||||
controller.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
private void setTxtNameWithLoaders() {
|
||||
StringBuilder nameBuilder = new StringBuilder(getTitle());
|
||||
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
if (component.getComponentType() == GameComponentType.GAME
|
||||
|| !controller.getSettings().containsKey(component.getComponentType().getPatchId()))
|
||||
continue;
|
||||
|
||||
String loaderName = switch (component.getComponentType()) {
|
||||
case FORGE -> "Forge";
|
||||
case NEO_FORGE -> "NeoForge";
|
||||
case CLEANROOM -> "Cleanroom";
|
||||
case LEGACY_FABRIC -> "LegacyFabric";
|
||||
case FABRIC -> "Fabric";
|
||||
case LITELOADER -> "LiteLoader";
|
||||
case QUILT -> "Quilt";
|
||||
case OPTIFINE -> "OptiFine";
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (loaderName != null)
|
||||
nameBuilder.append('-').append(loaderName);
|
||||
}
|
||||
|
||||
txtName.setText(nameBuilder.toString());
|
||||
isNameModifiedByUser = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean showExtendPane() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void resetDefaultName() {
|
||||
isNameModifiedByUser = false;
|
||||
setTxtNameWithLoaders();
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -28,25 +28,40 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
private final HMCLGameRepository repository;
|
||||
private final Path file;
|
||||
private final String updateVersion;
|
||||
private final ServerModpackManifest remoteManifest;
|
||||
private String iconUrl;
|
||||
private boolean hasSource;
|
||||
|
||||
public ModpackInstallWizardProvider(HMCLGameRepository repository) {
|
||||
this(repository, null, null);
|
||||
this(repository, null, null, null);
|
||||
}
|
||||
|
||||
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile) {
|
||||
this(repository, modpackFile, null);
|
||||
this(repository, modpackFile, null, null);
|
||||
}
|
||||
|
||||
public ModpackInstallWizardProvider(HMCLGameRepository repository, GameInstanceID updateInstanceId) {
|
||||
this(repository, null, updateInstanceId);
|
||||
this(repository, null, updateInstanceId, null);
|
||||
}
|
||||
|
||||
/// Starts the wizard with a remote Server Modpack manifest (TDLM fileApi install).
|
||||
public ModpackInstallWizardProvider(HMCLGameRepository repository, ServerModpackManifest remoteManifest) {
|
||||
this(repository, null, null, remoteManifest);
|
||||
}
|
||||
|
||||
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, GameInstanceID updateInstanceId) {
|
||||
this(repository, modpackFile, updateInstanceId, null);
|
||||
}
|
||||
|
||||
private ModpackInstallWizardProvider(
|
||||
HMCLGameRepository repository,
|
||||
Path modpackFile,
|
||||
GameInstanceID updateInstanceId,
|
||||
ServerModpackManifest remoteManifest) {
|
||||
this.repository = repository;
|
||||
this.file = modpackFile;
|
||||
this.updateVersion = updateInstanceId != null ? updateInstanceId.toString() : null;
|
||||
this.remoteManifest = remoteManifest;
|
||||
}
|
||||
|
||||
public void setIconUrl(String iconUrl) {
|
||||
@@ -57,6 +72,8 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
public void start(SettingsMap settings) {
|
||||
if (file != null)
|
||||
settings.put(LocalModpackPage.MODPACK_FILE, file);
|
||||
if (remoteManifest != null)
|
||||
settings.put(RemoteModpackPage.MODPACK_SERVER_MANIFEST, remoteManifest);
|
||||
if (updateVersion != null)
|
||||
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
|
||||
if (StringUtils.isNotBlank(iconUrl))
|
||||
|
||||
@@ -1348,6 +1348,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
var button = new JFXButton();
|
||||
button.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS);
|
||||
button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
|
||||
// The launcher has a single settings page, so players never choose between inheriting and overriding.
|
||||
button.setVisible(false);
|
||||
button.setManaged(false);
|
||||
Tooltip tooltip = new Tooltip();
|
||||
button.getProperties().put(INHERIT_BUTTON_TOOLTIP_KEY, tooltip);
|
||||
FXUtils.installFastTooltip(button, tooltip);
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 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.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdate;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdateTask;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
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.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.CSVTable;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import org.jackhuang.hmcl.util.javafx.Subscription;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update")));
|
||||
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final Path localAddonDirectory;
|
||||
private final ObservableList<AddonUpdateObject> objects;
|
||||
|
||||
private final JFXListView<AddonUpdateObject> listView;
|
||||
|
||||
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
|
||||
this.downloadProvider = downloadProvider;
|
||||
this.localAddonDirectory = localAddonDirectory;
|
||||
|
||||
getStyleClass().add("gray-background");
|
||||
|
||||
var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList());
|
||||
var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList());
|
||||
|
||||
objects = FXCollections.observableArrayList();
|
||||
|
||||
{
|
||||
StackPane centerWrapper = new StackPane();
|
||||
centerWrapper.setStyle("-fx-padding: 10;");
|
||||
|
||||
ComponentList centrePane = new ComponentList();
|
||||
centrePane.getStyleClass().add("no-padding");
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("addon.check_update.only_preview"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
|
||||
listView = new JFXListView<>();
|
||||
listView.setItems(objects);
|
||||
listView.getStyleClass().add("jfx-list-view-float");
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
setMargin(listView, new Insets(0, 10, 0, 10));
|
||||
VBox.setVgrow(listView, Priority.ALWAYS);
|
||||
|
||||
listView.setCellFactory(AddonUpdateListCell::new);
|
||||
|
||||
ComponentList.setVgrow(listView, Priority.ALWAYS);
|
||||
|
||||
centrePane.getContent().setAll(listView);
|
||||
|
||||
centerWrapper.getChildren().setAll(centrePane);
|
||||
|
||||
setCenter(centerWrapper);
|
||||
}
|
||||
|
||||
{
|
||||
HBox actions = new HBox(8);
|
||||
actions.setPadding(new Insets(8));
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
JFXCheckBox allEnabledBox = new JFXCheckBox();
|
||||
allEnabledBox.setText(i18n("button.select_all"));
|
||||
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
|
||||
|
||||
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
|
||||
{
|
||||
List<Subscription> subscriptions = new ArrayList<>();
|
||||
FXUtils.onChangeAndOperate(showPreviewCheck.selectedProperty(), preview -> {
|
||||
if (preview) objects.setAll(common.get());
|
||||
else objects.setAll(release.get());
|
||||
subscriptions.forEach(Subscription::unsubscribe);
|
||||
subscriptions.clear();
|
||||
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
|
||||
});
|
||||
}
|
||||
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
|
||||
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
|
||||
exportListButton.setOnAction(e -> exportList());
|
||||
|
||||
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
|
||||
nextButton.setOnAction(e -> updateFiles());
|
||||
|
||||
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
onEscPressed(listView, cancelButton::fire);
|
||||
|
||||
actions.getChildren().setAll(allEnabledBox, showPreviewCheck, spacer, exportListButton, nextButton, cancelButton);
|
||||
|
||||
setBottom(actions);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFiles() {
|
||||
AddonUpdateTask task = new AddonUpdateTask(
|
||||
downloadProvider,
|
||||
localAddonDirectory,
|
||||
objects.stream()
|
||||
.filter(AddonUpdateObject::isEnabled)
|
||||
.map(AddonUpdateObject::getData)
|
||||
.toList()
|
||||
);
|
||||
Controllers.taskDialog(
|
||||
task.whenComplete(Schedulers.javafx(), exception -> {
|
||||
fireEvent(new PageCloseEvent());
|
||||
if (!task.getFailedAddons().isEmpty()) {
|
||||
Controllers.dialog(i18n("addon.check_update.failed_download") + "\n" +
|
||||
task.getFailedAddons().stream().map(LocalAddonFile::getFileName).collect(Collectors.joining("\n")),
|
||||
i18n("install.failed"),
|
||||
MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
if (exception == null) {
|
||||
Controllers.dialog(i18n("install.success"));
|
||||
}
|
||||
}),
|
||||
i18n("addon.check_update"),
|
||||
TaskCancellationAction.NORMAL);
|
||||
}
|
||||
|
||||
private void exportList() {
|
||||
Path path = Paths.get("hmcl-mod-update-list-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".csv").toAbsolutePath();
|
||||
|
||||
Controllers.taskDialog(Task.runAsync(() -> {
|
||||
CSVTable csvTable = new CSVTable();
|
||||
|
||||
csvTable.set(0, 0, "Source File Name");
|
||||
csvTable.set(1, 0, "Current Version");
|
||||
csvTable.set(2, 0, "Target Version");
|
||||
csvTable.set(3, 0, "Update Source");
|
||||
|
||||
for (int i = 0; i < objects.size(); i++) {
|
||||
csvTable.set(0, i + 1, objects.get(i).getFileName());
|
||||
csvTable.set(1, i + 1, objects.get(i).getCurrentVersion());
|
||||
csvTable.set(2, i + 1, objects.get(i).getTargetVersion());
|
||||
csvTable.set(3, i + 1, objects.get(i).getSource());
|
||||
}
|
||||
|
||||
csvTable.write(path);
|
||||
|
||||
FXUtils.showFileInExplorer(path);
|
||||
}).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception == null) {
|
||||
Controllers.dialog(path.toString(), i18n("message.success"));
|
||||
} else {
|
||||
Controllers.dialog("", i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
}), i18n("button.export"), TaskCancellationAction.NORMAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectWrapper<State> stateProperty() {
|
||||
return state;
|
||||
}
|
||||
|
||||
private static final class AddonUpdateObject {
|
||||
final AddonUpdate data;
|
||||
final String targetVersionType;
|
||||
final String source;
|
||||
String changelog = null;
|
||||
|
||||
final BooleanProperty enabled = new SimpleBooleanProperty();
|
||||
|
||||
final ItemPropertyAsyncCache<Image, AddonUpdateObject> iconCache;
|
||||
|
||||
public AddonUpdateObject(AddonUpdate data) {
|
||||
this.data = data;
|
||||
|
||||
this.targetVersionType = switch (data.targetVersion().versionType()) {
|
||||
case Release -> i18n("addon.channel.release");
|
||||
case Beta -> i18n("addon.channel.beta");
|
||||
case Alpha -> i18n("addon.channel.alpha");
|
||||
};
|
||||
this.source = switch (data.currentVersion().source()) {
|
||||
case CURSEFORGE -> i18n("addon.curseforge");
|
||||
case MODRINTH -> i18n("addon.modrinth");
|
||||
};
|
||||
|
||||
this.enabled.set(!data.localAddonFile().isDisabled());
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
|
||||
public AddonUpdate getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled.get();
|
||||
}
|
||||
|
||||
public BooleanProperty enabledProperty() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public LocalAddonFile<?> getLocalAddonFile() {
|
||||
return data.localAddonFile();
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return data.localAddonFile().getFileName();
|
||||
}
|
||||
|
||||
public String getCurrentVersion() {
|
||||
return data.currentVersion().version();
|
||||
}
|
||||
|
||||
public String getTargetVersion() {
|
||||
return data.targetVersion().version();
|
||||
}
|
||||
|
||||
public String getTargetVersionType() {
|
||||
return targetVersionType;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
if (data.localAddonFile() instanceof LocalModFile localModFile) {
|
||||
return GameInstanceIconType
|
||||
.getIconType(localModFile.getModLoaderType())
|
||||
.getIcon();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Image loadIcon() {
|
||||
if (data.localAddonFile() instanceof LocalModFile localModFile) {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(localModFile.getLogoPath())) {
|
||||
iconPaths.add(localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(localModFile.getFile())) {
|
||||
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(
|
||||
iconPath,
|
||||
80,
|
||||
80,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (!image.isError()
|
||||
&& image.getWidth() > 0
|
||||
&& image.getHeight() > 0
|
||||
&& Math.abs(image.getWidth() - image.getHeight()) < 1
|
||||
) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load addon icons", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.localAddonFile() instanceof ResourcePackFile resourcePackFile) {
|
||||
Image image = resourcePackFile.loadIcon();
|
||||
|
||||
if (image != null
|
||||
&& !image.isError()
|
||||
&& image.getWidth() > 0
|
||||
&& image.getHeight() > 0
|
||||
&& Math.abs(image.getWidth() - image.getHeight()) < 1
|
||||
) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultIcon();
|
||||
}
|
||||
}
|
||||
|
||||
private static class AddonUpdateListCell extends ListCell<AddonUpdateObject> {
|
||||
private final StackPane pane = new StackPane();
|
||||
private final RipplerContainer ripplerContainer;
|
||||
|
||||
private final JFXCheckBox enabledBox = new JFXCheckBox();
|
||||
private final ImageContainer imageContainer = new ImageContainer(40);
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton changelogButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
|
||||
AddonUpdateListCell(ListView<AddonUpdatesPage.AddonUpdateObject> listView) {
|
||||
HBox hbox = new HBox(16);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
hbox.setAlignment(Pos.CENTER);
|
||||
|
||||
hbox.getChildren().setAll(enabledBox, imageContainer, content, changelogButton);
|
||||
|
||||
pane.getStyleClass().add("md-list-cell");
|
||||
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
|
||||
|
||||
ripplerContainer = new RipplerContainer(hbox);
|
||||
pane.getChildren().setAll(ripplerContainer);
|
||||
|
||||
enabledBox.setMouseTransparent(false);
|
||||
imageContainer.setMouseTransparent(true);
|
||||
|
||||
FXUtils.installFastTooltip(changelogButton, i18n("addon.changelog"));
|
||||
changelogButton.setOnAction(event -> {
|
||||
AddonUpdateObject item = getItem();
|
||||
if (item != null) {
|
||||
Controllers.dialog(new AddonChangelog(item));
|
||||
}
|
||||
});
|
||||
|
||||
FXUtils.onClicked(this, () -> {
|
||||
AddonUpdateObject item = getItem();
|
||||
if (item != null) {
|
||||
item.enabled.set(!item.isEnabled());
|
||||
}
|
||||
});
|
||||
|
||||
FXUtils.limitCellWidth(listView, this);
|
||||
}
|
||||
|
||||
/// Updates addon version information and replaces tags together, reusing their labels.
|
||||
@Override
|
||||
public void updateItem(@Nullable AddonUpdateObject item, boolean empty) {
|
||||
@Nullable AddonUpdateObject oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
ripplerContainer.resetFeedback();
|
||||
|
||||
if (oldItem != null) {
|
||||
enabledBox.selectedProperty().unbindBidirectional(oldItem.enabledProperty());
|
||||
}
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
|
||||
enabledBox.selectedProperty().bindBidirectional(item.enabledProperty());
|
||||
|
||||
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
|
||||
LocalAddonFile<?> addonFile = item.getLocalAddonFile();
|
||||
|
||||
var tags = new ArrayList<Tag>(3);
|
||||
if (addonFile instanceof LocalModFile modFile)
|
||||
tags.add(new Tag(modFile.getId()));
|
||||
tags.add(new Tag(item.getSource()));
|
||||
tags.add(new Tag(item.getTargetVersionType()));
|
||||
content.getTags().setAll(tags);
|
||||
|
||||
if (addonFile instanceof LocalModFile modFile) {
|
||||
content.setTitle(modFile.getName());
|
||||
} else if (addonFile instanceof ResourcePackFile resourcePackFile) {
|
||||
content.setTitle(resourcePackFile.getFileName());
|
||||
}
|
||||
|
||||
content.setSubtitle(item.getCurrentVersion() + " → " + item.getTargetVersion());
|
||||
|
||||
setGraphic(pane);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonChangelog extends JFXDialogLayout {
|
||||
|
||||
public AddonChangelog(AddonUpdateObject object) {
|
||||
RemoteAddon.Version targetVersion = object.data.targetVersion();
|
||||
|
||||
this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name())));
|
||||
|
||||
VBox box = new VBox(8);
|
||||
box.setPadding(new Insets(8));
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
|
||||
loadChangelog(object, spinnerPane, scrollPane);
|
||||
spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane));
|
||||
|
||||
spinnerPane.setContent(scrollPane);
|
||||
box.getChildren().add(spinnerPane);
|
||||
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
|
||||
|
||||
this.setBody(box);
|
||||
|
||||
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
|
||||
versionPageBtn.setDisable(true);
|
||||
loadVersionPageUrl(object, versionPageBtn);
|
||||
|
||||
JFXButton closeButton = new JFXButton(i18n("button.ok"));
|
||||
closeButton.getStyleClass().add("dialog-accept");
|
||||
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
setActions(versionPageBtn, closeButton);
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
|
||||
|
||||
onEscPressed(this, closeButton::fire);
|
||||
}
|
||||
|
||||
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
|
||||
spinnerPane.setLoading(true);
|
||||
RemoteAddonRepository repo = object.data.targetVersion().source().getRepository();
|
||||
Task.supplyAsync(() -> {
|
||||
if (object.changelog != null) return object.changelog;
|
||||
RemoteAddon.Version version = object.data.targetVersion();
|
||||
return StringUtils.convertToHtml(
|
||||
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
|
||||
"238222".equals(object.data.targetVersion().projectId())
|
||||
);
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
|
||||
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo.getBaseUrl()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
|
||||
}
|
||||
spinnerPane.setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
|
||||
Task.supplyAsync(() ->
|
||||
object.data.targetVersion().source().getRepository().getVersionPageUrl(object.data.targetVersion())
|
||||
).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null && StringUtils.isNotBlank(result)) {
|
||||
button.setExternalLink(result);
|
||||
button.setDisable(false);
|
||||
} else {
|
||||
LOG.warning("Failed to load addon version page url", exception);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +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.instances;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.addon.datapack.DataPack;
|
||||
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.ListPageBase;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.DataPackInfoObject> implements WorldManagePage.WorldRefreshable {
|
||||
private final World world;
|
||||
private final DataPack dataPack;
|
||||
final BooleanProperty readOnly;
|
||||
|
||||
public DataPackListPage(WorldManagePage worldManagePage) {
|
||||
world = worldManagePage.getWorld();
|
||||
dataPack = new DataPack(world.getFile().resolve("datapacks"));
|
||||
setItems(MappedObservableList.create(dataPack.getPacks(), DataPackListPageSkin.DataPackInfoObject::new));
|
||||
readOnly = worldManagePage.readOnlyProperty();
|
||||
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
|
||||
this::installMultiDataPack, this::refresh);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void installMultiDataPack(List<Path> dataPackPath) {
|
||||
dataPackPath.forEach(this::installSingleDataPack);
|
||||
if (readOnly.get()) {
|
||||
Controllers.showToast(i18n("datapack.reload.toast"));
|
||||
}
|
||||
}
|
||||
|
||||
private void installSingleDataPack(Path dataPack) {
|
||||
try {
|
||||
this.dataPack.installPack(dataPack, world.getGameVersion());
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
LOG.warning("Unable to parse datapack file " + dataPack, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new DataPackListPageSkin(this);
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
setLoading(true);
|
||||
Task.runAsync(dataPack::loadFromDir)
|
||||
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
|
||||
.start();
|
||||
}
|
||||
|
||||
public void add() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle(i18n("datapack.add.title"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.datapack"), "*.zip"));
|
||||
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
|
||||
if (res != null) {
|
||||
installMultiDataPack(res);
|
||||
}
|
||||
|
||||
dataPack.loadFromDir();
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> {
|
||||
try {
|
||||
dataPack.deletePack(pack);
|
||||
} catch (IOException e) {
|
||||
failed.add(pack.getPath().toString());
|
||||
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("datapack.delete.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void setSelectedEnabled(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems, boolean enabled) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> {
|
||||
try {
|
||||
pack.setActive(enabled);
|
||||
} catch (IOException e) {
|
||||
failed.add(pack.getPath().toString());
|
||||
LOG.warning("Failed to enable or disable datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("datapack.toggle.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void openDataPackFolder() {
|
||||
FXUtils.openFolder(dataPack.getPath());
|
||||
}
|
||||
}
|
||||
@@ -1,370 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXSwitch;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.datapack.DataPack;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
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;
|
||||
|
||||
@NotNullByDefault
|
||||
final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
private final HBox normalToolbar;
|
||||
private final HBox selectingToolbar;
|
||||
|
||||
private final JFXListView<DataPackInfoObject> listView;
|
||||
|
||||
/// Whether the search mechanism is currently active.
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
|
||||
private final JFXTextField searchField;
|
||||
|
||||
/// Timer for debouncing search input to avoid executing search on every keystroke.
|
||||
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
|
||||
|
||||
DataPackListPageSkin(DataPackListPage 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.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
var selectAllButton = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
|
||||
|
||||
ListChangeListener<Object> listener = change -> selectAllButton.setDisable(!listView.getItems().isEmpty()
|
||||
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
searchBar = new HBox();
|
||||
normalToolbar = new HBox();
|
||||
selectingToolbar = new HBox();
|
||||
|
||||
normalToolbar.getChildren().addAll(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openDataPackFolder),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
);
|
||||
|
||||
JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () ->
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () ->
|
||||
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()), null));
|
||||
JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true));
|
||||
JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false));
|
||||
removeButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
enableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
disableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
|
||||
listView.getSelectionModel().getSelectedItems().addListener(listener);
|
||||
|
||||
selectingToolbar.getChildren().addAll(
|
||||
removeButton,
|
||||
enableButton,
|
||||
disableButton,
|
||||
selectAllButton,
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 5));
|
||||
searchField = new JFXTextField();
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
searchPause.setOnFinished(e -> search());
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
|
||||
searchPause.setRate(1);
|
||||
searchPause.playFromStart();
|
||||
}
|
||||
});
|
||||
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
|
||||
() -> {
|
||||
changeToolbar(normalToolbar);
|
||||
|
||||
searchField.clear();
|
||||
searchPause.stop();
|
||||
|
||||
isSearching.set(false);
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
});
|
||||
FXUtils.onEscPressed(searchField, closeSearchBar::fire);
|
||||
searchBar.getChildren().addAll(searchField, closeSearchBar);
|
||||
|
||||
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.ESCAPE) {
|
||||
if (listView.getSelectionModel().getSelectedItem() != null) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
|
||||
selectedItem -> {
|
||||
if (selectedItem == null)
|
||||
changeToolbar(isSearching.get() ? searchBar : normalToolbar);
|
||||
else
|
||||
changeToolbar(selectingToolbar);
|
||||
});
|
||||
FXUtils.setOverflowHidden(toolbarPane, 8);
|
||||
root.getContent().add(toolbarPane);
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("datapack.empty"));
|
||||
placeholderLabel.textProperty().bind(
|
||||
Bindings.createStringBinding(() -> {
|
||||
if (isSearching.get()) {
|
||||
return i18n("search.no_results_found");
|
||||
} else {
|
||||
return i18n("datapack.empty");
|
||||
}
|
||||
},
|
||||
isSearching)
|
||||
);
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
skinnable.getItems().addListener((ListChangeListener<? super DataPackInfoObject>) change -> {
|
||||
if (isSearching.get()) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
listView.getItems().addListener(listener);
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
|
||||
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
pane.getChildren().setAll(root);
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
/// Filters pack IDs and descriptions using the shared query syntax; invalid regular expressions leave the list empty.
|
||||
private void search() {
|
||||
isSearching.set(true);
|
||||
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
|
||||
|
||||
String queryString = searchField.getText();
|
||||
if (StringUtils.isBlank(queryString)) {
|
||||
listView.getItems().setAll(getSkinnable().getItems());
|
||||
} else {
|
||||
listView.getItems().clear();
|
||||
|
||||
Predicate<@Nullable String> predicate;
|
||||
try {
|
||||
predicate = StringUtils.compileQuery(queryString);
|
||||
} catch (PatternSyntaxException e) {
|
||||
LOG.warning("Illegal regular expression", e);
|
||||
return;
|
||||
}
|
||||
|
||||
for (DataPackInfoObject item : getSkinnable().getItems()) {
|
||||
if (predicate.test(item.getTitle()) || predicate.test(item.getSubtitle())) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void changeToolbar(HBox newToolbar) {
|
||||
@Nullable Node oldToolbar = toolbarPane.getCurrentNode();
|
||||
if (newToolbar != oldToolbar) {
|
||||
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
|
||||
if (newToolbar == searchBar) {
|
||||
// search button click will get focus while searchField request focus, this cause conflict.
|
||||
// Defer focus request to next pulse avoids this conflict.
|
||||
Platform.runLater(searchField::requestFocus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class DataPackInfoObject {
|
||||
private final DataPack.Pack packInfo;
|
||||
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
|
||||
|
||||
DataPackInfoObject(DataPack.Pack packInfo) {
|
||||
this.packInfo = packInfo;
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
|
||||
String getTitle() {
|
||||
return packInfo.getId();
|
||||
}
|
||||
|
||||
String getSubtitle() {
|
||||
return packInfo.getDescription().toString();
|
||||
}
|
||||
|
||||
DataPack.Pack getPackInfo() {
|
||||
return packInfo;
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
|
||||
}
|
||||
|
||||
private Image loadIcon() {
|
||||
Image image = null;
|
||||
Path imagePath;
|
||||
if (this.getPackInfo().isDirectory()) {
|
||||
imagePath = getPackInfo().getPath().resolve("pack.png");
|
||||
try {
|
||||
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
|
||||
}
|
||||
} else {
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) {
|
||||
imagePath = fs.getPath("/pack.png");
|
||||
if (Files.exists(imagePath)) {
|
||||
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (image != null && !image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
|
||||
Math.abs(image.getWidth() - image.getHeight()) < 1) {
|
||||
return image;
|
||||
} else {
|
||||
return getDefaultIcon();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
|
||||
private final JFXSwitch activeSwitch = new JFXSwitch();
|
||||
ImageContainer imageContainer = new ImageContainer(32);
|
||||
final TwoLineListItem content = new TwoLineListItem();
|
||||
|
||||
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getPackInfo().isActive());
|
||||
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
|
||||
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
|
||||
|
||||
/// Creates a selectable data pack row whose switch follows the read-only state.
|
||||
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
|
||||
super(listView);
|
||||
|
||||
HBox container = new HBox(8);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
container.setPadding(new Insets(0, 8, 0, 8));
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
setSelectable();
|
||||
|
||||
activeSwitch.disableProperty().bind(isReadOnlyProperty);
|
||||
activeSwitch.setSize(20);
|
||||
activeSwitch.setMinSize(30, 30);
|
||||
activeSwitch.setPrefSize(30, 30);
|
||||
activeSwitch.setMaxSize(30, 30);
|
||||
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
|
||||
|
||||
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
container.getChildren().setAll(imageContainer, content, activeSwitch);
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateControl(@Nullable DataPackInfoObject dataItem, boolean empty) {
|
||||
if (observedActiveProperty != null)
|
||||
observedActiveProperty.removeListener(weakActiveListener);
|
||||
if (empty || dataItem == null) return;
|
||||
content.setTitle(dataItem.getTitle());
|
||||
content.setSubtitle(dataItem.getSubtitle());
|
||||
|
||||
DataPack.Pack packInfo = dataItem.getPackInfo();
|
||||
(observedActiveProperty = packInfo.activeProperty()).addListener(weakActiveListener);
|
||||
activeSwitch.setDisableAnimation(true);
|
||||
activeSwitch.setSelected(packInfo.isActive());
|
||||
activeSwitch.setOnAction(event -> {
|
||||
try {
|
||||
packInfo.setActive(activeSwitch.isSelected());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to change state of datapack " + packInfo.getPath(), e);
|
||||
Controllers.showToast(i18n("datapack.toggle.failed"));
|
||||
} finally {
|
||||
activeSwitch.setSelected(packInfo.isActive());
|
||||
}
|
||||
});
|
||||
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
|
||||
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 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.JFXComboBox;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.ObjectBinding;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
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.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
|
||||
|
||||
public class DownloadListPage extends Control implements DecoratorPage {
|
||||
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty failed = new SimpleBooleanProperty(false);
|
||||
private final boolean instanceSelection;
|
||||
private final ObjectProperty<HMCLGameInstance.Optional> instanceReference = new SimpleObjectProperty<>();
|
||||
private final IntegerProperty pageOffset = new SimpleIntegerProperty(0);
|
||||
private final IntegerProperty pageCount = new SimpleIntegerProperty(-1);
|
||||
private final ListProperty<RemoteAddon> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
|
||||
private final ObservableList<GameInstanceID> instances = FXCollections.observableArrayList();
|
||||
private final ObjectProperty<GameInstanceID> selectedInstance = new SimpleObjectProperty<>();
|
||||
private final DownloadPage.DownloadCallback callback;
|
||||
private boolean searchInitialized = false;
|
||||
protected final BooleanProperty supportChinese = new SimpleBooleanProperty();
|
||||
private final ObservableList<Node> actions = FXCollections.observableArrayList();
|
||||
protected final ListProperty<String> downloadSources = new SimpleListProperty<>(this, "downloadSources", FXCollections.observableArrayList());
|
||||
protected final StringProperty downloadSource = new SimpleStringProperty();
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private int searchID = 0;
|
||||
protected final RemoteAddonRepository repository;
|
||||
protected final @NotNull RemoteAddon.Type type;
|
||||
private final DownloadProvider downloadProvider;
|
||||
|
||||
private Runnable retrySearch;
|
||||
|
||||
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
|
||||
this(type, repository, null, false);
|
||||
}
|
||||
|
||||
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
this.type = Objects.requireNonNull(type);
|
||||
this.repository = repository;
|
||||
this.callback = callback;
|
||||
this.instanceSelection = instanceSelection;
|
||||
this.downloadProvider = DownloadProviders.getDownloadProvider();
|
||||
}
|
||||
|
||||
public DownloadProvider getDownloadProvider() {
|
||||
return downloadProvider;
|
||||
}
|
||||
|
||||
public ObservableList<Node> getActions() {
|
||||
return actions;
|
||||
}
|
||||
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.instanceReference.set(instance);
|
||||
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
|
||||
if (!searchInitialized) {
|
||||
searchInitialized = true;
|
||||
search("", null, 0, "", RemoteAddonRepository.SortType.RELEVANCY);
|
||||
}
|
||||
|
||||
if (instanceSelection) {
|
||||
HMCLGameRepository repository = instance.repository();
|
||||
instances.setAll(repository.getDisplayInstances()
|
||||
.map(DefaultGameInstance::getId)
|
||||
.toList());
|
||||
@Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance();
|
||||
selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
return failed.get();
|
||||
}
|
||||
|
||||
public BooleanProperty failedProperty() {
|
||||
return failed;
|
||||
}
|
||||
|
||||
public void setFailed(boolean failed) {
|
||||
this.failed.set(failed);
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return loading.get();
|
||||
}
|
||||
|
||||
public BooleanProperty loadingProperty() {
|
||||
return loading;
|
||||
}
|
||||
|
||||
public void setLoading(boolean loading) {
|
||||
this.loading.set(loading);
|
||||
}
|
||||
|
||||
public void selectInstance(GameInstanceID instanceId) {
|
||||
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
|
||||
}
|
||||
|
||||
public DownloadPage.DownloadCallback getCallback() {
|
||||
return callback;
|
||||
}
|
||||
|
||||
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
|
||||
retrySearch = null;
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
|
||||
int currentSearchID = searchID = searchID + 1;
|
||||
Task.supplyAsync(() -> {
|
||||
HMCLGameInstance.Optional instanceReference = this.instanceReference.get();
|
||||
@Nullable HMCLGameInstance instance = instanceReference.instance();
|
||||
if (instance == null) {
|
||||
return userGameVersion;
|
||||
} else {
|
||||
GameVersionNumber version = instance.getVersion();
|
||||
return version != GameVersionNumber.unknown() ? version.toString() : "";
|
||||
}
|
||||
}).thenApplyAsync(
|
||||
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
|
||||
).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (searchID != currentSearchID) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
if (exception == null) {
|
||||
items.setAll(result.results().collect(Collectors.toList()));
|
||||
pageCount.set(result.totalPages());
|
||||
failed.set(false);
|
||||
} else {
|
||||
failed.set(true);
|
||||
pageCount.set(-1);
|
||||
retrySearch = () -> search(userGameVersion, category, pageOffset, searchFilter, sort);
|
||||
}
|
||||
}).executor(true);
|
||||
}
|
||||
|
||||
protected String getLocalizedCategory(String category, Object self) {
|
||||
return repository instanceof ModrinthRemoteAddonRepository
|
||||
? i18n("modrinth.category." + category)
|
||||
: i18n("curse.category." + category);
|
||||
}
|
||||
|
||||
protected boolean shouldDisplayCategory(String category) {
|
||||
return !"minecraft".equals(category);
|
||||
}
|
||||
|
||||
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
|
||||
return StringUtils.repeats(' ', category.indent * 4) +
|
||||
(category.category() == null
|
||||
? i18n("curse.category.0")
|
||||
: getLocalizedCategory(category.category().id(), category.category().self()));
|
||||
}
|
||||
|
||||
protected String getLocalizedOfficialPage() {
|
||||
if (repository instanceof ModrinthRemoteAddonRepository) {
|
||||
return i18n("addon.modrinth");
|
||||
} else {
|
||||
return i18n("addon.curseforge");
|
||||
}
|
||||
}
|
||||
|
||||
protected HMCLGameInstance.Optional getInstanceOptional() {
|
||||
if (instanceSelection) {
|
||||
@Nullable GameInstanceID instanceId = selectedInstance.get();
|
||||
return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId);
|
||||
} else {
|
||||
return instanceReference.get();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new ModDownloadListPageSkin(this);
|
||||
}
|
||||
|
||||
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
|
||||
private final JFXListView<RemoteAddon> listView = new JFXListView<>();
|
||||
private final RemoteImageLoader iconLoader;
|
||||
|
||||
protected ModDownloadListPageSkin(DownloadListPage control) {
|
||||
super(control);
|
||||
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
iconLoader = new RemoteImageLoader(control.downloadProvider) {
|
||||
@Override
|
||||
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
|
||||
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
|
||||
}
|
||||
};
|
||||
|
||||
BorderPane pane = new BorderPane();
|
||||
|
||||
GridPane searchPane = new GridPane();
|
||||
pane.setTop(searchPane);
|
||||
searchPane.getStyleClass().addAll("card");
|
||||
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
|
||||
|
||||
ColumnConstraints nameColumn = new ColumnConstraints();
|
||||
nameColumn.setMinWidth(USE_PREF_SIZE);
|
||||
ColumnConstraints column1 = new ColumnConstraints();
|
||||
column1.setHgrow(Priority.ALWAYS);
|
||||
ColumnConstraints column2 = new ColumnConstraints();
|
||||
column2.setHgrow(Priority.ALWAYS);
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2);
|
||||
|
||||
searchPane.setHgap(16);
|
||||
searchPane.setVgap(10);
|
||||
|
||||
{
|
||||
int rowIndex = 0;
|
||||
|
||||
if (control.instanceSelection || !control.downloadSources.isEmpty()) {
|
||||
searchPane.addRow(rowIndex);
|
||||
int columns = 0;
|
||||
Node lastNode = null;
|
||||
if (control.instanceSelection) {
|
||||
JFXComboBox<GameInstanceID> instancesComboBox = new JFXComboBox<>();
|
||||
instancesComboBox.setMaxWidth(Double.MAX_VALUE);
|
||||
Bindings.bindContent(instancesComboBox.getItems(), control.instances);
|
||||
selectedItemPropertyFor(instancesComboBox).bindBidirectional(control.selectedInstance);
|
||||
|
||||
searchPane.add(new Label(i18n("instance")), columns++, rowIndex);
|
||||
searchPane.add(lastNode = instancesComboBox, columns++, rowIndex);
|
||||
}
|
||||
|
||||
if (control.downloadSources.getSize() > 1) {
|
||||
JFXComboBox<String> downloadSourceComboBox = new JFXComboBox<>();
|
||||
downloadSourceComboBox.setMaxWidth(Double.MAX_VALUE);
|
||||
downloadSourceComboBox.getItems().setAll(control.downloadSources.get());
|
||||
downloadSourceComboBox.setConverter(stringConverter(I18n::i18n));
|
||||
selectedItemPropertyFor(downloadSourceComboBox).bindBidirectional(control.downloadSource);
|
||||
|
||||
searchPane.add(new Label(i18n("settings.launcher.download_source")), columns++, rowIndex);
|
||||
searchPane.add(lastNode = downloadSourceComboBox, columns++, rowIndex);
|
||||
}
|
||||
|
||||
if (columns == 2) {
|
||||
GridPane.setColumnSpan(lastNode, 3);
|
||||
}
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
JFXTextField nameField = new JFXTextField();
|
||||
nameField.setPromptText(getSkinnable().supportChinese.get() ? i18n("search.hint.chinese") : i18n("search.hint.english"));
|
||||
if (getSkinnable().supportChinese.get()) {
|
||||
FXUtils.installFastTooltip(nameField, i18n("search.hint.chinese"));
|
||||
} else {
|
||||
FXUtils.installFastTooltip(nameField, i18n("search.hint.english"));
|
||||
}
|
||||
|
||||
JFXComboBox<String> gameVersionField = new JFXComboBox<>();
|
||||
gameVersionField.setMaxWidth(Double.MAX_VALUE);
|
||||
gameVersionField.setEditable(true);
|
||||
gameVersionField.getItems().setAll(GameVersionNumber.getDefaultGameVersions());
|
||||
Label lblGameVersion = new Label(i18n("world.game_version"));
|
||||
searchPane.addRow(rowIndex++, new Label(i18n("mods.name")), nameField, lblGameVersion, gameVersionField);
|
||||
|
||||
ObjectBinding<Boolean> hasVersion = BindingMapping.of(getSkinnable().instanceReference)
|
||||
.map(instanceReference -> instanceReference.instanceId() == null);
|
||||
lblGameVersion.managedProperty().bind(hasVersion);
|
||||
lblGameVersion.visibleProperty().bind(hasVersion);
|
||||
gameVersionField.managedProperty().bind(hasVersion);
|
||||
gameVersionField.visibleProperty().bind(hasVersion);
|
||||
FXUtils.installFastTooltip(gameVersionField, i18n("search.enter"));
|
||||
|
||||
FXUtils.onChangeAndOperate(getSkinnable().instanceReference, instanceReference -> {
|
||||
if (instanceReference.instanceId() != null) {
|
||||
GridPane.setColumnSpan(nameField, 3);
|
||||
} else {
|
||||
GridPane.setColumnSpan(nameField, 1);
|
||||
}
|
||||
});
|
||||
|
||||
StackPane categoryStackPane = new StackPane();
|
||||
JFXComboBox<CategoryIndented> categoryComboBox = new JFXComboBox<>();
|
||||
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
|
||||
categoryStackPane.getChildren().setAll(categoryComboBox);
|
||||
categoryComboBox.prefWidthProperty().bind(categoryStackPane.widthProperty());
|
||||
categoryComboBox.getStyleClass().add("fit-width");
|
||||
categoryComboBox.setPromptText(i18n("addon.category"));
|
||||
categoryComboBox.getSelectionModel().select(0);
|
||||
categoryComboBox.setConverter(stringConverter(getSkinnable()::getLocalizedCategoryIndent));
|
||||
FXUtils.onChangeAndOperate(getSkinnable().downloadSource, downloadSource -> {
|
||||
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
|
||||
categoryComboBox.getSelectionModel().select(0);
|
||||
|
||||
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
|
||||
.thenAcceptAsync(Schedulers.javafx(), categories -> {
|
||||
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<CategoryIndented> result = new ArrayList<>();
|
||||
result.add(CategoryIndented.ALL);
|
||||
for (RemoteAddonRepository.Category category : Lang.toIterable(categories)) {
|
||||
resolveCategory(category, 0, result);
|
||||
}
|
||||
categoryComboBox.getItems().setAll(result);
|
||||
categoryComboBox.getSelectionModel().select(0);
|
||||
}).start();
|
||||
});
|
||||
|
||||
StackPane sortStackPane = new StackPane();
|
||||
JFXComboBox<RemoteAddonRepository.SortType> sortComboBox = new JFXComboBox<>();
|
||||
sortStackPane.getChildren().setAll(sortComboBox);
|
||||
sortComboBox.prefWidthProperty().bind(sortStackPane.widthProperty());
|
||||
sortComboBox.getStyleClass().add("fit-width");
|
||||
sortComboBox.setConverter(stringConverter(sortType -> i18n("addon.sort." + sortType.name().toLowerCase(Locale.ROOT))));
|
||||
sortComboBox.getItems().setAll(RemoteAddonRepository.SortType.values());
|
||||
sortComboBox.getSelectionModel().select(0);
|
||||
searchPane.addRow(rowIndex++, new Label(i18n("addon.category")), categoryStackPane, new Label(i18n("search.sort")), sortStackPane);
|
||||
|
||||
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
|
||||
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
|
||||
EventHandler<ActionEvent> searchAction = e -> {
|
||||
iconLoader.clearInvalidCache();
|
||||
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
|
||||
control.pageOffset.set(0);
|
||||
}
|
||||
currentFilterID.set(filterID.get());
|
||||
|
||||
int pageOffset = control.pageOffset.get();
|
||||
getSkinnable().search(gameVersionField.getSelectionModel().getSelectedItem(),
|
||||
Optional.ofNullable(categoryComboBox.getSelectionModel().getSelectedItem())
|
||||
.map(CategoryIndented::category)
|
||||
.orElse(null),
|
||||
pageOffset == -1 ? 0 : pageOffset,
|
||||
nameField.getText(),
|
||||
sortComboBox.getSelectionModel().getSelectedItem());
|
||||
};
|
||||
|
||||
control.listenerHolder.add(FXUtils.observeWeak(
|
||||
() -> filterID.set(filterID.get() + 1),
|
||||
|
||||
control.downloadSource,
|
||||
gameVersionField.getSelectionModel().selectedItemProperty(),
|
||||
categoryComboBox.getSelectionModel().selectedItemProperty(),
|
||||
nameField.textProperty(),
|
||||
sortComboBox.getSelectionModel().selectedItemProperty()
|
||||
));
|
||||
|
||||
HBox actionsBox = new HBox(8);
|
||||
GridPane.setColumnSpan(actionsBox, 4);
|
||||
actionsBox.setAlignment(Pos.CENTER);
|
||||
{
|
||||
AggregatedObservableList<Node> actions = new AggregatedObservableList<>();
|
||||
|
||||
Holder<Runnable> changeButton = new Holder<>();
|
||||
|
||||
JFXButton firstPageButton = FXUtils.newBorderButton(i18n("search.first_page"));
|
||||
firstPageButton.setOnAction(event -> {
|
||||
control.pageOffset.set(0);
|
||||
searchAction.handle(event);
|
||||
changeButton.value.run();
|
||||
});
|
||||
|
||||
JFXButton previousPageButton = FXUtils.newBorderButton(i18n("search.previous_page"));
|
||||
previousPageButton.setOnAction(event -> {
|
||||
int pageOffset = control.pageOffset.get();
|
||||
if (pageOffset > 0) {
|
||||
control.pageOffset.set(pageOffset - 1);
|
||||
searchAction.handle(event);
|
||||
changeButton.value.run();
|
||||
}
|
||||
});
|
||||
|
||||
Label pageDescription = new Label();
|
||||
pageDescription.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||
int pageCount = control.pageCount.get();
|
||||
return i18n("search.page_n", control.pageOffset.get() + 1, pageCount == -1 ? "-" : String.valueOf(pageCount));
|
||||
}, control.pageOffset, control.pageCount));
|
||||
|
||||
JFXButton nextPageButton = FXUtils.newBorderButton(i18n("search.next_page"));
|
||||
nextPageButton.setOnAction(event -> {
|
||||
int nv = control.pageOffset.get() + 1;
|
||||
if (nv < control.pageCount.get()) {
|
||||
control.pageOffset.set(nv);
|
||||
searchAction.handle(event);
|
||||
changeButton.value.run();
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton lastPageButton = FXUtils.newBorderButton(i18n("search.last_page"));
|
||||
lastPageButton.setOnAction(event -> {
|
||||
control.pageOffset.set(control.pageCount.get() - 1);
|
||||
searchAction.handle(event);
|
||||
changeButton.value.run();
|
||||
});
|
||||
|
||||
firstPageButton.setDisable(true);
|
||||
previousPageButton.setDisable(true);
|
||||
lastPageButton.setDisable(true);
|
||||
nextPageButton.setDisable(true);
|
||||
|
||||
changeButton.value = () -> {
|
||||
int pageOffset = control.pageOffset.get();
|
||||
int pageCount = control.pageCount.get();
|
||||
|
||||
boolean disableAll = pageCount >= -1 && pageCount <= 1;
|
||||
|
||||
boolean disablePrevious = disableAll || pageOffset == 0;
|
||||
firstPageButton.setDisable(disablePrevious);
|
||||
previousPageButton.setDisable(disablePrevious);
|
||||
|
||||
boolean disableNext = disableAll || pageOffset == pageCount - 1;
|
||||
nextPageButton.setDisable(disableNext);
|
||||
lastPageButton.setDisable(disableNext);
|
||||
|
||||
listView.scrollTo(0);
|
||||
};
|
||||
|
||||
FXUtils.onChange(control.pageCount, pageCountN -> {
|
||||
int pageCount = pageCountN.intValue();
|
||||
|
||||
if (pageCount != -1) {
|
||||
if (control.pageOffset.get() + 1 >= pageCount) {
|
||||
control.pageOffset.set(pageCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
changeButton.value.run();
|
||||
});
|
||||
|
||||
FXUtils.onChange(control.pageOffset, pageOffsetN -> {
|
||||
changeButton.value.run();
|
||||
});
|
||||
|
||||
Pane placeholder = new Pane();
|
||||
HBox.setHgrow(placeholder, Priority.SOMETIMES);
|
||||
|
||||
JFXButton searchButton = FXUtils.newRaisedButton(i18n("search"));
|
||||
searchButton.setOnAction(searchAction);
|
||||
|
||||
actions.appendList(FXCollections.observableArrayList(firstPageButton, previousPageButton, pageDescription, nextPageButton, lastPageButton, placeholder, searchButton));
|
||||
actions.appendList(control.actions);
|
||||
Bindings.bindContent(actionsBox.getChildren(), actions.getAggregatedList());
|
||||
}
|
||||
|
||||
searchPane.addRow(rowIndex++, actionsBox);
|
||||
|
||||
FXUtils.onChange(control.downloadSource, v -> searchAction.handle(null));
|
||||
nameField.setOnAction(searchAction);
|
||||
gameVersionField.setOnAction(searchAction);
|
||||
categoryComboBox.setOnAction(searchAction);
|
||||
sortComboBox.setOnAction(searchAction);
|
||||
}
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
pane.setCenter(spinnerPane);
|
||||
{
|
||||
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
|
||||
spinnerPane.failedReasonProperty().bind(
|
||||
Bindings.createStringBinding(() -> {
|
||||
if (getSkinnable().isFailed()) {
|
||||
return i18n("download.failed.refresh");
|
||||
} else if (!getSkinnable().isLoading() && getSkinnable().pageCount.get() >= 0 && getSkinnable().items.isEmpty()) {
|
||||
return i18n("search.no_results_found");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSkinnable().failedProperty(),
|
||||
getSkinnable().loadingProperty(),
|
||||
getSkinnable().pageCount,
|
||||
getSkinnable().items)
|
||||
);
|
||||
spinnerPane.setOnFailedAction(e -> {
|
||||
if (getSkinnable().isFailed() && getSkinnable().retrySearch != null) {
|
||||
getSkinnable().retrySearch.run();
|
||||
}
|
||||
});
|
||||
|
||||
spinnerPane.setContent(listView);
|
||||
Bindings.bindContent(listView.getItems(), getSkinnable().items);
|
||||
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
// 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.setCellFactory(x -> new DownloadListCell(getSkinnable(), listView, iconLoader));
|
||||
}
|
||||
|
||||
this.getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
private record CategoryIndented(int indent, RemoteAddonRepository.Category category) {
|
||||
private static final CategoryIndented ALL = new CategoryIndented(0, null);
|
||||
}
|
||||
|
||||
private static void resolveCategory(RemoteAddonRepository.Category category, int indent, List<CategoryIndented> result) {
|
||||
result.add(new CategoryIndented(indent, category));
|
||||
for (RemoteAddonRepository.Category subcategory : category.subcategories()) {
|
||||
resolveCategory(subcategory, indent + 1, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DownloadListCell extends ListCell<RemoteAddon> {
|
||||
private final DownloadListPage page;
|
||||
private final JFXListView<RemoteAddon> listView;
|
||||
private final RemoteImageLoader iconLoader;
|
||||
|
||||
private static final Insets PADDING = new Insets(9, 9, 0, 9);
|
||||
private static final Insets LAST_PADDING = new Insets(9, 9, 9, 9);
|
||||
|
||||
private final RipplerContainer graphic;
|
||||
private final StackPane wrapper = new StackPane();
|
||||
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final ImageContainer imageContainer = new ImageContainer(40);
|
||||
|
||||
/// Creates a download card with its icon and text laid out by the same row.
|
||||
private DownloadListCell(DownloadListPage page, JFXListView<RemoteAddon> listView, RemoteImageLoader iconLoader) {
|
||||
this.page = page;
|
||||
this.listView = listView;
|
||||
this.iconLoader = iconLoader;
|
||||
|
||||
setPadding(PADDING);
|
||||
|
||||
content.setPadding(new Insets(8));
|
||||
content.setCursor(Cursor.HAND);
|
||||
TwoLineListItem.setMargin(imageContainer, new Insets(0, 8, 0, 0));
|
||||
imageContainer.setMouseTransparent(true);
|
||||
content.setLeading(imageContainer);
|
||||
|
||||
this.graphic = new RipplerContainer(content);
|
||||
wrapper.getChildren().setAll(this.graphic);
|
||||
wrapper.getStyleClass().add("card-no-padding");
|
||||
|
||||
FXUtils.onClicked(wrapper, () -> {
|
||||
RemoteAddon item = getItem();
|
||||
if (item != null) {
|
||||
Controllers.navigate(new DownloadPage(page, item, page.getInstanceOptional(), page.callback));
|
||||
}
|
||||
});
|
||||
|
||||
setPrefWidth(0);
|
||||
|
||||
FXUtils.limitCellWidth(listView, this);
|
||||
}
|
||||
|
||||
/// Updates addon text and replaces category tags together so their labels can be reused.
|
||||
@Override
|
||||
protected void updateItem(@Nullable RemoteAddon item, boolean empty) {
|
||||
@Nullable RemoteAddon oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.resetFeedback();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setPadding(
|
||||
getIndex() == listView.getItems().size() - 1
|
||||
? LAST_PADDING
|
||||
: PADDING
|
||||
);
|
||||
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(page.type);
|
||||
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(item.slug()) : null;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : item.title());
|
||||
@Nullable String description = item.description();
|
||||
if (description != null) {
|
||||
description = description.replaceAll("\\R", " ");
|
||||
}
|
||||
content.setSubtitle(description);
|
||||
content.getTags().setAll(item.categories().stream()
|
||||
.filter(page::shouldDisplayCategory)
|
||||
.map(category -> new Tag(page.getLocalizedCategory(category, null)))
|
||||
.toList());
|
||||
iconLoader.load(imageContainer.imageProperty(), item.iconUrl());
|
||||
setGraphic(wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 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 javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
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.SVG;
|
||||
import org.jackhuang.hmcl.ui.SVGContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty failed = new SimpleBooleanProperty(false);
|
||||
private final RemoteAddonRepository repository;
|
||||
private final RemoteAddon addon;
|
||||
/// Localized project metadata, or null when no registry or matching project is available.
|
||||
private final @Nullable AddonProjectInfo mod;
|
||||
private final HMCLGameInstance.Optional instanceReference;
|
||||
private final DownloadCallback callback;
|
||||
private final DownloadListPage page;
|
||||
private final RemoteAddon.Type type;
|
||||
|
||||
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
|
||||
|
||||
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) {
|
||||
this.page = page;
|
||||
this.repository = page.repository;
|
||||
this.addon = addon;
|
||||
this.type = Objects.requireNonNullElse(addon.type(), page.type);
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(this.type);
|
||||
this.mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
|
||||
this.instanceReference = instanceReference;
|
||||
this.callback = callback;
|
||||
loadAddonVersions();
|
||||
|
||||
this.state.set(State.fromTitle(addon.title()));
|
||||
}
|
||||
|
||||
private void loadAddonVersions() {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
|
||||
Task.supplyAsync(() -> {
|
||||
Stream<RemoteAddon.Version> versions = addon.data().loadVersions(repository, page.getDownloadProvider());
|
||||
return sortVersions(versions);
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
this.versions = result;
|
||||
|
||||
loaded.set(true);
|
||||
setFailed(false);
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> sortVersions(Stream<RemoteAddon.Version> versions) {
|
||||
SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> classifiedVersions
|
||||
= new SimpleMultimap<>(HashMap::new, ArrayList::new);
|
||||
versions.forEach(version -> {
|
||||
for (String gameVersion : version.gameVersions()) {
|
||||
classifiedVersions.put(gameVersion, version);
|
||||
}
|
||||
});
|
||||
|
||||
for (String gameVersion : classifiedVersions.keys()) {
|
||||
List<RemoteAddon.Version> versionList = classifiedVersions.get(gameVersion);
|
||||
versionList.sort(Comparator.comparing(RemoteAddon.Version::datePublished).reversed());
|
||||
}
|
||||
return classifiedVersions;
|
||||
}
|
||||
|
||||
public RemoteAddon getAddon() {
|
||||
return addon;
|
||||
}
|
||||
|
||||
public HMCLGameInstance.Optional getInstanceOptional() {
|
||||
return instanceReference;
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return loading.get();
|
||||
}
|
||||
|
||||
public BooleanProperty loadingProperty() {
|
||||
return loading;
|
||||
}
|
||||
|
||||
public void setLoading(boolean loading) {
|
||||
this.loading.set(loading);
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
return failed.get();
|
||||
}
|
||||
|
||||
public BooleanProperty failedProperty() {
|
||||
return failed;
|
||||
}
|
||||
|
||||
public void setFailed(boolean failed) {
|
||||
this.failed.set(failed);
|
||||
}
|
||||
|
||||
public void download(RemoteAddon addon, RemoteAddon.Version file) {
|
||||
if (this.callback == null) {
|
||||
saveAs(file);
|
||||
} else {
|
||||
this.callback.download(page.getDownloadProvider(), instanceReference.repository(), instanceReference.instanceId(), addon, file);
|
||||
}
|
||||
}
|
||||
|
||||
public void saveAs(RemoteAddon.Version file) {
|
||||
String extension = StringUtils.substringAfterLast(file.file().filename(), '.');
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("button.save_as"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*." + extension));
|
||||
fileChooser.setInitialFileName(file.file().filename());
|
||||
Path dest = Controllers.showSaveDialog(fileChooser);
|
||||
if (dest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Controllers.taskDialog(
|
||||
Task.composeAsync(() -> {
|
||||
var task = new FileDownloadTask(file.file().url(), dest, file.file().getIntegrityCheck());
|
||||
task.setName(file.name());
|
||||
return task;
|
||||
}),
|
||||
i18n("message.downloading"),
|
||||
TaskCancellationAction.NORMAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new DownloadPageSkin(this);
|
||||
}
|
||||
|
||||
private static class DownloadPageSkin extends SkinBase<DownloadPage> {
|
||||
|
||||
protected DownloadPageSkin(DownloadPage control) {
|
||||
super(control);
|
||||
|
||||
VBox pane = new VBox(8);
|
||||
pane.getStyleClass().add("gray-background");
|
||||
pane.setPadding(new Insets(10));
|
||||
|
||||
HBox descriptionPane = new HBox(8);
|
||||
descriptionPane.setMinHeight(Region.USE_PREF_SIZE);
|
||||
descriptionPane.setAlignment(Pos.CENTER);
|
||||
pane.getChildren().add(descriptionPane);
|
||||
descriptionPane.getStyleClass().add("card-non-transparent");
|
||||
{
|
||||
var imageContainer = new ImageContainer(40);
|
||||
if (StringUtils.isNotBlank(getSkinnable().addon.iconUrl())) {
|
||||
imageContainer.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.iconUrl(), 80, 80, true, true));
|
||||
}
|
||||
descriptionPane.getChildren().add(imageContainer);
|
||||
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
@Nullable AddonProjectInfo mod = getSkinnable().mod;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : getSkinnable().addon.title());
|
||||
content.setSubtitle(getSkinnable().addon.description());
|
||||
content.getSubtitleLabel().setWrapText(true);
|
||||
getSkinnable().addon.categories().stream()
|
||||
.filter(category -> getSkinnable().page.shouldDisplayCategory(category))
|
||||
.map(category -> getSkinnable().page.getLocalizedCategory(category, null))
|
||||
.forEach(content::addTag);
|
||||
descriptionPane.getChildren().add(content);
|
||||
|
||||
@Nullable String mcmodUrl = mod != null ? mod.mcmodUrl() : null;
|
||||
if (mcmodUrl != null) {
|
||||
JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod"));
|
||||
openMcmodButton.setExternalLink(mcmodUrl);
|
||||
descriptionPane.getChildren().add(openMcmodButton);
|
||||
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||
}
|
||||
|
||||
JFXHyperlink openUrlButton = new JFXHyperlink(control.page.getLocalizedOfficialPage());
|
||||
openUrlButton.setExternalLink(getSkinnable().addon.pageUrl());
|
||||
descriptionPane.getChildren().add(openUrlButton);
|
||||
openUrlButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||
}
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
VBox.setVgrow(spinnerPane, Priority.ALWAYS);
|
||||
pane.getChildren().add(spinnerPane);
|
||||
{
|
||||
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
|
||||
spinnerPane.failedReasonProperty().bind(Bindings.createStringBinding(() -> {
|
||||
if (getSkinnable().isFailed()) {
|
||||
return i18n("download.failed.refresh");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, getSkinnable().failedProperty()));
|
||||
spinnerPane.setOnFailedAction(e -> getSkinnable().loadAddonVersions());
|
||||
|
||||
ComponentList list = new ComponentList();
|
||||
ScrollPane scrollPane = new ScrollPane(list);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
StackPane.setAlignment(scrollPane, Pos.TOP_CENTER);
|
||||
spinnerPane.setContent(scrollPane);
|
||||
|
||||
FXUtils.onChangeAndOperate(control.loaded, loaded -> {
|
||||
if (control.versions == null) return;
|
||||
|
||||
@Nullable HMCLGameInstance instance = control.instanceReference.instance();
|
||||
if (instance != null) {
|
||||
String gameVersion = instance.getVersion().toString();
|
||||
if (!GameVersionNumber.unknown().equals(instance.getVersion())
|
||||
&& control.versions.containsKey(gameVersion)) {
|
||||
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
|
||||
if (addonVersions != null && !addonVersions.isEmpty()) {
|
||||
Set<ModLoaderType> targetLoaders = instance.getModLoaders();
|
||||
|
||||
resolve:
|
||||
for (RemoteAddon.Version addonVersion : addonVersions) {
|
||||
if (getSkinnable().type == RemoteAddon.Type.MOD) {
|
||||
for (AddonLoader loader : addonVersion.loaders()) {
|
||||
if (loader.type() instanceof ModLoaderType modLoaderType && targetLoaders.contains(modLoaderType)) {
|
||||
list.getContent().addAll(
|
||||
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
|
||||
new AddonItem(control.addon, addonVersion, control)
|
||||
);
|
||||
break resolve;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
list.getContent().addAll(
|
||||
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
|
||||
new AddonItem(control.addon, addonVersion, control)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (getSkinnable().type == RemoteAddon.Type.MODPACK) {
|
||||
control.versions
|
||||
.keys()
|
||||
.stream()
|
||||
.max(Comparator.comparing(GameVersionNumber::asGameVersion))
|
||||
.flatMap(gameVersion ->
|
||||
control.versions.get(gameVersion)
|
||||
.stream()
|
||||
.max(Comparator.comparing(RemoteAddon.Version::datePublished))
|
||||
.map(v -> Pair.pair(gameVersion, v))
|
||||
).ifPresent(pair ->
|
||||
list.getContent().addAll(
|
||||
ComponentList.createComponentListTitle(i18n("addon.download.recommend", pair.key())),
|
||||
new AddonItem(control.addon, pair.value(), control)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
final class Versions {
|
||||
static ComponentSublist createSublist(DownloadPage control, String title, List<String> target) {
|
||||
var sublist = new ComponentSublist(() -> {
|
||||
ArrayList<AddonItem> items = new ArrayList<>();
|
||||
for (String gv : target) {
|
||||
List<RemoteAddon.Version> versions = control.versions.get(gv);
|
||||
if (versions != null) {
|
||||
for (RemoteAddon.Version v : versions) {
|
||||
items.add(new AddonItem(control.addon, v, control));
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
sublist.setTitle(title);
|
||||
sublist.getStyleClass().add("no-padding");
|
||||
|
||||
return sublist;
|
||||
}
|
||||
|
||||
final List<String> releases = new ArrayList<>();
|
||||
final List<String> snapshots = new ArrayList<>();
|
||||
}
|
||||
|
||||
TreeMap<GameVersionNumber, Versions> map = new TreeMap<>(Collections.reverseOrder());
|
||||
for (String version : control.versions.keys()) {
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(version);
|
||||
GameVersionNumber releaseOfSnapshot = GameVersionNumber.getReleaseOfSnapshot(gameVersion);
|
||||
GameVersionNumber releaseVersion = Objects.requireNonNullElse(releaseOfSnapshot, gameVersion);
|
||||
|
||||
Versions lists = map.computeIfAbsent(releaseVersion, v -> new Versions());
|
||||
|
||||
if (releaseOfSnapshot == null) {
|
||||
lists.releases.add(version);
|
||||
} else {
|
||||
lists.snapshots.add(version);
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<GameVersionNumber, Versions> entry : map.entrySet()) {
|
||||
GameVersionNumber gameVersion = entry.getKey();
|
||||
Versions versions = entry.getValue();
|
||||
|
||||
if (!versions.releases.isEmpty())
|
||||
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.release", gameVersion), versions.releases));
|
||||
|
||||
if (!versions.snapshots.isEmpty())
|
||||
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.snapshot", gameVersion), versions.snapshots));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays an addon dependency and opens its download page when activated.
|
||||
private static final class DependencyAddonItem extends LineButton {
|
||||
/// Returns the localized dependency type, or its lowercase enum name for other types.
|
||||
public static String translateDependencyType(RemoteAddon.DependencyType dependencyType) {
|
||||
return switch (dependencyType) {
|
||||
case EMBEDDED -> i18n("addon.dependency.embedded");
|
||||
case OPTIONAL -> i18n("addon.dependency.optional");
|
||||
case REQUIRED -> i18n("addon.dependency.required");
|
||||
default -> dependencyType.name().toLowerCase(Locale.ROOT);
|
||||
};
|
||||
}
|
||||
|
||||
/// The dependency represented by this row.
|
||||
public final RemoteAddon addon;
|
||||
|
||||
/// Creates a dependency row that opens the addon's download page.
|
||||
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) {
|
||||
this.addon = addon;
|
||||
|
||||
container.setPadding(new Insets(10, 24, 10, 24));
|
||||
container.getTitleLabel().setMinWidth(0);
|
||||
setLargeTitle(true);
|
||||
var imageView = new ImageContainer(40);
|
||||
imageView.setMouseTransparent(true);
|
||||
setMargin(imageView, new Insets(0, 8, 0, 0));
|
||||
setLeading(imageView);
|
||||
FXUtils.setLimitHeight(this, 60);
|
||||
|
||||
RemoteAddon.@Nullable Type type = addon.type();
|
||||
@Nullable DownloadCallback callback = type == null ? null : switch (type) {
|
||||
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
|
||||
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
|
||||
default -> null; // Mods, modpacks, worlds, and custom types are not downloadable from this UI
|
||||
};
|
||||
setOnAction((e) -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
Controllers.navigate(new DownloadPage(page, addon, instanceReference, callback));
|
||||
});
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
|
||||
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
|
||||
setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : addon.title());
|
||||
setSubtitle(addon.description());
|
||||
container.getSubtitleLabel().setWrapText(false);
|
||||
for (String category : addon.categories()) {
|
||||
if (page.shouldDisplayCategory(category))
|
||||
getTags().add(new Tag(page.getLocalizedCategory(category, null), Tag.Type.NORMAL));
|
||||
}
|
||||
if (StringUtils.isNotBlank(addon.iconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonItem extends StackPane {
|
||||
|
||||
/// Creates a version summary that opens its details when clicked.
|
||||
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage) {
|
||||
this(mod, dataItem, selfPage, true);
|
||||
}
|
||||
|
||||
/// Creates a version summary, optionally enabling navigation to its details.
|
||||
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage, boolean interactive) {
|
||||
VBox pane = new VBox(8);
|
||||
pane.setPadding(new Insets(8, 0, 8, 0));
|
||||
|
||||
{
|
||||
HBox descPane = new HBox(8);
|
||||
descPane.setPadding(new Insets(0, 8, 0, 8));
|
||||
descPane.setAlignment(Pos.CENTER_LEFT);
|
||||
descPane.setPickOnBounds(false);
|
||||
|
||||
{
|
||||
StackPane graphicPane = new StackPane();
|
||||
graphicPane.setMouseTransparent(true);
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setTitle(dataItem.name());
|
||||
content.setSubtitle(I18n.formatDateTime(dataItem.datePublished()));
|
||||
|
||||
@Nullable SVG svg = null;
|
||||
@Nullable String styleClass = null;
|
||||
if (dataItem.versionType() != null) {
|
||||
switch (dataItem.versionType()) {
|
||||
case Alpha:
|
||||
content.addTag(i18n("addon.channel.alpha"));
|
||||
svg = SVG.ALPHA_CIRCLE;
|
||||
styleClass = "alpha";
|
||||
break;
|
||||
case Beta:
|
||||
content.addTag(i18n("addon.channel.beta"));
|
||||
svg = SVG.BETA_CIRCLE;
|
||||
styleClass = "beta";
|
||||
break;
|
||||
case Release:
|
||||
content.addTag(i18n("addon.channel.release"));
|
||||
svg = SVG.RELEASE_CIRCLE;
|
||||
styleClass = "release";
|
||||
break;
|
||||
default:
|
||||
styleClass = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (svg != null) {
|
||||
SVGContainer icon = svg.createIcon(24);
|
||||
icon.getStyleClass().addAll("addon-channel-icon", styleClass);
|
||||
graphicPane.getChildren().setAll(icon);
|
||||
}
|
||||
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
for (AddonLoader loader : dataItem.loaders()) {
|
||||
tags.add(I18n.translateLoaderName(loader));
|
||||
}
|
||||
content.addTags(tags);
|
||||
|
||||
descPane.getChildren().setAll(graphicPane, content);
|
||||
}
|
||||
|
||||
pane.getChildren().add(descPane);
|
||||
}
|
||||
|
||||
RipplerContainer container = new RipplerContainer(pane);
|
||||
container.setPressFeedbackEnabled(interactive);
|
||||
if (interactive)
|
||||
FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage)));
|
||||
getChildren().setAll(container);
|
||||
|
||||
// Workaround for https://github.com/HMCL-dev/HMCL/issues/2129
|
||||
this.setMinHeight(50);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonVersion extends JFXDialogLayout {
|
||||
/// Creates version details with dependency downloads and a non-navigating summary.
|
||||
public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) {
|
||||
RemoteAddon.Type type = selfPage.type;
|
||||
|
||||
String title = switch (type) {
|
||||
case RESOURCE_PACK -> "resourcepack.download.title";
|
||||
case SHADER_PACK -> "shaderpack.download.title";
|
||||
default -> "mods.download.title";
|
||||
};
|
||||
this.setHeading(new HBox(new Label(i18n(title, version.name()))));
|
||||
|
||||
VBox box = new VBox(8);
|
||||
box.setPadding(new Insets(8));
|
||||
var addonItem = new AddonItem(mod, version, selfPage, false);
|
||||
box.getChildren().setAll(addonItem);
|
||||
|
||||
JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog"));
|
||||
changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider())));
|
||||
|
||||
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
|
||||
versionPageBtn.setDisable(true);
|
||||
loadVersionPageUrl(version, selfPage.repository, versionPageBtn);
|
||||
|
||||
HBox additionalBox = new HBox(changelogButton, versionPageBtn);
|
||||
box.getChildren().add(additionalBox);
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
ComponentList dependenciesList = new ComponentList();
|
||||
loadDependencies(version, selfPage, spinnerPane, dependenciesList);
|
||||
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));
|
||||
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
scrollPane.setContent(dependenciesList);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
FXUtils.onChangeAndOperate(scrollPane.widthProperty(), d -> FXUtils.setLimitWidth(dependenciesList, d.doubleValue()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
spinnerPane.setContent(scrollPane);
|
||||
box.getChildren().add(spinnerPane);
|
||||
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
|
||||
|
||||
this.setBody(box);
|
||||
|
||||
JFXButton downloadButton = null;
|
||||
if (selfPage.callback != null) {
|
||||
downloadButton = new JFXButton(i18n("mods.install"));
|
||||
downloadButton.getStyleClass().add("dialog-accept");
|
||||
downloadButton.setOnAction(e -> {
|
||||
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
}
|
||||
selfPage.download(mod, version);
|
||||
});
|
||||
}
|
||||
|
||||
JFXButton saveAsButton = new JFXButton(i18n("mods.save_as"));
|
||||
saveAsButton.getStyleClass().add("dialog-accept");
|
||||
saveAsButton.setOnAction(e -> {
|
||||
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
}
|
||||
selfPage.saveAs(version);
|
||||
});
|
||||
|
||||
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("dialog-cancel");
|
||||
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
if (downloadButton == null) {
|
||||
this.setActions(saveAsButton, cancelButton);
|
||||
} else {
|
||||
this.setActions(downloadButton, saveAsButton, cancelButton);
|
||||
}
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
|
||||
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
}
|
||||
|
||||
private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) {
|
||||
spinnerPane.setLoading(true);
|
||||
Task.composeAsync(() -> {
|
||||
// TODO: Massive tasks may cause OOM.
|
||||
EnumMap<RemoteAddon.DependencyType, Pair<Label, List<DependencyAddonItem>>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class);
|
||||
AtomicBoolean hasBroken = new AtomicBoolean(false);
|
||||
List<Task<?>> queue = new ArrayList<>(version.dependencies().size());
|
||||
for (RemoteAddon.Dependency dependency : version.dependencies()) {
|
||||
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE) continue;
|
||||
if (dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
|
||||
hasBroken.set(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dependencies.containsKey(dependency.getType())) {
|
||||
Label title = new Label(DependencyAddonItem.translateDependencyType(dependency.getType()));
|
||||
title.setPadding(new Insets(0, 8, 0, 8));
|
||||
List<DependencyAddonItem> list = new ArrayList<>();
|
||||
dependencies.put(dependency.getType(), Pair.pair(title, list));
|
||||
}
|
||||
|
||||
queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider()))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenAcceptAsync(Schedulers.javafx(), dep -> {
|
||||
if (dep == RemoteAddon.BROKEN) {
|
||||
hasBroken.set(true);
|
||||
return;
|
||||
}
|
||||
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference);
|
||||
var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue()));
|
||||
dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener);
|
||||
dependencies.get(dependency.getType()).value().add(dependencyAddonItem);
|
||||
})
|
||||
.setSignificance(Task.TaskSignificance.MINOR));
|
||||
}
|
||||
|
||||
return Task.allOf(queue).thenSupplyAsync(() -> {
|
||||
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
|
||||
if (types.value().isEmpty()) return Stream.empty();
|
||||
return Stream.concat(
|
||||
Stream.of(types.key()),
|
||||
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
|
||||
});
|
||||
if (!hasBroken.get()) {
|
||||
return dependenciesStream;
|
||||
} else {
|
||||
Label warning = new Label(i18n("addon.dependencies.has_broken"));
|
||||
warning.setWrapText(true);
|
||||
warning.setPadding(new Insets(0, 8, 0, 8));
|
||||
return Stream.concat(
|
||||
Stream.of(warning),
|
||||
dependenciesStream
|
||||
);
|
||||
}
|
||||
});
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
dependenciesList.getContent().setAll(result.toList());
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
dependenciesList.getContent().setAll();
|
||||
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
|
||||
}
|
||||
spinnerPane.setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository repo, JFXHyperlink button) {
|
||||
Task.supplyAsync(() -> repo.getVersionPageUrl(version))
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null && StringUtils.isNotBlank(result)) {
|
||||
button.setExternalLink(result);
|
||||
button.setDisable(false);
|
||||
} else {
|
||||
LOG.warning("Failed to load addon version page url", exception);
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonChangelog extends JFXDialogLayout {
|
||||
|
||||
public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) {
|
||||
setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name())));
|
||||
|
||||
VBox box = new VBox(8);
|
||||
box.setPadding(new Insets(8));
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
|
||||
loadChangelog(version, repo, provider, spinnerPane, scrollPane);
|
||||
spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, spinnerPane, scrollPane));
|
||||
|
||||
spinnerPane.setContent(scrollPane);
|
||||
box.getChildren().add(spinnerPane);
|
||||
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
|
||||
|
||||
this.setBody(box);
|
||||
|
||||
JFXButton closeButton = new JFXButton(i18n("button.ok"));
|
||||
closeButton.getStyleClass().add("dialog-accept");
|
||||
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
setActions(closeButton);
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
|
||||
|
||||
onEscPressed(this, closeButton::fire);
|
||||
}
|
||||
|
||||
private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) {
|
||||
spinnerPane.setLoading(true);
|
||||
Task.supplyAsync(() ->
|
||||
StringUtils.convertToHtml(
|
||||
repo.getAddonChangelog(provider, version.projectId(), version.versionId()),
|
||||
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 FUCK YOU JEI, FUCK YOU CURSEFORGE
|
||||
)
|
||||
).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
|
||||
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
|
||||
}
|
||||
spinnerPane.setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface DownloadCallback {
|
||||
void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon addon, RemoteAddon.Version file);
|
||||
}
|
||||
}
|
||||
@@ -41,14 +41,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
private final TabController tabController;
|
||||
/// The settings tab bound to the current game instance.
|
||||
private final TabItem<GameSettingsPage<GameSettings.Instance>> gameSettingsTab;
|
||||
/// The component management tab bound to the current game instance.
|
||||
private final TabItem<InstallerListPage> installerListTab;
|
||||
/// The installed mods tab bound to the current game instance.
|
||||
private final TabItem<ModListPage> modListTab;
|
||||
/// The saved worlds tab bound to the current game instance.
|
||||
private final TabItem<WorldListPage> worldListTab;
|
||||
/// 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.
|
||||
private final TabItem<ScreenshotsPage> screenshotsTab;
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
@@ -79,18 +71,10 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
public GameInstancePage() {
|
||||
// Child tabs subscribe to instanceProperty() themselves and reload on change.
|
||||
this.gameSettingsTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance));
|
||||
this.installerListTab = new TabItem<>(() -> new InstallerListPage(instance));
|
||||
this.modListTab = new TabItem<>(() -> new ModListPage(instance));
|
||||
this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance));
|
||||
this.worldListTab = new TabItem<>(() -> new WorldListPage(instance));
|
||||
this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance));
|
||||
|
||||
tabController = new TabController(transitionPane,
|
||||
gameSettingsTab,
|
||||
installerListTab,
|
||||
modListTab,
|
||||
resourcePackTab,
|
||||
worldListTab,
|
||||
screenshotsTab);
|
||||
tabController.selectTab(gameSettingsTab);
|
||||
|
||||
@@ -118,11 +102,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
}));
|
||||
|
||||
getSidebar()
|
||||
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL)
|
||||
.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, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL);
|
||||
|
||||
PopupMenu browseList = new PopupMenu();
|
||||
JFXPopup browsePopup = new JFXPopup(browseList);
|
||||
|
||||
@@ -1,132 +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.instances;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class GameItem {
|
||||
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
|
||||
|
||||
protected final HMCLGameInstance gameInstance;
|
||||
|
||||
private boolean initialized = false;
|
||||
private StringProperty title;
|
||||
private StringProperty tag;
|
||||
private StringProperty subtitle;
|
||||
private ObjectProperty<Image> image;
|
||||
|
||||
public GameItem(HMCLGameInstance gameInstance) {
|
||||
this.gameInstance = gameInstance;
|
||||
}
|
||||
|
||||
public GameDirectory getGameDirectory() {
|
||||
return gameInstance.getRepository().getGameDirectory();
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return gameInstance.getRepository();
|
||||
}
|
||||
|
||||
public GameInstanceID getInstanceId() {
|
||||
return gameInstance.getId();
|
||||
}
|
||||
|
||||
public HMCLGameInstance getGameInstance() {
|
||||
return gameInstance;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return gameInstance.getId().toString();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
if (initialized)
|
||||
return;
|
||||
|
||||
initialized = true;
|
||||
title = new SimpleStringProperty();
|
||||
tag = new SimpleStringProperty();
|
||||
subtitle = new SimpleStringProperty();
|
||||
image = new SimpleObjectProperty<>();
|
||||
|
||||
record Result(@Nullable String gameVersion, @Nullable String tag) {
|
||||
}
|
||||
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
// Version detection performs blocking JAR reads and may hash the entire file.
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
@Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString();
|
||||
@Nullable String modPackVersion = null;
|
||||
try {
|
||||
@Nullable ModpackConfiguration<?> config = gameInstance.readModpackConfiguration();
|
||||
modPackVersion = config != null ? config.getVersion() : null;
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read modpack configuration from " + getId(), e);
|
||||
}
|
||||
return new Result(gameVersion, modPackVersion);
|
||||
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
|
||||
if (exception == null) {
|
||||
if (result.tag != null) {
|
||||
tag.set(result.tag);
|
||||
}
|
||||
|
||||
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
|
||||
GameComponentAnalyzer analyzer = gameInstance.getAnalyzer();
|
||||
for (GameComponentAnalyzer.Mark mark : analyzer) {
|
||||
if (mark.componentType() == GameComponentType.GAME) continue;
|
||||
|
||||
if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) {
|
||||
libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId()));
|
||||
if (mark.version() != null)
|
||||
libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.set(libraries.toString());
|
||||
} else {
|
||||
LOG.warning("Failed to read version info from " + getId(), exception);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
|
||||
title.set(getId());
|
||||
image.set(gameInstance.getIconImage());
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty titleProperty() {
|
||||
init();
|
||||
return title;
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty tagProperty() {
|
||||
init();
|
||||
return tag;
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty subtitleProperty() {
|
||||
init();
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
public ReadOnlyObjectProperty<Image> imageProperty() {
|
||||
init();
|
||||
return image;
|
||||
}
|
||||
}
|
||||
@@ -1,191 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class GameListCell extends ListCell<GameListItem> {
|
||||
|
||||
private final RipplerContainer graphic;
|
||||
|
||||
private final ImageContainer imageView;
|
||||
private final TwoLineListItem content;
|
||||
|
||||
private final JFXRadioButton chkSelected;
|
||||
private final JFXButton btnUpgrade;
|
||||
/// Opens the settings for this cell's game instance.
|
||||
private final JFXButton btnEditInstance;
|
||||
private final JFXButton btnManage;
|
||||
|
||||
private final HBox right;
|
||||
|
||||
private final StringProperty tag = new SimpleStringProperty();
|
||||
|
||||
/// Creates a game instance cell constrained to the supplied list's viewport width.
|
||||
public GameListCell(ListView<GameListItem> listView) {
|
||||
FXUtils.limitCellWidth(listView, this);
|
||||
|
||||
TwoLineListItem root = this.content = new TwoLineListItem();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8, 8, 8, 0));
|
||||
|
||||
RipplerContainer container = new RipplerContainer(root);
|
||||
this.graphic = container;
|
||||
|
||||
{
|
||||
this.chkSelected = new JFXRadioButton() {
|
||||
/// Selects this cell's game instance when the radio button is enabled and unselected.
|
||||
@Override
|
||||
public void fire() {
|
||||
if (!isDisable() && !isSelected()) {
|
||||
fireEvent(new ActionEvent());
|
||||
@Nullable GameListItem item = GameListCell.this.getItem();
|
||||
if (item != null) {
|
||||
item.getRepository().setSelectedInstance(item.getGameInstance());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
this.imageView = new ImageContainer(32);
|
||||
imageView.setMouseTransparent(true);
|
||||
HBox leading = new HBox(8, chkSelected, imageView);
|
||||
leading.setAlignment(Pos.CENTER_LEFT);
|
||||
leading.setPickOnBounds(false);
|
||||
TwoLineListItem.setMargin(leading, new Insets(0, 8, 0, 0));
|
||||
content.setLeading(leading);
|
||||
|
||||
FXUtils.onChangeAndOperate(tag, tag -> {
|
||||
if (StringUtils.isNotBlank(tag))
|
||||
content.getTags().setAll(new Tag(tag));
|
||||
else
|
||||
content.getTags().clear();
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
this.right = new HBox();
|
||||
content.setTrailing(right);
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
right.setPickOnBounds(false);
|
||||
|
||||
this.btnUpgrade = FXUtils.newToggleButton4(SVG.UPDATE);
|
||||
btnUpgrade.setOnAction(e -> {
|
||||
@Nullable GameListItem item = this.getItem();
|
||||
if (item != null)
|
||||
item.update();
|
||||
});
|
||||
FXUtils.installFastTooltip(btnUpgrade, i18n("instance.update"));
|
||||
right.getChildren().add(btnUpgrade);
|
||||
|
||||
this.btnEditInstance = FXUtils.newToggleButton4(SVG.SETTINGS);
|
||||
btnEditInstance.setOnAction(e -> {
|
||||
@Nullable GameListItem item = this.getItem();
|
||||
if (item != null)
|
||||
item.modifyGameSettings();
|
||||
});
|
||||
FXUtils.installFastTooltip(btnEditInstance, i18n("instance.manage.manage"));
|
||||
right.getChildren().add(btnEditInstance);
|
||||
|
||||
this.btnManage = FXUtils.newToggleButton4(SVG.MORE_VERT);
|
||||
btnManage.setOnAction(e -> {
|
||||
@Nullable GameListItem item = this.getItem();
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
JFXPopup popup = getPopup(item);
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
|
||||
popup.show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
|
||||
});
|
||||
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
|
||||
right.getChildren().add(btnManage);
|
||||
}
|
||||
|
||||
root.setCursor(Cursor.HAND);
|
||||
container.setOnMouseClicked(e -> {
|
||||
@Nullable GameListItem item = getItem();
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
if (e.getButton() == MouseButton.PRIMARY) {
|
||||
if (e.getClickCount() == 1) {
|
||||
item.getRepository().setSelectedInstance(item.getGameInstance());
|
||||
}
|
||||
} else if (e.getButton() == MouseButton.SECONDARY) {
|
||||
JFXPopup popup = getPopup(item);
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
|
||||
popup.show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateItem(GameListItem item, boolean empty) {
|
||||
GameListItem oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.resetFeedback();
|
||||
|
||||
this.imageView.imageProperty().unbind();
|
||||
this.content.titleProperty().unbind();
|
||||
this.content.subtitleProperty().unbind();
|
||||
this.tag.unbind();
|
||||
this.right.getChildren().clear();
|
||||
this.chkSelected.selectedProperty().unbind();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(this.graphic);
|
||||
|
||||
this.chkSelected.selectedProperty().bind(item.selectedProperty());
|
||||
this.imageView.imageProperty().bind(item.imageProperty());
|
||||
this.content.titleProperty().bind(item.titleProperty());
|
||||
this.content.subtitleProperty().bind(item.subtitleProperty());
|
||||
this.tag.bind(item.tagProperty());
|
||||
if (item.canUpdate())
|
||||
this.right.getChildren().add(btnUpgrade);
|
||||
this.right.getChildren().addAll(btnEditInstance, btnManage);
|
||||
}
|
||||
}
|
||||
|
||||
private static JFXPopup getPopup(GameListItem item) {
|
||||
PopupMenu menu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(menu);
|
||||
|
||||
menu.getContent().setAll(
|
||||
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch.test"), item::testGame, popup),
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), item::generateLaunchScript, popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
|
||||
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
|
||||
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
|
||||
return popup;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +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.instances;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public class GameListItem extends GameItem {
|
||||
private final boolean isModpack;
|
||||
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
|
||||
|
||||
public GameListItem(HMCLGameInstance gameInstance) {
|
||||
super(gameInstance);
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
GameInstanceID instanceId = gameInstance.getId();
|
||||
this.isModpack = gameInstance.isModpack();
|
||||
selected.bind(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
|
||||
@Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance();
|
||||
return selectedInstance != null && selectedInstance.getId().equals(instanceId);
|
||||
},
|
||||
GameDirectoryManager.selectedGameDirectoryProperty(),
|
||||
GameDirectoryManager.selectedInstanceProperty()));
|
||||
}
|
||||
|
||||
public ReadOnlyBooleanProperty selectedProperty() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void rename() {
|
||||
Instances.renameInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void duplicate() {
|
||||
Instances.duplicateInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void remove() {
|
||||
Instances.deleteInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void browse() {
|
||||
Instances.openFolder(gameInstance);
|
||||
}
|
||||
|
||||
public void testGame() {
|
||||
Instances.testGame(gameInstance);
|
||||
}
|
||||
|
||||
public void launch() {
|
||||
Instances.launch(gameInstance);
|
||||
}
|
||||
|
||||
public void modifyGameSettings() {
|
||||
Instances.modifyGameSettings(gameInstance);
|
||||
}
|
||||
|
||||
public void generateLaunchScript() {
|
||||
Instances.generateLaunchScript(gameInstance);
|
||||
}
|
||||
|
||||
public boolean canUpdate() {
|
||||
return isModpack;
|
||||
}
|
||||
|
||||
public void update() {
|
||||
Instances.updateInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
@@ -1,248 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
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.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
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.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
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 java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
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")));
|
||||
|
||||
/// Creates the fixed-directory instance list.
|
||||
public GameListPage() {
|
||||
getSidebar()
|
||||
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
|
||||
|
||||
setContent(new GameList());
|
||||
|
||||
FXUtils.applyDragListener(this, file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getNameWithoutExtension(file)), files -> {
|
||||
Path file = files.get(0);
|
||||
|
||||
if (ModpackHelper.isFileModpackByExtension(file)) {
|
||||
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file), i18n("install.modpack"));
|
||||
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
|
||||
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void modifyGlobalGameSettings() {
|
||||
Instances.modifyGlobalSettings(GameDirectoryManager.getSelectedRepository());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
private static class GameList extends ListPageBase<GameListItem> {
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private final ObservableList<GameListItem> sourceList = FXCollections.observableArrayList();
|
||||
private final FilteredList<GameListItem> filteredList = new FilteredList<>(sourceList);
|
||||
|
||||
public GameList() {
|
||||
setItems(filteredList);
|
||||
|
||||
GameDirectoryManager.registerVersionsListener(this::loadVersions);
|
||||
|
||||
setOnFailedAction(e -> Instances.installServerModpack());
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private void loadVersions(HMCLGameRepository repository) {
|
||||
listenerHolder.clear();
|
||||
setLoading(true);
|
||||
setFailedReason(null);
|
||||
|
||||
List<GameListItem> instanceItems = repository.getDisplayInstances()
|
||||
.map(GameListItem::new)
|
||||
.toList();
|
||||
|
||||
sourceList.setAll(instanceItems);
|
||||
|
||||
if (instanceItems.isEmpty()) {
|
||||
setFailedReason(i18n("instance.empty.hint"));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
private Predicate<GameListItem> createPredicate(String searchText) {
|
||||
if (searchText == null || searchText.isEmpty()) {
|
||||
return item -> true;
|
||||
}
|
||||
|
||||
if (searchText.startsWith("regex:")) {
|
||||
String regex = searchText.substring("regex:".length());
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
return item -> pattern.matcher(item.getId()).find();
|
||||
} catch (PatternSyntaxException e) {
|
||||
return item -> false;
|
||||
}
|
||||
} else {
|
||||
return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshList() {
|
||||
GameDirectoryManager.getSelectedRepository().refreshAsync().start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new GameListSkin(this);
|
||||
}
|
||||
|
||||
private static class GameListSkin extends SkinBase<GameList> {
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
private final HBox toolbarNormal;
|
||||
|
||||
/// Whether the search mechanism is currently active.
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
|
||||
private final JFXTextField searchField;
|
||||
|
||||
/// Creates the instance list with search controls and viewport-constrained rows.
|
||||
public GameListSkin(GameList 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");
|
||||
JFXListView<GameListItem> listView = new JFXListView<>();
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
|
||||
searchBar = new HBox();
|
||||
toolbarNormal = new HBox();
|
||||
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 5));
|
||||
searchField = new JFXTextField();
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
PauseTransition pause = new PauseTransition(Duration.millis(100));
|
||||
pause.setOnFinished(e -> 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.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::installServerModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
|
||||
|
||||
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
|
||||
|
||||
FXUtils.setOverflowHidden(toolbarPane, 8);
|
||||
|
||||
root.getContent().add(toolbarPane);
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
center.failedReasonProperty().bind(skinnable.failedReasonProperty());
|
||||
|
||||
listView.setCellFactory(GameListCell::new);
|
||||
listView.setItems(skinnable.getItems());
|
||||
|
||||
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.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);
|
||||
}
|
||||
|
||||
pane.getChildren().setAll(root);
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.WindowEvent;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.ImageContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.Tag;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Displays game instances in a popup selection list.
|
||||
///
|
||||
/// @author Glavo
|
||||
public final class GameListPopupMenu extends StackPane {
|
||||
|
||||
private static final String KEY = GameListPopupMenu.class.getName() + ".popup";
|
||||
|
||||
public static boolean hideShowing(Node owner) {
|
||||
JFXPopup popup = (JFXPopup) owner.getProperties().get(KEY);
|
||||
if (popup != null && popup.isShowing()) {
|
||||
popup.hide();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an instance selection popup relative to its owner.
|
||||
///
|
||||
/// @param owner the node used to position the popup
|
||||
/// @param vAlign the popup's vertical alignment relative to `owner`
|
||||
/// @param hAlign the popup's horizontal alignment relative to `owner`
|
||||
/// @param initOffsetX the horizontal offset from the aligned position
|
||||
/// @param initOffsetY the vertical offset from the aligned position
|
||||
/// @param instances the instances to copy into the popup, in display order
|
||||
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
|
||||
double initOffsetX, double initOffsetY,
|
||||
List<HMCLGameInstance> instances) {
|
||||
if (!hideShowing(owner)) {
|
||||
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows and returns an instance selection popup relative to its owner.
|
||||
///
|
||||
/// @param owner the node used to position the popup
|
||||
/// @param vAlign the popup's vertical alignment relative to `owner`
|
||||
/// @param hAlign the popup's horizontal alignment relative to `owner`
|
||||
/// @param initOffsetX the horizontal offset from the aligned position
|
||||
/// @param initOffsetY the vertical offset from the aligned position
|
||||
/// @param instances the instances to copy into the popup, in display order
|
||||
/// @return the shown popup
|
||||
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
|
||||
double initOffsetX, double initOffsetY,
|
||||
List<HMCLGameInstance> instances) {
|
||||
GameListPopupMenu menu = new GameListPopupMenu();
|
||||
menu.getItems().setAll(instances.stream()
|
||||
.map(GameItem::new)
|
||||
.toList());
|
||||
JFXPopup popup = new JFXPopup(menu);
|
||||
owner.getProperties().put(KEY, popup);
|
||||
popup.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> owner.getProperties().remove(KEY, popup));
|
||||
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY, true);
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
private final JFXListView<GameItem> listView = new JFXListView<>();
|
||||
private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems());
|
||||
|
||||
public GameListPopupMenu() {
|
||||
this.setMaxHeight(365);
|
||||
this.getStyleClass().add("popup-menu-content");
|
||||
|
||||
listView.setCellFactory(Cell::new);
|
||||
|
||||
listView.setFixedCellSize(50);
|
||||
listView.setPrefWidth(300);
|
||||
|
||||
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
|
||||
|
||||
Label placeholder = new Label(i18n("instance.empty"));
|
||||
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
|
||||
|
||||
FXUtils.onChangeAndOperate(isEmpty, empty -> getChildren().setAll(empty ? placeholder : listView));
|
||||
}
|
||||
|
||||
public ObservableList<GameItem> getItems() {
|
||||
return listView.getItems();
|
||||
}
|
||||
|
||||
private static final class Cell extends ListCell<GameItem> {
|
||||
|
||||
private final Region graphic;
|
||||
private final RipplerContainer ripplerContainer;
|
||||
|
||||
private final ImageContainer imageView;
|
||||
private final TwoLineListItem content;
|
||||
|
||||
private final StringProperty tag = new SimpleStringProperty();
|
||||
|
||||
/// Creates a popup row with a leading icon and the shared advanced-item text styling.
|
||||
public Cell(ListView<GameItem> listView) {
|
||||
this.setPadding(Insets.EMPTY);
|
||||
|
||||
this.imageView = new ImageContainer(32);
|
||||
this.imageView.setMouseTransparent(true);
|
||||
|
||||
this.content = new TwoLineListItem();
|
||||
content.getStyleClass().add("container");
|
||||
content.setLeading(imageView);
|
||||
FXUtils.onChangeAndOperate(tag, tag -> {
|
||||
if (StringUtils.isNotBlank(tag))
|
||||
content.getTags().setAll(new Tag(tag));
|
||||
else
|
||||
content.getTags().clear();
|
||||
});
|
||||
|
||||
this.ripplerContainer = new RipplerContainer(content);
|
||||
|
||||
StackPane rootPane = new StackPane();
|
||||
rootPane.getStyleClass().add("advanced-list-item");
|
||||
rootPane.getChildren().setAll(ripplerContainer);
|
||||
rootPane.maxWidthProperty().bind(listView.widthProperty().subtract(5));
|
||||
|
||||
FXUtils.onClicked(rootPane, () -> {
|
||||
@Nullable GameItem item = getItem();
|
||||
if (item != null) {
|
||||
item.getRepository().setSelectedInstance(item.getGameInstance());
|
||||
if (getScene().getWindow() instanceof JFXPopup popup)
|
||||
popup.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.graphic = rootPane;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(GameItem item, boolean empty) {
|
||||
GameItem oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.ripplerContainer.resetFeedback();
|
||||
this.imageView.imageProperty().unbind();
|
||||
this.content.titleProperty().unbind();
|
||||
this.content.subtitleProperty().unbind();
|
||||
this.tag.unbind();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(this.graphic);
|
||||
|
||||
this.imageView.imageProperty().bind(item.imageProperty());
|
||||
this.content.titleProperty().bind(item.titleProperty());
|
||||
this.content.subtitleProperty().bind(item.subtitleProperty());
|
||||
this.tag.bind(item.tagProperty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
|
||||
import java.util.MissingResourceException;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
|
||||
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, true, true);
|
||||
page.supportChinese.set(false);
|
||||
return page;
|
||||
}
|
||||
|
||||
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, boolean curseForge, boolean modrinth) {
|
||||
super(type, new Repository(), callback, instanceSelection);
|
||||
((Repository) repository).downloadSource.bind(downloadSource);
|
||||
|
||||
supportChinese.set(true);
|
||||
|
||||
boolean supportedCurseForge = curseForge && CurseForgeRemoteAddonRepository.isAvailable();
|
||||
|
||||
downloadSources.setAll("addon.modrinth");
|
||||
if (supportedCurseForge) {
|
||||
downloadSources.add("addon.curseforge");
|
||||
}
|
||||
|
||||
if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) {
|
||||
if (supportedCurseForge) {
|
||||
downloadSource.set("addon.curseforge");
|
||||
} else if (modrinth) {
|
||||
downloadSource.set("addon.modrinth");
|
||||
} else {
|
||||
throw new AssertionError("Should not be here.");
|
||||
}
|
||||
} else {
|
||||
if (modrinth) {
|
||||
downloadSource.set("addon.modrinth");
|
||||
} else if (supportedCurseForge) {
|
||||
downloadSource.set("addon.curseforge");
|
||||
} else {
|
||||
throw new AssertionError("Should not be here.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class Repository extends LocalizedRemoteAddonRepository {
|
||||
|
||||
public Repository() {
|
||||
}
|
||||
|
||||
public final StringProperty downloadSource = new SimpleStringProperty();
|
||||
|
||||
@Override
|
||||
protected RemoteAddonRepository getBackedRemoteModRepository() {
|
||||
if ("addon.modrinth".equals(downloadSource.get())) {
|
||||
return ModrinthRemoteAddonRepository.getInstance();
|
||||
} else {
|
||||
return CurseForgeRemoteAddonRepository.getInstance();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getLocalizedCategory(String category, Object self) {
|
||||
if (category.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String key = ("addon.modrinth".equals(downloadSource.get()) ? "modrinth" : "curse") + ".category." + category;
|
||||
try {
|
||||
return I18n.getResourceBundle().getString(key);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.warning("Cannot find key " + key + " in resource bundle");
|
||||
if (self instanceof CurseForgeRemoteAddonRepository.Category curseCategory) {
|
||||
return curseCategory.getName();
|
||||
}
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getLocalizedOfficialPage() {
|
||||
if ("addon.modrinth".equals(downloadSource.get())) {
|
||||
return i18n("addon.modrinth");
|
||||
} else {
|
||||
return i18n("addon.curseforge");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +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.instances;
|
||||
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Skin;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class InstallerListPage extends ListPageBase<InstallerItem> {
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
|
||||
/// Creates an installer list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new InstallerListPageSkin();
|
||||
}
|
||||
|
||||
/// Rebuilds the component controls for the selected instance, or clears them if none is selected.
|
||||
/// Minecraft editing follows the stored manifest's modifiability; other installed components
|
||||
/// require a recognized patch to expose editing controls.
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
@Nullable HMCLGameInstance gameInstance = instance.instance();
|
||||
this.gameInstance = gameInstance;
|
||||
if (gameInstance == null) {
|
||||
itemsProperty().clear();
|
||||
return;
|
||||
}
|
||||
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
|
||||
itemsProperty().clear();
|
||||
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM);
|
||||
|
||||
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
|
||||
// Skip fabric-api and quilt-api and legacyfabric-api
|
||||
if (component.getComponentType().getPatchId().endsWith("-api")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType());
|
||||
|
||||
if (libraryVersion != null) {
|
||||
component.versionProperty().set(new InstallerItem.InstalledState(
|
||||
libraryVersion,
|
||||
component.getComponentType() == GameComponentType.GAME
|
||||
? !gameInstance.getManifest().isModifiable()
|
||||
: !gameInstance.getAnalyzer().isClear(component.getComponentType()),
|
||||
false
|
||||
));
|
||||
} else {
|
||||
component.versionProperty().set(null);
|
||||
}
|
||||
|
||||
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
|
||||
gameInstance.getId(),
|
||||
publishedInstance -> repository.getDependency().removeComponentAsync(
|
||||
publishedInstance,
|
||||
component.getComponentType()))
|
||||
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
|
||||
.start());
|
||||
|
||||
itemsProperty().add(component);
|
||||
}
|
||||
|
||||
var currentItems = getItems().stream().map(InstallerItem::getComponentType).toList();
|
||||
// other third-party libraries which are unable to manage.
|
||||
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
|
||||
if (currentItems.contains(mark.componentType())) continue;
|
||||
|
||||
// we have done this library above.
|
||||
|
||||
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
|
||||
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
|
||||
installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
|
||||
gameInstance.getId(),
|
||||
publishedInstance -> repository.getDependency().removeComponentAsync(
|
||||
publishedInstance,
|
||||
mark.componentType()))
|
||||
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
|
||||
.start());
|
||||
|
||||
itemsProperty().add(installerItem);
|
||||
}
|
||||
}
|
||||
|
||||
private void reloadCurrentInstance() {
|
||||
if (gameInstance != null) {
|
||||
loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
|
||||
|
||||
InstallerListPageSkin() {
|
||||
super(InstallerListPage.this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Node> initializeToolbar(InstallerListPage skinnable) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,20 @@ import javafx.application.Platform;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.task.GetTask;
|
||||
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.account.CreateAccountPane;
|
||||
import org.jackhuang.hmcl.ui.account.FirstRunAccountDialog;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
||||
@@ -46,9 +48,10 @@ public final class Instances {
|
||||
private Instances() {
|
||||
}
|
||||
|
||||
/// Installs or updates the server client from the launcher-bundled modpack when present.
|
||||
/// Installs or updates the TDLM client.
|
||||
///
|
||||
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
|
||||
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
|
||||
/// then the generic local/remote import wizard.
|
||||
public static void installServerModpack() {
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
if (!repository.isLoaded()) {
|
||||
@@ -63,7 +66,33 @@ public final class Instances {
|
||||
return;
|
||||
}
|
||||
|
||||
importModpack();
|
||||
Controllers.taskDialog(
|
||||
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null) {
|
||||
Controllers.dialog(
|
||||
exception.getMessage(),
|
||||
i18n("message.error"),
|
||||
MessageDialogPane.MessageType.ERROR);
|
||||
importModpack();
|
||||
return;
|
||||
}
|
||||
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
|
||||
result, ServerModpackManifest.class);
|
||||
if (manifest == null) {
|
||||
Controllers.dialog(
|
||||
i18n("modpack.type.server.malformed"),
|
||||
i18n("message.error"),
|
||||
MessageDialogPane.MessageType.ERROR);
|
||||
importModpack();
|
||||
return;
|
||||
}
|
||||
Controllers.getDecorator().startWizard(
|
||||
new ModpackInstallWizardProvider(repository, manifest),
|
||||
i18n("install.modpack"));
|
||||
}),
|
||||
i18n("message.downloading"),
|
||||
TaskCancellationAction.NORMAL);
|
||||
}
|
||||
|
||||
public static void importModpack() {
|
||||
@@ -329,6 +358,24 @@ public final class Instances {
|
||||
});
|
||||
}
|
||||
|
||||
/// Launches the instance and connects straight to the server configured for it.
|
||||
///
|
||||
/// When no server address is configured the game starts at the title screen.
|
||||
///
|
||||
/// @param gameInstance the instance to launch, or `null` when none is available
|
||||
public static void joinServer(@Nullable HMCLGameInstance gameInstance) {
|
||||
if (gameInstance == null) {
|
||||
launch(null);
|
||||
return;
|
||||
}
|
||||
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(gameInstance.getModpackConfigurationFile());
|
||||
if (route == null) {
|
||||
launch(gameInstance);
|
||||
} else {
|
||||
launch(gameInstance, helper -> helper.setQuickPlayOption(new QuickPlayOption.MultiPlayer(route.address())));
|
||||
}
|
||||
}
|
||||
|
||||
public static void testGame(HMCLGameInstance gameInstance) {
|
||||
launch(gameInstance, LauncherHelper::setTestMode);
|
||||
}
|
||||
@@ -345,20 +392,8 @@ public final class Instances {
|
||||
|
||||
private static void ensureSelectedAccount(Consumer<Account> action) {
|
||||
Account account = Accounts.getSelectedAccount();
|
||||
if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() &&
|
||||
!(account instanceof AuthlibInjectorAccount && AuthlibInjectorServers.getServers().contains(((AuthlibInjectorAccount) account).getServer()))) {
|
||||
CreateAccountPane dialog = new CreateAccountPane(AuthlibInjectorServers.getServers().iterator().next());
|
||||
dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> {
|
||||
Account newAccount = Accounts.getSelectedAccount();
|
||||
if (newAccount == null) {
|
||||
// user cancelled operation
|
||||
} else {
|
||||
Platform.runLater(() -> action.accept(newAccount));
|
||||
}
|
||||
});
|
||||
Controllers.dialog(dialog);
|
||||
} else if (account == null) {
|
||||
CreateAccountPane dialog = new CreateAccountPane();
|
||||
if (account == null) {
|
||||
FirstRunAccountDialog dialog = new FirstRunAccountDialog();
|
||||
dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> {
|
||||
Account newAccount = Accounts.getSelectedAccount();
|
||||
if (newAccount == null) {
|
||||
@@ -374,7 +409,6 @@ public final class Instances {
|
||||
}
|
||||
|
||||
public static void modifyGlobalSettings(HMCLGameRepository repository) {
|
||||
Controllers.getSettingsPage().showGameSettings(repository);
|
||||
Controllers.navigate(Controllers.getSettingsPage());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,900 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.mod.ModManager;
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> implements PageAware {
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private ModManager modManager;
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
|
||||
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
|
||||
|
||||
private final BooleanProperty isLoadingMods = new SimpleBooleanProperty(false);
|
||||
private long modLoadRequestId;
|
||||
|
||||
/// Creates a mod list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public ModListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
|
||||
mods.forEach(it -> {
|
||||
try {
|
||||
modManager.addMod(it);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
LOG.warning("Unable to parse mod file " + it, e);
|
||||
}
|
||||
});
|
||||
loadMods(modManager);
|
||||
});
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new ModListPageSkin(this);
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (isLoadingMods.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadMods(modManager);
|
||||
}
|
||||
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.gameInstance = instance.instance();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadMods(gameInstance.getModManager());
|
||||
}
|
||||
|
||||
private void loadMods(ModManager modManager) {
|
||||
long requestId = ++modLoadRequestId;
|
||||
|
||||
setLoading(true);
|
||||
isLoadingMods.set(true);
|
||||
|
||||
if (this.modManager != modManager) {
|
||||
getItems().clear();
|
||||
}
|
||||
this.modManager = modManager;
|
||||
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
lock.lock();
|
||||
try {
|
||||
modManager.refresh();
|
||||
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}, Schedulers.io()).whenCompleteAsync((list, exception) -> {
|
||||
if (this.modManager != modManager || requestId != modLoadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
supportedLoaders.clear();
|
||||
supportedLoaders.addAll(modManager.getSupportedLoaders());
|
||||
|
||||
if (exception == null) {
|
||||
getItems().setAll(list);
|
||||
} else {
|
||||
LOG.warning("Failed to load mods", exception);
|
||||
getItems().clear();
|
||||
}
|
||||
} finally {
|
||||
isLoadingMods.set(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
|
||||
public void add() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle(i18n("mods.add.title"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
|
||||
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
|
||||
|
||||
if (res == null) return;
|
||||
|
||||
// It's guaranteed that succeeded and failed are thread safe here.
|
||||
List<String> succeeded = new ArrayList<>(res.size());
|
||||
List<String> failed = new ArrayList<>();
|
||||
|
||||
Task.runAsync(() -> {
|
||||
for (Path file : res) {
|
||||
try {
|
||||
modManager.addMod(file);
|
||||
succeeded.add(FileUtils.getName(file));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Unable to add mod " + file, e);
|
||||
failed.add(FileUtils.getName(file));
|
||||
|
||||
// Actually addMod will not throw exceptions because FileChooser has already filtered files.
|
||||
}
|
||||
}
|
||||
}).withRunAsync(Schedulers.javafx(), () -> {
|
||||
List<String> prompt = new ArrayList<>(1);
|
||||
if (!succeeded.isEmpty())
|
||||
prompt.add(i18n("mods.add.success", String.join(", ", succeeded)));
|
||||
if (!failed.isEmpty())
|
||||
prompt.add(i18n("mods.add.failed", String.join(", ", failed)));
|
||||
Controllers.dialog(String.join("\n", prompt), i18n("mods.add"));
|
||||
loadMods(modManager);
|
||||
}).start();
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> {
|
||||
try {
|
||||
info.delete();
|
||||
} catch (IOException e) {
|
||||
failed.add(info.getFile().toString());
|
||||
LOG.warning("Failed to delete mod \"" + info.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
loadMods(modManager);
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("mods.delete.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void setSelectedEnabled(ObservableList<ModInfoObject> selectedItems, boolean enabled) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> {
|
||||
try {
|
||||
info.setActive(enabled);
|
||||
} catch (IOException e) {
|
||||
failed.add(info.getFile().toString());
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("mods.toggle.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
public void openModFolder() {
|
||||
if (gameInstance != null) {
|
||||
FXUtils.openFolder(gameInstance.getModsDirectory());
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the supplied mods for updates and opens the results for the current instance.
|
||||
/// The target directory is retained even if this page switches instances during the check.
|
||||
/// Does nothing while the list is loading or no instance is selected.
|
||||
///
|
||||
/// @param mods the mods to check
|
||||
public void checkUpdates(Collection<LocalModFile> mods) {
|
||||
Objects.requireNonNull(mods);
|
||||
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
|
||||
if (isLoading() || gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path modDirectory = gameInstance.getModManager().getDirectory();
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
if (version != GameVersionNumber.unknown()) {
|
||||
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods);
|
||||
} else {
|
||||
LOG.warning("Failed to check for updates, due to unable to get instance game version");
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception instanceof CancellationException) return;
|
||||
if (exception != null || result == null) {
|
||||
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
if (exception != null) LOG.warning("Failed to check for updates", exception);
|
||||
} else if (result.commonUpdates().isEmpty()) {
|
||||
Controllers.dialog(i18n("addon.check_update.empty"));
|
||||
} else {
|
||||
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), modDirectory, result));
|
||||
}
|
||||
})
|
||||
.withStagesHints("update.checking"),
|
||||
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
|
||||
|
||||
if (gameInstance.isModpack()) {
|
||||
Controllers.confirm(
|
||||
i18n("mods.update_modpack_mod.warning"), null,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
action, null);
|
||||
} else {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
public void rollback(LocalModFile from, LocalModFile to) {
|
||||
try {
|
||||
modManager.rollback(from, to);
|
||||
refresh();
|
||||
} catch (IOException ex) {
|
||||
Controllers.showToast(i18n("message.failed"));
|
||||
}
|
||||
}
|
||||
|
||||
public GameDirectory getGameDirectory() {
|
||||
return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null;
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return gameInstance != null ? gameInstance.getRepository() : null;
|
||||
}
|
||||
|
||||
public GameInstanceID getInstanceId() {
|
||||
return gameInstance != null ? gameInstance.getId() : null;
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private static final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
private final HBox toolbarNormal;
|
||||
private final HBox toolbarSelecting;
|
||||
|
||||
private final JFXListView<ModListPage.ModInfoObject> listView;
|
||||
|
||||
/// Whether the search mechanism is currently active.
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
|
||||
private final JFXTextField searchField;
|
||||
|
||||
/// Timer for debouncing search input to avoid executing search on every keystroke.
|
||||
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
|
||||
|
||||
public ModListPageSkin(ModListPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
StackPane pane = new StackPane();
|
||||
pane.setPadding(new Insets(10));
|
||||
pane.getStyleClass().addAll("notice-pane");
|
||||
|
||||
ComponentList root = new ComponentList();
|
||||
pane.getChildren().setAll(root);
|
||||
root.getStyleClass().add("no-padding");
|
||||
listView = new JFXListView<>();
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
|
||||
searchBar = new HBox();
|
||||
toolbarNormal = new HBox();
|
||||
toolbarSelecting = new HBox();
|
||||
|
||||
// Search Bar
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 5));
|
||||
searchField = new JFXTextField();
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
searchPause.setOnFinished(e -> search());
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
|
||||
searchPause.setRate(1);
|
||||
searchPause.playFromStart();
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
|
||||
() -> {
|
||||
changeToolbar(toolbarNormal);
|
||||
|
||||
searchField.clear();
|
||||
searchPause.stop();
|
||||
|
||||
isSearching.set(false);
|
||||
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
|
||||
});
|
||||
|
||||
onEscPressed(searchField, closeSearchBar::fire);
|
||||
|
||||
searchBar.getChildren().setAll(searchField, closeSearchBar);
|
||||
|
||||
JFXButton refreshButton = createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh);
|
||||
refreshButton.disableProperty().bind(skinnable.isLoadingMods);
|
||||
|
||||
// Toolbar Normal
|
||||
toolbarNormal.getChildren().setAll(
|
||||
refreshButton,
|
||||
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getItems().stream()
|
||||
.map(ModListPage.ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
);
|
||||
|
||||
// Toolbar Selecting
|
||||
|
||||
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
|
||||
|
||||
ListChangeListener<Object> listener = change -> {
|
||||
selectAll.setDisable(!listView.getItems().isEmpty()
|
||||
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
|
||||
};
|
||||
|
||||
listView.getSelectionModel().getSelectedItems().addListener(listener);
|
||||
listView.getItems().addListener(listener);
|
||||
|
||||
toolbarSelecting.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream()
|
||||
.map(ModListPage.ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
selectAll,
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
|
||||
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
|
||||
selectedItem -> {
|
||||
if (selectedItem == null)
|
||||
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
|
||||
else
|
||||
changeToolbar(toolbarSelecting);
|
||||
});
|
||||
|
||||
FXUtils.setOverflowHidden(toolbarPane, 8);
|
||||
|
||||
root.getContent().add(toolbarPane);
|
||||
|
||||
// Clear selection when pressing ESC
|
||||
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.ESCAPE) {
|
||||
if (listView.getSelectionModel().getSelectedItem() != null) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new ModListPage.ModInfoListCell(listView, skinnable));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("mods.empty"));
|
||||
placeholderLabel.textProperty().bind(
|
||||
Bindings.createStringBinding(() -> {
|
||||
if (isSearching.get()) {
|
||||
return i18n("search.no_results_found");
|
||||
} else {
|
||||
return i18n("mods.empty");
|
||||
}
|
||||
},
|
||||
isSearching)
|
||||
);
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
skinnable.getItems().addListener((ListChangeListener<? super ModListPage.ModInfoObject>) c -> {
|
||||
if (isSearching.get()) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
listView.setOnContextMenuRequested(event -> {
|
||||
ModListPage.ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
|
||||
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
Controllers.dialog(new ModListPage.ModInfoDialog(selectedItem));
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
private void changeToolbar(HBox newToolbar) {
|
||||
Node oldToolbar = toolbarPane.getCurrentNode();
|
||||
if (newToolbar != oldToolbar) {
|
||||
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
|
||||
if (newToolbar == searchBar) {
|
||||
Platform.runLater(searchField::requestFocus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void search() {
|
||||
isSearching.set(true);
|
||||
|
||||
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
|
||||
|
||||
String queryString = searchField.getText();
|
||||
if (StringUtils.isBlank(queryString)) {
|
||||
listView.getItems().setAll(getSkinnable().getItems());
|
||||
} else {
|
||||
listView.getItems().clear();
|
||||
|
||||
Predicate<@Nullable String> predicate;
|
||||
try {
|
||||
predicate = StringUtils.compileQuery(queryString);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Illegal regular expression", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do we need to search in the background thread?
|
||||
for (ModListPage.ModInfoObject item : getSkinnable().getItems()) {
|
||||
LocalModFile modInfo = item.getModInfo();
|
||||
if (predicate.test(modInfo.getFileName())
|
||||
|| predicate.test(modInfo.getName())
|
||||
|| predicate.test(modInfo.getVersion())
|
||||
|| predicate.test(modInfo.getGameVersion())
|
||||
|| predicate.test(modInfo.getId())
|
||||
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|
||||
|| predicate.test((item.getProjectInfo() != null ? item.getProjectInfo().displayName() : null))) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static final class ModInfoObject {
|
||||
private final LocalModFile localModFile;
|
||||
/// Matching project metadata, or null if the local mod cannot be identified.
|
||||
private final @Nullable ModProjectInfo projectInfo;
|
||||
|
||||
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
|
||||
public LocalModFile getModInfo() {
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
/// Returns matching project metadata, or null if no project was found.
|
||||
public @Nullable ModProjectInfo getProjectInfo() {
|
||||
return projectInfo;
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
|
||||
}
|
||||
|
||||
private Image loadIcon() {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
|
||||
iconPaths.add(this.localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
|
||||
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
|
||||
Math.abs(image.getWidth() - image.getHeight()) < 1) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load mod icons", e);
|
||||
}
|
||||
|
||||
return getDefaultIcon();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ModInfoDialog extends JFXDialogLayout {
|
||||
|
||||
ModInfoDialog(ModInfoObject modInfo) {
|
||||
HBox titleContainer = new HBox();
|
||||
titleContainer.setSpacing(8);
|
||||
titleContainer.setPadding(new Insets(0, 0, 12, 0));
|
||||
|
||||
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
|
||||
prefWidthProperty().bind(widthBinding);
|
||||
maxWidthProperty().bind(widthBinding);
|
||||
|
||||
var imageContainer = new ImageContainer(40);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
modInfo.iconCache.attachValue(imageContainer.imageProperty(), null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.getTitleLabel().setWrapText(true);
|
||||
String displayName = modInfo.getModInfo().getName();
|
||||
if (modInfo.getProjectInfo() != null && I18n.isUseChinese()) {
|
||||
String chineseName = modInfo.getProjectInfo().mainName();
|
||||
if (StringUtils.containsChinese(chineseName)) {
|
||||
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
|
||||
displayName = chineseName + " (" + displayName + ")";
|
||||
}
|
||||
}
|
||||
title.setTitle(displayName);
|
||||
|
||||
StringJoiner subtitle = new StringJoiner("\n");
|
||||
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
}
|
||||
title.setSubtitle(subtitle.toString());
|
||||
|
||||
titleContainer.getChildren().setAll(imageContainer, title);
|
||||
setHeading(titleContainer);
|
||||
|
||||
Label description = new Label(modInfo.getModInfo().getDescription().toString());
|
||||
description.setWrapText(true);
|
||||
FXUtils.copyOnDoubleClick(description);
|
||||
|
||||
ScrollPane descriptionPane = new ScrollPane(description);
|
||||
FXUtils.smoothScrolling(descriptionPane);
|
||||
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
descriptionPane.setFitToWidth(true);
|
||||
description.heightProperty().addListener((obs, oldVal, newVal) -> {
|
||||
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
|
||||
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
|
||||
descriptionPane.setPrefViewportHeight(targetHeight);
|
||||
});
|
||||
|
||||
setBody(descriptionPane);
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
|
||||
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
|
||||
)) {
|
||||
RemoteAddonRepository repository = item.getValue();
|
||||
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
|
||||
Task.runAsync(() -> {
|
||||
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
|
||||
if (versionOptional.isPresent()) {
|
||||
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
|
||||
FXUtils.runInFX(() -> {
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
for (AddonLoader loader : versionOptional.get().loaders()) {
|
||||
String tag = I18n.translateLoaderName(loader);
|
||||
if (tag != null) tags.add(tag);
|
||||
}
|
||||
title.addTagsIfNotExist(tags);
|
||||
|
||||
button.setExternalLink(remoteAddon.pageUrl());
|
||||
button.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
button.setDisable(true);
|
||||
getActions().add(button);
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
|
||||
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
|
||||
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
|
||||
getActions().add(officialPageButton);
|
||||
}
|
||||
|
||||
@Nullable String mcmodUrl = modInfo.getProjectInfo() != null ? modInfo.getProjectInfo().mcmodUrl() : null;
|
||||
if (mcmodUrl == null) {
|
||||
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
|
||||
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
|
||||
pair("key", modInfo.getModInfo().getName()),
|
||||
pair("site", "all"),
|
||||
pair("filter", "0")
|
||||
)));
|
||||
getActions().add(searchButton);
|
||||
} else {
|
||||
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
|
||||
mcmodButton.setExternalLink(mcmodUrl);
|
||||
getActions().add(mcmodButton);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
|
||||
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
|
||||
|
||||
private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
|
||||
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
|
||||
|
||||
private final ModListPage page;
|
||||
|
||||
private final ImageContainer imageContainer = new ImageContainer(32);
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
|
||||
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
private final JFXSwitch activeSwitch = new JFXSwitch();
|
||||
|
||||
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getModInfo().isActive());
|
||||
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
|
||||
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
|
||||
|
||||
private Tooltip warningTooltip;
|
||||
|
||||
/// Creates a selectable mod row with file actions for the supplied page.
|
||||
ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
|
||||
super(listView);
|
||||
this.page = page;
|
||||
|
||||
this.getStyleClass().add("mod-info-list-cell");
|
||||
|
||||
HBox container = new HBox(8);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
container.setPadding(new Insets(0, 8, 0, 8));
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
setSelectable();
|
||||
|
||||
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
|
||||
|
||||
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
|
||||
|
||||
// free up space when not visible
|
||||
restoreButton.managedProperty().bind(restoreButton.visibleProperty());
|
||||
|
||||
HBox actions = new HBox();
|
||||
actions.setAlignment(Pos.CENTER);
|
||||
actions.getChildren().setAll(restoreButton, revealButton, infoButton, activeSwitch);
|
||||
|
||||
activeSwitch.setSize(20);
|
||||
activeSwitch.setMinSize(30, 30);
|
||||
activeSwitch.setPrefSize(30, 30);
|
||||
activeSwitch.setMaxSize(30, 30);
|
||||
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
|
||||
|
||||
container.getChildren().setAll(imageContainer, content, actions);
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
/// Updates mod details, warnings, and tags while retaining reusable tag labels.
|
||||
@Override
|
||||
protected void updateControl(@Nullable ModInfoObject dataItem, boolean empty) {
|
||||
if (observedActiveProperty != null)
|
||||
observedActiveProperty.removeListener(weakActiveListener);
|
||||
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
if (warningTooltip != null) {
|
||||
Tooltip.uninstall(this, warningTooltip);
|
||||
warningTooltip = null;
|
||||
}
|
||||
|
||||
if (empty || dataItem == null) return;
|
||||
|
||||
List<String> warning = new ArrayList<>();
|
||||
|
||||
var tags = new ArrayList<Tag>(2);
|
||||
|
||||
LocalModFile modInfo = dataItem.getModInfo();
|
||||
@Nullable ModProjectInfo projectInfo = dataItem.getProjectInfo();
|
||||
|
||||
ModLoaderType modLoaderType = modInfo.getModLoaderType();
|
||||
|
||||
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
|
||||
String displayName = modInfo.getName();
|
||||
if (projectInfo != null && I18n.isUseChinese()) {
|
||||
String chineseName = projectInfo.mainName();
|
||||
if (StringUtils.containsChinese(chineseName)) {
|
||||
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
|
||||
displayName = chineseName + " (" + displayName + ")";
|
||||
}
|
||||
}
|
||||
content.setTitle(displayName);
|
||||
|
||||
StringJoiner joiner = new StringJoiner(" | ");
|
||||
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
|
||||
joiner.add(modInfo.getId());
|
||||
|
||||
joiner.add(FileUtils.getName(modInfo.getFile()));
|
||||
|
||||
content.setSubtitle(joiner.toString());
|
||||
|
||||
if (modLoaderType == ModLoaderType.UNKNOWN) {
|
||||
tags.add(Tag.warning(i18n("mods.unknown")));
|
||||
} else if (!page.supportedLoaders.contains(modLoaderType)) {
|
||||
warning.add(i18n("mods.warning.loader_mismatch"));
|
||||
tags.add(Tag.warning(I18n.translateLoaderType(dataItem.getModInfo().getModLoaderType())));
|
||||
}
|
||||
|
||||
String modVersion = modInfo.getVersion();
|
||||
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
|
||||
tags.add(new Tag(modVersion));
|
||||
}
|
||||
|
||||
content.getTags().setAll(tags);
|
||||
|
||||
(observedActiveProperty = modInfo.activeProperty()).addListener(weakActiveListener);
|
||||
activeSwitch.setDisableAnimation(true);
|
||||
activeSwitch.setSelected(modInfo.isActive());
|
||||
activeSwitch.setOnAction(event -> {
|
||||
try {
|
||||
modInfo.setActive(activeSwitch.isSelected());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to change state of mod file " + modInfo.getFile(), e);
|
||||
Controllers.showToast(i18n("mods.toggle.failed"));
|
||||
} finally {
|
||||
activeSwitch.setSelected(modInfo.isActive());
|
||||
}
|
||||
});
|
||||
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
|
||||
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setOnAction(e -> {
|
||||
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
|
||||
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
|
||||
() -> page.rollback(modInfo, localModFile),
|
||||
popup.get()))
|
||||
.toList()
|
||||
);
|
||||
|
||||
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
|
||||
});
|
||||
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
|
||||
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
|
||||
|
||||
if (!warning.isEmpty()) {
|
||||
pseudoClassStateChanged(WARNING, true);
|
||||
|
||||
//noinspection ConstantValue
|
||||
this.warningTooltip = warning.size() == 1
|
||||
? new Tooltip(warning.get(0))
|
||||
: new Tooltip(String.join("\n", warning));
|
||||
FXUtils.installFastTooltip(this, warningTooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,689 +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.*;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
|
||||
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
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.ListPageBase;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> {
|
||||
|
||||
private static final String TIP_KEY = "resourcePackWarning";
|
||||
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
|
||||
return switch (compatibility) {
|
||||
case TOO_NEW -> i18n("resourcepack.warning.too_new");
|
||||
case TOO_OLD -> i18n("resourcepack.warning.too_old");
|
||||
case INVALID -> i18n("resourcepack.warning.invalid");
|
||||
case MISSING_PACK_META -> i18n("resourcepack.warning.missing_pack_meta");
|
||||
case MISSING_GAME_META -> i18n("resourcepack.warning.missing_game_meta");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
|
||||
private Path resourcePackDirectory;
|
||||
private ResourcePackManager resourcePackManager;
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
/// Creates a resource-pack list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public ResourcePackListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new ResourcePackListPageSkin(this);
|
||||
}
|
||||
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.gameInstance = instance.instance();
|
||||
if (gameInstance == null) {
|
||||
this.resourcePackManager = null;
|
||||
this.resourcePackDirectory = null;
|
||||
getItems().clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.resourcePackManager = gameInstance.getResourcePackManager();
|
||||
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (resourcePackManager == null) return;
|
||||
setDisable(false);
|
||||
setLoading(true);
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
lock.lock();
|
||||
try {
|
||||
if (!ResourcePackManager.isMcVersionSupported(resourcePackManager.getMinecraftVersion())) return null;
|
||||
resourcePackManager.refresh();
|
||||
return resourcePackManager.arePacksEnabled(resourcePackManager.getLocalFiles().stream())
|
||||
.map(ResourcePackInfoObject::new)
|
||||
.toList();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
if (result != null) {
|
||||
getItems().setAll(result);
|
||||
} else { // Unsupported mc version
|
||||
getItems().clear();
|
||||
setDisable(true);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("Failed to load resource packs", exception);
|
||||
getItems().clear();
|
||||
}
|
||||
setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void addFiles(List<Path> files) {
|
||||
if (resourcePackManager == null) return;
|
||||
|
||||
List<Path> failures = new ArrayList<>();
|
||||
Task.runAsync(() -> {
|
||||
for (Path file : files) {
|
||||
try {
|
||||
resourcePackManager.importResourcePack(file);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to add resource pack", e);
|
||||
failures.add(file);
|
||||
}
|
||||
}
|
||||
}).withRunAsync(Schedulers.javafx(), () -> {
|
||||
if (!failures.isEmpty()) {
|
||||
StringBuilder failure = new StringBuilder(i18n("resourcepack.add.failed"));
|
||||
for (Path file: failures) {
|
||||
failure.append("\n").append(file.toString());
|
||||
}
|
||||
Controllers.dialog(failure.toString(), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
refresh();
|
||||
}).start();
|
||||
|
||||
}
|
||||
|
||||
public void onAddFiles() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("resourcepack.add"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip"));
|
||||
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
|
||||
if (files != null && !files.isEmpty()) {
|
||||
addFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
private void onOpenFolder() {
|
||||
if (resourcePackDirectory != null) {
|
||||
FXUtils.openFolder(resourcePackDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private void setSelectedEnabled(List<ResourcePackInfoObject> selectedItems, boolean enabled) {
|
||||
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) {
|
||||
Controllers.confirm(
|
||||
i18n("resourcepack.warning.manipulate"),
|
||||
i18n("message.warning"),
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
() -> {
|
||||
SettingsManager.state().getShownTips().put(TIP_KEY, true);
|
||||
setSelectedEnabled(selectedItems, true);
|
||||
}, null);
|
||||
} else {
|
||||
if (resourcePackManager == null) return;
|
||||
if (enabled) {
|
||||
resourcePackManager.enableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
|
||||
} else {
|
||||
resourcePackManager.disableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
|
||||
}
|
||||
for (ResourcePackInfoObject item : selectedItems) {
|
||||
item.enabledProperty().set(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeSelected(List<ResourcePackInfoObject> selectedItems) {
|
||||
try {
|
||||
if (resourcePackManager != null) {
|
||||
if (resourcePackManager.removeResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList())) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
LOG.warning("Failed to delete resource packs", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the supplied resource packs for updates and opens the results for the current instance.
|
||||
/// The target directory is retained even if this page switches instances during the check.
|
||||
/// Does nothing when no instance is selected.
|
||||
///
|
||||
/// @param resourcePacks the resource packs to check
|
||||
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
|
||||
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path resourcePackDirectory = gameInstance.getResourcePackManager().getDirectory();
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
if (version != GameVersionNumber.unknown()) {
|
||||
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), resourcePacks);
|
||||
} else {
|
||||
LOG.warning("Failed to check for updates, due to unable to get instance game version");
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null || result == null) {
|
||||
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
} else if (result.commonUpdates().isEmpty()) {
|
||||
Controllers.dialog(i18n("addon.check_update.empty"));
|
||||
} else {
|
||||
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), resourcePackDirectory, result));
|
||||
}
|
||||
})
|
||||
.withStagesHints("update.checking"),
|
||||
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
|
||||
|
||||
if (gameInstance.isModpack()) {
|
||||
Controllers.confirm(
|
||||
i18n("resourcepack.update_in_modpack.warning"), null,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
action, null);
|
||||
} else {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private static final class ResourcePackListPageSkin extends SkinBase<ResourcePackListPage> {
|
||||
private final JFXListView<ResourcePackInfoObject> listView;
|
||||
private final JFXTextField searchField = new JFXTextField();
|
||||
|
||||
private final TransitionPane toolbarPane = new TransitionPane();
|
||||
private final HBox searchBar = new HBox();
|
||||
private final HBox toolbarNormal = new HBox();
|
||||
private final HBox toolbarSelecting = new HBox();
|
||||
|
||||
/// Whether the search mechanism is currently active.
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Timer for debouncing search input to avoid executing search on every keystroke.
|
||||
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
|
||||
|
||||
private ResourcePackListPageSkin(ResourcePackListPage control) {
|
||||
super(control);
|
||||
|
||||
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<>();
|
||||
|
||||
{
|
||||
|
||||
// Toolbar Selecting
|
||||
toolbarSelecting.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
control.removeSelected(listView.getSelectionModel().getSelectedItems());
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("button.enable"), SVG.CHECK, () ->
|
||||
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
|
||||
createToolbarButton2(i18n("button.disable"), SVG.CLOSE, () ->
|
||||
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
control.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream().map(ResourcePackInfoObject::getFile).toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
|
||||
listView.getSelectionModel().selectAll()),
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
|
||||
// Search Bar
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 5));
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
searchPause.setOnFinished(e -> search());
|
||||
FXUtils.onChange(searchField.textProperty(), newValue -> {
|
||||
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
|
||||
searchPause.setRate(1);
|
||||
searchPause.playFromStart();
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
|
||||
() -> {
|
||||
changeToolbar(toolbarNormal);
|
||||
|
||||
searchField.clear();
|
||||
searchPause.stop();
|
||||
|
||||
isSearching.set(false);
|
||||
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
|
||||
});
|
||||
|
||||
onEscPressed(searchField, closeSearchBar::fire);
|
||||
|
||||
searchBar.getChildren().setAll(searchField, closeSearchBar);
|
||||
|
||||
// Toolbar Normal
|
||||
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
|
||||
toolbarNormal.setPickOnBounds(false);
|
||||
toolbarNormal.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh),
|
||||
createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, control::onOpenFolder),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
|
||||
),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
);
|
||||
|
||||
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
|
||||
selectedItem -> {
|
||||
if (selectedItem == null)
|
||||
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
|
||||
else
|
||||
changeToolbar(toolbarSelecting);
|
||||
});
|
||||
FXUtils.setOverflowHidden(toolbarPane, 8);
|
||||
root.getContent().add(toolbarPane);
|
||||
|
||||
// Clear selection when pressing ESC
|
||||
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.ESCAPE) {
|
||||
if (listView.getSelectionModel().getSelectedItem() != null) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(control.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new ResourcePackListCell(listView, control));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("resourcepack.empty"));
|
||||
placeholderLabel.textProperty().bind(
|
||||
Bindings.createStringBinding(() -> {
|
||||
if (isSearching.get()) {
|
||||
return i18n("search.no_results_found");
|
||||
} else {
|
||||
return i18n("resourcepack.empty");
|
||||
}
|
||||
},
|
||||
isSearching)
|
||||
);
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
Bindings.bindContent(listView.getItems(), control.getItems());
|
||||
|
||||
listView.setOnContextMenuRequested(event -> {
|
||||
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
|
||||
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
Controllers.dialog(new ResourcePackInfoDialog(selectedItem));
|
||||
}
|
||||
});
|
||||
|
||||
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
pane.getChildren().setAll(root);
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
private void changeToolbar(HBox newToolbar) {
|
||||
Node oldToolbar = toolbarPane.getCurrentNode();
|
||||
if (newToolbar != oldToolbar) {
|
||||
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
|
||||
if (newToolbar == searchBar) {
|
||||
Platform.runLater(searchField::requestFocus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void search() {
|
||||
isSearching.set(true);
|
||||
|
||||
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
|
||||
|
||||
String queryString = searchField.getText();
|
||||
if (StringUtils.isBlank(queryString)) {
|
||||
listView.getItems().setAll(getSkinnable().getItems());
|
||||
} else {
|
||||
listView.getItems().clear();
|
||||
|
||||
Predicate<@Nullable String> predicate;
|
||||
try {
|
||||
predicate = StringUtils.compileQuery(queryString);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Illegal regular expression", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do we need to search in the background thread?
|
||||
for (ResourcePackInfoObject item : getSkinnable().getItems()) {
|
||||
ResourcePackFile resourcePack = item.getFile();
|
||||
LocalAddonFile.Description description = resourcePack.getDescription();
|
||||
Stream<String> descriptionParts = description == null
|
||||
? Stream.empty()
|
||||
: description.parts().stream().map(LocalAddonFile.Description.Part::text);
|
||||
if (predicate.test(resourcePack.getFileNameWithExtension())
|
||||
|| predicate.test(resourcePack.getFileName())
|
||||
|| descriptionParts.anyMatch(predicate)) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ResourcePackInfoObject {
|
||||
|
||||
private final ResourcePackFile file;
|
||||
private final BooleanProperty enabled;
|
||||
|
||||
private final ItemPropertyAsyncCache<Image, ResourcePackInfoObject> iconCache;
|
||||
|
||||
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
|
||||
this.file = pair.key();
|
||||
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
|
||||
public ResourcePackFile getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public BooleanProperty enabledProperty() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
|
||||
}
|
||||
|
||||
private Image loadIcon() {
|
||||
Image icon = file.loadIcon();
|
||||
if (icon != null && !icon.isError() && icon.getWidth() > 0 && icon.getHeight() > 0 && Math.abs(icon.getWidth() - icon.getHeight()) < 1) {
|
||||
return icon;
|
||||
}
|
||||
return getDefaultIcon();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ResourcePackListCell extends MDListCell<ResourcePackInfoObject> {
|
||||
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
|
||||
|
||||
private final ResourcePackListPage page;
|
||||
|
||||
private final ImageContainer imageContainer = new ImageContainer(32);
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
|
||||
private final JFXSwitch activeSwitch;
|
||||
|
||||
private BooleanProperty booleanProperty = null;
|
||||
|
||||
/// Creates a selectable pack row with compatibility confirmation and file actions.
|
||||
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
|
||||
super(listView);
|
||||
this.page = page;
|
||||
|
||||
getStyleClass().add("resource-pack-list-cell");
|
||||
|
||||
HBox root = new HBox(8);
|
||||
root.setPickOnBounds(false);
|
||||
root.setAlignment(Pos.CENTER_LEFT);
|
||||
root.setPadding(new Insets(0, 8, 0, 8));
|
||||
|
||||
activeSwitch = new JFXSwitch() {
|
||||
@Override
|
||||
public void fire() {
|
||||
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && getItem() != null && !getItem().getFile().isCompatible()) {
|
||||
Controllers.confirm(
|
||||
i18n("resourcepack.warning.manipulate"),
|
||||
i18n("message.info"),
|
||||
MessageDialogPane.MessageType.INFO,
|
||||
() -> {
|
||||
super.fire();
|
||||
SettingsManager.state().getShownTips().put(TIP_KEY, true);
|
||||
}, null);
|
||||
} else {
|
||||
super.fire();
|
||||
}
|
||||
}
|
||||
};
|
||||
activeSwitch.setOnAction(e -> {
|
||||
if (getItem() != null) getItem().getFile().setEnabled(activeSwitch.isSelected());
|
||||
});
|
||||
activeSwitch.setSize(20);
|
||||
activeSwitch.setMinSize(30, 30);
|
||||
activeSwitch.setPrefSize(30, 30);
|
||||
activeSwitch.setMaxSize(30, 30);
|
||||
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
|
||||
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
|
||||
HBox actions = new HBox();
|
||||
actions.setAlignment(Pos.CENTER);
|
||||
actions.getChildren().setAll(btnReveal, btnInfo, activeSwitch);
|
||||
|
||||
root.getChildren().setAll(imageContainer, content, actions);
|
||||
|
||||
setSelectable();
|
||||
|
||||
StackPane.setMargin(root, new Insets(8));
|
||||
getContainer().getChildren().add(root);
|
||||
}
|
||||
|
||||
/// Updates pack details and reuses the compatibility tag when a warning remains.
|
||||
@Override
|
||||
protected void updateControl(@Nullable ResourcePackInfoObject item, boolean empty) {
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
|
||||
if (empty || item == null) return;
|
||||
|
||||
ResourcePackFile file = item.getFile();
|
||||
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(itemProperty()));
|
||||
|
||||
content.setTitle(file.getFileName());
|
||||
{
|
||||
var description = file.getDescription();
|
||||
content.setSubtitle(description != null ? description.toStringSingleLine() : "");
|
||||
}
|
||||
|
||||
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
|
||||
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
|
||||
|
||||
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(item)));
|
||||
|
||||
if (booleanProperty != null) {
|
||||
activeSwitch.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
activeSwitch.selectedProperty().bindBidirectional(booleanProperty = item.enabledProperty());
|
||||
|
||||
{
|
||||
var compatibility = file.getCompatibility();
|
||||
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
|
||||
pseudoClassStateChanged(WARNING, true);
|
||||
content.getTags().setAll(Tag.warning(getWarning(compatibility)));
|
||||
} else {
|
||||
content.getTags().clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
|
||||
|
||||
ResourcePackInfoDialog(ResourcePackInfoObject packInfoObject) {
|
||||
ResourcePackFile pack = packInfoObject.getFile();
|
||||
|
||||
HBox titleContainer = new HBox();
|
||||
titleContainer.setSpacing(8);
|
||||
|
||||
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
|
||||
ImageContainer imageContainer = new ImageContainer(40);
|
||||
packInfoObject.iconCache.attachValue(imageContainer.imageProperty(), null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(pack.getFileName());
|
||||
title.setSubtitle(pack.getFileNameWithExtension());
|
||||
var compatibility = pack.getCompatibility();
|
||||
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
|
||||
title.addTagWarning(getWarning(compatibility));
|
||||
}
|
||||
|
||||
titleContainer.getChildren().setAll(imageContainer, title);
|
||||
setHeading(titleContainer);
|
||||
|
||||
Label description = new Label(Objects.requireNonNullElse(pack.getDescription(), "").toString());
|
||||
description.setWrapText(true);
|
||||
FXUtils.copyOnDoubleClick(description);
|
||||
|
||||
ScrollPane descriptionPane = new ScrollPane(description);
|
||||
FXUtils.smoothScrolling(descriptionPane);
|
||||
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
descriptionPane.setFitToWidth(true);
|
||||
FXUtils.onChange(description.heightProperty(), newVal -> {
|
||||
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
|
||||
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
|
||||
descriptionPane.setPrefViewportHeight(targetHeight);
|
||||
});
|
||||
|
||||
setBody(descriptionPane);
|
||||
|
||||
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
|
||||
)) {
|
||||
RemoteAddonRepository repository = item.getValue();
|
||||
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
|
||||
Task.runAsync(() -> {
|
||||
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile());
|
||||
if (versionOptional.isPresent()) {
|
||||
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
|
||||
FXUtils.runInFX(() -> {
|
||||
button.setExternalLink(remoteAddon.pageUrl());
|
||||
button.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
button.setDisable(true);
|
||||
getActions().add(button);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +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 org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class WorldBackupTask extends Task<Path> {
|
||||
|
||||
private final World world;
|
||||
private final Path backupsDir;
|
||||
private final boolean needLock;
|
||||
|
||||
public WorldBackupTask(World world, Path backupsDir, boolean needLock) {
|
||||
this.world = world;
|
||||
this.backupsDir = backupsDir;
|
||||
this.needLock = needLock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
try (FileChannel lockChannel = needLock ? world.lock() : null) {
|
||||
Files.createDirectories(backupsDir);
|
||||
String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER);
|
||||
String baseName = time + "_" + world.getFileName();
|
||||
Path backupFile = null;
|
||||
OutputStream outputStream = null;
|
||||
|
||||
int count;
|
||||
for (count = 0; count < 256; count++) {
|
||||
try {
|
||||
backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath();
|
||||
outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
|
||||
break;
|
||||
} catch (FileAlreadyExistsException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if (outputStream == null)
|
||||
throw new IOException("Too many attempts");
|
||||
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) {
|
||||
String rootName = world.getFileName();
|
||||
Path rootDir = this.world.getFile();
|
||||
Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
|
||||
if (path.endsWith("session.lock")) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/')));
|
||||
Files.copy(path, zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setResult(backupFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +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 javafx.beans.property.BooleanProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.game.WorldLockedException;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.ImageContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.BackupInfo> implements WorldManagePage.WorldRefreshable {
|
||||
static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
||||
|
||||
private final World world;
|
||||
private final Path backupsDir;
|
||||
private final BooleanProperty readOnly;
|
||||
private final Pattern backupFileNamePattern;
|
||||
|
||||
public WorldBackupsPage(WorldManagePage worldManagePage) {
|
||||
this.world = worldManagePage.getWorld();
|
||||
this.backupsDir = worldManagePage.getBackupsDir();
|
||||
this.readOnly = worldManagePage.readOnlyProperty();
|
||||
this.backupFileNamePattern = Pattern.compile("(?<datetime>[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?<count>[0-9]+))?\\.zip");
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
setLoading(true);
|
||||
Task.supplyAsync(() -> {
|
||||
if (Files.isDirectory(backupsDir)) {
|
||||
try (Stream<Path> paths = Files.list(backupsDir)) {
|
||||
ArrayList<BackupInfo> result = new ArrayList<>();
|
||||
|
||||
paths.forEach(path -> {
|
||||
if (Files.isRegularFile(path)) {
|
||||
try {
|
||||
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
|
||||
if (matcher.matches()) {
|
||||
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
|
||||
int count = 0;
|
||||
|
||||
if (matcher.group("count") != null) {
|
||||
count = Integer.parseInt(matcher.group("count"));
|
||||
}
|
||||
|
||||
result.add(new BackupInfo(path, new World(path), time, count));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load backup file " + path, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
result.sort(Comparator.naturalOrder());
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
return new ArrayList<BackupInfo>();
|
||||
}
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
this.setLoading(false);
|
||||
if (exception == null) {
|
||||
this.setItems(FXCollections.observableList(result));
|
||||
} else {
|
||||
LOG.warning("Failed to load backups", exception);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new WorldBackupsPageSkin();
|
||||
}
|
||||
|
||||
void createBackup() {
|
||||
Controllers.taskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> {
|
||||
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
|
||||
if (!matcher.matches()) {
|
||||
throw new AssertionError("Wrong backup file name" + path);
|
||||
}
|
||||
|
||||
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
|
||||
int count = 0;
|
||||
|
||||
if (matcher.group("count") != null) {
|
||||
count = Integer.parseInt(matcher.group("count"));
|
||||
}
|
||||
|
||||
return Pair.pair(path, new BackupInfo(path, new World(path), time, count));
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
WorldBackupsPage.this.getItems().add(result.getValue());
|
||||
WorldBackupsPage.this.getItems().sort(Comparator.naturalOrder());
|
||||
Controllers.dialog(i18n("world.backup.create.success", result.getKey()), null, MessageDialogPane.MessageType.INFO);
|
||||
} else if (exception instanceof WorldLockedException) {
|
||||
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
|
||||
} else {
|
||||
LOG.warning("Failed to create backup", exception);
|
||||
Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
|
||||
}
|
||||
}), i18n("world.backup"), TaskCancellationAction.NO_CANCEL);
|
||||
}
|
||||
|
||||
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
|
||||
|
||||
WorldBackupsPageSkin() {
|
||||
super(WorldBackupsPage.this);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("world.backup.empty"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Node> initializeToolbar(WorldBackupsPage skinnable) {
|
||||
JFXButton createBackup = createToolbarButton2(i18n("world.backup.create.new_one"), SVG.ARCHIVE, skinnable::createBackup);
|
||||
createBackup.disableProperty().bind(getSkinnable().readOnly);
|
||||
|
||||
return Arrays.asList(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createBackup
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public final class BackupInfo extends Control implements Comparable<BackupInfo> {
|
||||
private final Path file;
|
||||
private final World backupWorld;
|
||||
private final LocalDateTime backupTime;
|
||||
private final int count;
|
||||
|
||||
public BackupInfo(Path file, World backupWorld, LocalDateTime backupTime, int count) {
|
||||
this.file = file;
|
||||
this.backupWorld = backupWorld;
|
||||
this.backupTime = backupTime;
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
public World getBackupWorld() {
|
||||
return backupWorld;
|
||||
}
|
||||
|
||||
public LocalDateTime getBackupTime() {
|
||||
return backupTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new BackupInfoSkin(this);
|
||||
}
|
||||
|
||||
void onReveal() {
|
||||
FXUtils.showFileInExplorer(file);
|
||||
}
|
||||
|
||||
void onDelete() {
|
||||
WorldBackupsPage.this.getItems().remove(this);
|
||||
Task.runAsync(() -> Files.delete(file)).start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NotNull WorldBackupsPage.BackupInfo that) {
|
||||
int c = this.backupTime.compareTo(that.backupTime);
|
||||
return c != 0 ? c : Integer.compare(this.count, that.count);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BackupInfoSkin extends SkinBase<BackupInfo> {
|
||||
|
||||
BackupInfoSkin(BackupInfo skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
World world = skinnable.getBackupWorld();
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
|
||||
{
|
||||
StackPane left = new StackPane();
|
||||
root.setLeft(left);
|
||||
left.setPadding(new Insets(0, 8, 0, 0));
|
||||
|
||||
var imageView = new ImageContainer(32);
|
||||
left.getChildren().add(imageView);
|
||||
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
|
||||
}
|
||||
|
||||
{
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
root.setCenter(item);
|
||||
|
||||
if (skinnable.getBackupWorld().getWorldName() != null)
|
||||
item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName()));
|
||||
item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")"));
|
||||
|
||||
if (world.getGameVersion() != null)
|
||||
item.addTag(I18n.getDisplayVersion(world.getGameVersion()));
|
||||
}
|
||||
|
||||
{
|
||||
HBox right = new HBox(8);
|
||||
root.setRight(right);
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
|
||||
right.getChildren().add(btnReveal);
|
||||
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
|
||||
btnReveal.setOnAction(event -> skinnable.onReveal());
|
||||
|
||||
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
|
||||
right.getChildren().add(btnDelete);
|
||||
FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete"));
|
||||
btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::onDelete, null));
|
||||
}
|
||||
|
||||
getChildren().setAll(new RipplerContainer(root));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +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.instances;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.scene.control.Skin;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardSinglePage;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class WorldExportPage extends WizardSinglePage {
|
||||
private final StringProperty path = new SimpleStringProperty();
|
||||
private final StringProperty gameVersion = new SimpleStringProperty();
|
||||
private final StringProperty worldName = new SimpleStringProperty();
|
||||
private final World world;
|
||||
|
||||
public WorldExportPage(World world, Path export, Runnable onFinish) {
|
||||
super(onFinish);
|
||||
|
||||
this.world = world;
|
||||
|
||||
path.set(export.toString());
|
||||
if (world.getGameVersion() != null)
|
||||
gameVersion.set(I18n.getDisplayVersion(world.getGameVersion()));
|
||||
worldName.set(world.getWorldName());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new WorldExportPageSkin(this);
|
||||
}
|
||||
|
||||
public StringProperty pathProperty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public StringProperty gameVersionProperty() {
|
||||
return gameVersion;
|
||||
}
|
||||
|
||||
public StringProperty worldNameProperty() {
|
||||
return worldName;
|
||||
}
|
||||
|
||||
public void export() {
|
||||
onFinish.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return i18n("world.export.wizard", world.getFileName());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object finish() {
|
||||
return Task.runAsync(i18n("world.export.wizard", worldName.get()), () -> world.export(Paths.get(path.get()), worldName.get()));
|
||||
}
|
||||
}
|
||||
@@ -1,72 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
|
||||
import org.jackhuang.hmcl.ui.construct.LinePane;
|
||||
import org.jackhuang.hmcl.ui.construct.LineTextPane;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class WorldExportPageSkin extends SkinBase<WorldExportPage> {
|
||||
|
||||
public WorldExportPageSkin(WorldExportPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
VBox container = new VBox();
|
||||
container.setSpacing(16);
|
||||
container.setAlignment(Pos.CENTER);
|
||||
FXUtils.setLimitWidth(container, 500);
|
||||
|
||||
ComponentList list = new ComponentList();
|
||||
|
||||
var chooseFileButton = new LineFileChooserButton();
|
||||
chooseFileButton.setTitle(i18n("world.export.location"));
|
||||
chooseFileButton.setType(LineFileChooserButton.Type.SAVE_FILE);
|
||||
chooseFileButton.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
|
||||
chooseFileButton.locationProperty().bindBidirectional(skinnable.pathProperty());
|
||||
|
||||
var worldNamePane = new LinePane();
|
||||
worldNamePane.setTitle(i18n("world.name"));
|
||||
JFXTextField txtWorldName = new JFXTextField();
|
||||
txtWorldName.textProperty().bindBidirectional(skinnable.worldNameProperty());
|
||||
txtWorldName.setPrefWidth(300);
|
||||
worldNamePane.setRight(txtWorldName);
|
||||
|
||||
LineTextPane gameVersionPane = new LineTextPane();
|
||||
gameVersionPane.setTitle(i18n("world.game_version"));
|
||||
gameVersionPane.textProperty().bind(skinnable.gameVersionProperty());
|
||||
|
||||
list.getContent().setAll(chooseFileButton, worldNamePane, gameVersionPane);
|
||||
|
||||
container.getChildren().setAll(
|
||||
ComponentList.createComponentListTitle(i18n("world.export")),
|
||||
list
|
||||
);
|
||||
|
||||
JFXButton btnExport = FXUtils.newRaisedButton(i18n("button.export"));
|
||||
btnExport.disableProperty().bind(Bindings.createBooleanBinding(() -> txtWorldName.getText().isEmpty() || Files.exists(Paths.get(chooseFileButton.getLocation())),
|
||||
txtWorldName.textProperty().isEmpty(), chooseFileButton.locationProperty()));
|
||||
btnExport.setOnAction(e -> skinnable.export());
|
||||
HBox bottom = new HBox();
|
||||
bottom.setAlignment(Pos.CENTER_RIGHT);
|
||||
bottom.getChildren().setAll(btnExport);
|
||||
container.getChildren().add(bottom);
|
||||
|
||||
getChildren().setAll(container);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,670 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 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.JFXTextField;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.effect.BoxBlur;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.glavo.nbt.tag.*;
|
||||
import org.glavo.nbt.tag.Tag;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.DecimalFormat;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable {
|
||||
private final WorldManagePage worldManagePage;
|
||||
private boolean isReadOnly;
|
||||
private final World world;
|
||||
private CompoundTag levelData;
|
||||
private CompoundTag playerData;
|
||||
|
||||
private final ImageContainer iconImageView = new ImageContainer(32);
|
||||
|
||||
public WorldInfoPage(WorldManagePage worldManagePage) {
|
||||
this.worldManagePage = worldManagePage;
|
||||
this.world = worldManagePage.getWorld();
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void updateControls() {
|
||||
CompoundTag dataTag = (CompoundTag) levelData.get("Data");
|
||||
CompoundTag playerTag = playerData;
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setFitToHeight(true);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
|
||||
setContent(scrollPane);
|
||||
|
||||
VBox rootPane = new VBox();
|
||||
rootPane.setFillWidth(true);
|
||||
scrollPane.setContent(rootPane);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
rootPane.getStyleClass().add("card-list");
|
||||
|
||||
ComponentList worldInfo = new ComponentList();
|
||||
{
|
||||
var worldNamePane = new LinePane();
|
||||
{
|
||||
worldNamePane.setTitle(i18n("world.name"));
|
||||
JFXTextField worldNameField = new JFXTextField();
|
||||
setRightTextField(worldNamePane, worldNameField, 200);
|
||||
|
||||
if (dataTag.get("LevelName") instanceof StringTag worldNameTag) {
|
||||
var worldName = new SimpleStringProperty(worldNameTag.get());
|
||||
FXUtils.bindString(worldNameField, worldName);
|
||||
worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName);
|
||||
worldName.addListener((observable, oldValue, newValue) -> {
|
||||
if (StringUtils.isNotBlank(newValue)) {
|
||||
try {
|
||||
world.setWorldName(newValue);
|
||||
worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to set world name", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
worldNameField.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
var gameVersionPane = new LineTextPane();
|
||||
{
|
||||
gameVersionPane.setTitle(i18n("world.info.game_version"));
|
||||
gameVersionPane.setText(world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString());
|
||||
}
|
||||
|
||||
var iconPane = new LinePane();
|
||||
{
|
||||
iconPane.setTitle(i18n("world.icon"));
|
||||
|
||||
{
|
||||
iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
|
||||
}
|
||||
|
||||
JFXButton editIconButton = FXUtils.newToggleButton4(SVG.EDIT, 20);
|
||||
JFXButton resetIconButton = FXUtils.newToggleButton4(SVG.RESTORE, 20);
|
||||
{
|
||||
editIconButton.setDisable(isReadOnly);
|
||||
editIconButton.setOnAction(event -> Controllers.confirm(
|
||||
I18n.i18n("world.icon.change.tip"),
|
||||
I18n.i18n("world.icon.change"),
|
||||
MessageDialogPane.MessageType.INFO,
|
||||
this::changeWorldIcon,
|
||||
null
|
||||
));
|
||||
FXUtils.installFastTooltip(editIconButton, i18n("button.edit"));
|
||||
|
||||
resetIconButton.setDisable(isReadOnly);
|
||||
resetIconButton.setOnAction(event -> this.clearWorldIcon());
|
||||
FXUtils.installFastTooltip(resetIconButton, i18n("button.reset"));
|
||||
}
|
||||
|
||||
HBox hBox = new HBox(8);
|
||||
hBox.setAlignment(Pos.CENTER_LEFT);
|
||||
hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton);
|
||||
|
||||
iconPane.setRight(hBox);
|
||||
}
|
||||
|
||||
var seedPane = new LinePane();
|
||||
{
|
||||
seedPane.setTitle(i18n("world.info.random_seed"));
|
||||
|
||||
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
|
||||
JFXButton visibilityButton = FXUtils.newToggleButton4(SVG.VISIBILITY_OFF);
|
||||
{
|
||||
visibilityButton.setAlignment(Pos.CENTER);
|
||||
visibilityButton.setOnAction((event) -> visibility.set(!visibility.get()));
|
||||
}
|
||||
|
||||
Label seedLabel = new Label();
|
||||
{
|
||||
FXUtils.copyOnDoubleClick(seedLabel);
|
||||
seedLabel.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : "");
|
||||
|
||||
BoxBlur blur = new BoxBlur();
|
||||
blur.setIterations(3);
|
||||
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
|
||||
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
|
||||
visibilityButton.setGraphic(icon.createIcon(16));
|
||||
seedLabel.setEffect(isVisibility ? null : blur);
|
||||
});
|
||||
}
|
||||
|
||||
HBox right = new HBox(8);
|
||||
{
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
right.getChildren().setAll(visibilityButton, seedLabel);
|
||||
seedPane.setRight(right);
|
||||
}
|
||||
}
|
||||
|
||||
var worldSpawnPoint = new LineTextPane();
|
||||
{
|
||||
worldSpawnPoint.setTitle(i18n("world.info.spawn"));
|
||||
|
||||
String value;
|
||||
// Valid after 1.21.9-pre1
|
||||
if (dataTag.get("spawn") instanceof CompoundTag spawnTag && spawnTag.get("pos") instanceof IntArrayTag posTag) {
|
||||
value = (spawnTag.get("dimension") instanceof StringTag dimensionTag)
|
||||
? Dimension.of(dimensionTag).formatPosition(posTag)
|
||||
: Dimension.OVERWORLD.formatPosition(posTag);
|
||||
}
|
||||
// Valid before 1.21.9-pre1
|
||||
else if (dataTag.get("SpawnX") instanceof IntTag intX
|
||||
&& dataTag.get("SpawnY") instanceof IntTag intY
|
||||
&& dataTag.get("SpawnZ") instanceof IntTag intZ) {
|
||||
value = Dimension.OVERWORLD.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
|
||||
} else {
|
||||
value = null;
|
||||
}
|
||||
|
||||
worldSpawnPoint.setText(value);
|
||||
}
|
||||
|
||||
var lastPlayedPane = new LineTextPane();
|
||||
{
|
||||
lastPlayedPane.setTitle(i18n("world.info.last_played"));
|
||||
lastPlayedPane.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
|
||||
}
|
||||
|
||||
var timePane = new LineTextPane();
|
||||
{
|
||||
timePane.setTitle(i18n("world.info.time"));
|
||||
if (dataTag.get("Time") instanceof LongTag timeTag) {
|
||||
Duration duration = Duration.ofSeconds(timeTag.getValue() / 20);
|
||||
timePane.setText(i18n("world.info.time.format", duration.toDays(), duration.toHoursPart(), duration.toMinutesPart()));
|
||||
}
|
||||
}
|
||||
|
||||
var allowCheatsButton = new LineSwitch();
|
||||
{
|
||||
allowCheatsButton.setTitle(i18n("world.info.allow_cheats"));
|
||||
allowCheatsButton.setDisable(isReadOnly);
|
||||
|
||||
bindTagAndSwitch(dataTag.get("allowCommands"), allowCheatsButton);
|
||||
}
|
||||
|
||||
var generateFeaturesButton = new LineSwitch();
|
||||
{
|
||||
generateFeaturesButton.setTitle(i18n("world.info.generate_features"));
|
||||
generateFeaturesButton.setDisable(isReadOnly);
|
||||
// Valid before (1.16)20w20a
|
||||
if (dataTag.get("MapFeatures") instanceof ByteTag generateFeaturesTag) {
|
||||
bindTagAndSwitch(generateFeaturesTag, generateFeaturesButton);
|
||||
}
|
||||
// Valid after (1.16)20w20a
|
||||
else if (world.getNormalizedWorldGenSettingsData() != null) {
|
||||
CompoundTag unifiedWorldGenSettingsDataTag = world.getNormalizedWorldGenSettingsData();
|
||||
Tag tag = unifiedWorldGenSettingsDataTag.get("generate_features"); // Valid between (1.16)20w20a and 26.1-snapshot-6
|
||||
if (tag == null)
|
||||
tag = unifiedWorldGenSettingsDataTag.get("generate_structures"); // Valid after 26.1-snapshot-6
|
||||
bindTagAndSwitch(tag, generateFeaturesButton);
|
||||
} else {
|
||||
generateFeaturesButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
var difficultyButton = new LineSelectButton<Difficulty>();
|
||||
{
|
||||
difficultyButton.setTitle(i18n("world.info.difficulty"));
|
||||
difficultyButton.setDisable(isReadOnly);
|
||||
difficultyButton.setItems(Difficulty.items);
|
||||
|
||||
Difficulty difficulty;
|
||||
// Valid before 26.1-snapshot-6
|
||||
if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag
|
||||
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
|
||||
difficultyButton.setValue(difficulty);
|
||||
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
difficultyTag.setValue((byte) newValue.ordinal());
|
||||
saveWorldData();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Valid after 26.1-snapshot-6
|
||||
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
|
||||
&& difficultySettingTag.get("difficulty") instanceof StringTag difficultyTag
|
||||
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
|
||||
difficultyButton.setValue(difficulty);
|
||||
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
difficultyTag.setValue(newValue.getTagStringValue());
|
||||
saveWorldData();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
difficultyButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
var difficultyLockPane = new LineSwitch();
|
||||
{
|
||||
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
|
||||
difficultyLockPane.setDisable(isReadOnly);
|
||||
// Valid before 26.1-snapshot-6
|
||||
if (dataTag.get("DifficultyLocked") instanceof ByteTag difficultyLockedTag) {
|
||||
bindTagAndSwitch(difficultyLockedTag, difficultyLockPane);
|
||||
}
|
||||
// Valid after 26.1-snapshot-6
|
||||
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
|
||||
&& difficultySettingTag.get("locked") instanceof ByteTag lockedTag) {
|
||||
bindTagAndSwitch(lockedTag, difficultyLockPane);
|
||||
} else {
|
||||
difficultyLockPane.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
worldInfo.getContent().setAll(
|
||||
worldNamePane, gameVersionPane, iconPane, seedPane, worldSpawnPoint, lastPlayedPane, timePane,
|
||||
allowCheatsButton, generateFeaturesButton, difficultyButton, difficultyLockPane);
|
||||
|
||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info")), worldInfo);
|
||||
}
|
||||
|
||||
if (playerTag != null) {
|
||||
ComponentList playerInfo = new ComponentList();
|
||||
|
||||
var locationPane = new LineTextPane();
|
||||
{
|
||||
locationPane.setTitle(i18n("world.info.player.location"));
|
||||
Dimension dimension = Dimension.of(playerTag.get("Dimension"));
|
||||
if (dimension != null && playerTag.get("Pos") instanceof ListTag<?> posTag) {
|
||||
locationPane.setText(dimension.formatPosition(posTag));
|
||||
}
|
||||
}
|
||||
|
||||
var lastDeathLocationPane = new LineTextPane();
|
||||
{
|
||||
lastDeathLocationPane.setTitle(i18n("world.info.player.last_death_location"));
|
||||
// Valid after 22w14a; prior to this version, the game did not record the last death location data.
|
||||
if (playerTag.get("LastDeathLocation") instanceof CompoundTag LastDeathLocationTag) {
|
||||
Dimension dimension = Dimension.of(LastDeathLocationTag.get("dimension"));
|
||||
if (dimension != null && LastDeathLocationTag.get("pos") instanceof IntArrayTag posTag) {
|
||||
lastDeathLocationPane.setText(dimension.formatPosition(posTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var spawnPane = new LineTextPane();
|
||||
{
|
||||
spawnPane.setTitle(i18n("world.info.player.spawn"));
|
||||
if (playerTag.get("respawn") instanceof CompoundTag respawnTag
|
||||
&& respawnTag.get("dimension") instanceof StringTag dimensionTag
|
||||
&& respawnTag.get("pos") instanceof IntArrayTag intArrayTag
|
||||
&& intArrayTag.size() >= 3) { // Valid after 25w07a
|
||||
spawnPane.setText(Dimension.of(dimensionTag).formatPosition(intArrayTag));
|
||||
} else if (playerTag.get("SpawnX") instanceof IntTag intX
|
||||
&& playerTag.get("SpawnY") instanceof IntTag intY
|
||||
&& playerTag.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a
|
||||
// SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld.
|
||||
spawnPane.setText((playerTag.get("SpawnDimension") instanceof StringTag dimensionTag ? Dimension.of(dimensionTag) : Dimension.OVERWORLD)
|
||||
.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
var playerGameTypePane = new LineSelectButton<GameType>();
|
||||
{
|
||||
playerGameTypePane.setTitle(i18n("world.info.player.game_type"));
|
||||
playerGameTypePane.setDisable(worldManagePage.isReadOnly());
|
||||
playerGameTypePane.setItems(GameType.items);
|
||||
|
||||
// Valid before 26.1-snapshot-6
|
||||
Tag rawHardcoreTag = dataTag.get("hardcore");
|
||||
// Valid after 26.1-snapshot-6
|
||||
if (rawHardcoreTag == null && dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingsTag) {
|
||||
rawHardcoreTag = difficultySettingsTag.get("hardcore");
|
||||
}
|
||||
|
||||
if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag && rawHardcoreTag instanceof ByteTag hardcoreTag) {
|
||||
boolean isHardcore = hardcoreTag.getValue() == 1;
|
||||
GameType gameType = GameType.of(playerGameTypeTag.getValue(), isHardcore);
|
||||
if (gameType != null) {
|
||||
playerGameTypePane.setValue(gameType);
|
||||
playerGameTypePane.valueProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
if (newValue == GameType.HARDCORE) {
|
||||
playerGameTypeTag.setValue(0); // survival (hardcore worlds are survival+hardcore flag)
|
||||
hardcoreTag.setValue((byte) 1);
|
||||
} else {
|
||||
playerGameTypeTag.setValue(newValue.ordinal());
|
||||
hardcoreTag.setValue((byte) 0);
|
||||
}
|
||||
saveWorldData();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
playerGameTypePane.setDisable(true);
|
||||
}
|
||||
} else {
|
||||
playerGameTypePane.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
var healthPane = new LinePane();
|
||||
{
|
||||
healthPane.setTitle(i18n("world.info.player.health"));
|
||||
setRightTextField(healthPane, 50, playerTag.get("Health"));
|
||||
}
|
||||
|
||||
var foodLevelPane = new LinePane();
|
||||
{
|
||||
foodLevelPane.setTitle(i18n("world.info.player.food_level"));
|
||||
setRightTextField(foodLevelPane, 50, playerTag.get("foodLevel"));
|
||||
}
|
||||
|
||||
var foodSaturationPane = new LinePane();
|
||||
{
|
||||
foodSaturationPane.setTitle(i18n("world.info.player.food_saturation_level"));
|
||||
setRightTextField(foodSaturationPane, 50, playerTag.get("foodSaturationLevel"));
|
||||
}
|
||||
|
||||
var xpLevelPane = new LinePane();
|
||||
{
|
||||
xpLevelPane.setTitle(i18n("world.info.player.xp_level"));
|
||||
setRightTextField(xpLevelPane, 50, playerTag.get("XpLevel"));
|
||||
}
|
||||
|
||||
playerInfo.getContent().setAll(
|
||||
locationPane, lastDeathLocationPane, spawnPane, playerGameTypePane,
|
||||
healthPane, foodLevelPane, foodSaturationPane, xpLevelPane
|
||||
);
|
||||
|
||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.player")), playerInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private void setRightTextField(LinePane linePane, int perfWidth, Tag tag) {
|
||||
JFXTextField textField = new JFXTextField();
|
||||
setRightTextField(linePane, textField, perfWidth);
|
||||
if (tag instanceof IntTag intTag) {
|
||||
bindTagAndTextField(intTag, textField);
|
||||
} else if (tag instanceof FloatTag floatTag) {
|
||||
bindTagAndTextField(floatTag, textField);
|
||||
} else {
|
||||
textField.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setRightTextField(LinePane linePane, JFXTextField textField, int perfWidth) {
|
||||
textField.setDisable(isReadOnly);
|
||||
textField.setPrefWidth(perfWidth);
|
||||
linePane.setRight(textField);
|
||||
}
|
||||
|
||||
/// Saves boolean byte-tag edits through the switch, disabling it for missing or invalid tags.
|
||||
private void bindTagAndSwitch(@Nullable Tag tag, LineSwitch switchControl) {
|
||||
if (tag instanceof ByteTag byteTag) {
|
||||
byte value = byteTag.getValue();
|
||||
if (value == 0 || value == 1) {
|
||||
switchControl.setSelected(value == 1);
|
||||
switchControl.selectedProperty().addListener((o, oldValue, newValue) -> {
|
||||
try {
|
||||
byteTag.setValue((byte) (newValue ? 1 : 0));
|
||||
saveWorldData();
|
||||
} catch (Exception e) {
|
||||
switchControl.setSelected(oldValue);
|
||||
LOG.warning("Exception happened when saving world info", e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
switchControl.setDisable(true);
|
||||
}
|
||||
} else {
|
||||
switchControl.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void bindTagAndTextField(IntTag intTag, JFXTextField jfxTextField) {
|
||||
jfxTextField.setText(intTag.getValue().toString());
|
||||
|
||||
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
Integer integer = Lang.toIntOrNull(newValue);
|
||||
if (integer != null) {
|
||||
intTag.setValue(integer);
|
||||
saveWorldData();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
jfxTextField.setText(oldValue);
|
||||
LOG.warning("Exception happened when saving world data", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
|
||||
jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true));
|
||||
}
|
||||
|
||||
private void bindTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) {
|
||||
jfxTextField.setText(new DecimalFormat("0.#").format(floatTag.getValue()));
|
||||
|
||||
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
Float floatValue = Lang.toFloatOrNull(newValue);
|
||||
if (floatValue != null) {
|
||||
floatTag.setValue(floatValue);
|
||||
saveWorldData();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
jfxTextField.setText(oldValue);
|
||||
LOG.warning("Exception happened when saving world data", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
|
||||
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
|
||||
}
|
||||
|
||||
private void saveWorldData() {
|
||||
LOG.info("Saving data of world " + world.getWorldName());
|
||||
try {
|
||||
this.world.writeWorldData();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save world data", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refresh() {
|
||||
setFailedReason(null);
|
||||
try {
|
||||
this.isReadOnly = worldManagePage.isReadOnly();
|
||||
this.levelData = world.getLevelData();
|
||||
this.playerData = world.getPlayerData();
|
||||
updateControls();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to refresh world info", e);
|
||||
setFailedReason(i18n("world.info.failed"));
|
||||
}
|
||||
}
|
||||
|
||||
private record Dimension(String name) {
|
||||
static final Dimension OVERWORLD = new Dimension(null);
|
||||
static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether"));
|
||||
static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end"));
|
||||
|
||||
static Dimension of(Tag tag) {
|
||||
if (tag instanceof IntTag intTag) {
|
||||
return switch (intTag.get()) {
|
||||
case 0 -> OVERWORLD;
|
||||
case -1 -> THE_NETHER;
|
||||
case 1 -> THE_END;
|
||||
default -> null;
|
||||
};
|
||||
} else if (tag instanceof StringTag stringTag) {
|
||||
String id = stringTag.getValue();
|
||||
return switch (id) {
|
||||
case "overworld", "minecraft:overworld" -> OVERWORLD;
|
||||
case "the_nether", "minecraft:the_nether" -> THE_NETHER;
|
||||
case "the_end", "minecraft:the_end" -> THE_END;
|
||||
default -> new Dimension(id);
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String formatPosition(Tag tag) {
|
||||
if (tag instanceof ListTag<?> listTag && listTag.size() == 3) {
|
||||
|
||||
if (listTag.getTag(0) instanceof DoubleTag x
|
||||
&& listTag.getTag(1) instanceof DoubleTag y
|
||||
&& listTag.getTag(2) instanceof DoubleTag z) {
|
||||
return formatPosition(x.get(), y.get(), z.get());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tag instanceof IntArrayTag intArrayTag) {
|
||||
int x = intArrayTag.get(0);
|
||||
int y = intArrayTag.get(1);
|
||||
int z = intArrayTag.get(2);
|
||||
|
||||
return formatPosition(x, y, z);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String formatPosition(int x, int y, int z) {
|
||||
return this == OVERWORLD
|
||||
? "(%d, %d, %d)".formatted(x, y, z)
|
||||
: "%s (%d, %d, %d)".formatted(name, x, y, z);
|
||||
}
|
||||
|
||||
String formatPosition(double x, double y, double z) {
|
||||
return this == OVERWORLD
|
||||
? "(%.2f, %.2f, %.2f)".formatted(x, y, z)
|
||||
: "%s (%.2f, %.2f, %.2f)".formatted(name, x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
private enum Difficulty {
|
||||
PEACEFUL, EASY, NORMAL, HARD;
|
||||
|
||||
static final ObservableList<Difficulty> items = FXCollections.observableList(Arrays.asList(values()));
|
||||
|
||||
static Difficulty of(int d) {
|
||||
return (d >= 0 && d < items.size()) ? items.get(d) : null;
|
||||
}
|
||||
|
||||
static Difficulty of(String name) {
|
||||
for (Difficulty d : items) if (d.name().toLowerCase(Locale.ROOT).equals(name)) return d;
|
||||
return null;
|
||||
}
|
||||
|
||||
String getTagStringValue() {
|
||||
return this.name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return i18n("world.info.difficulty." + name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private enum GameType {
|
||||
SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR, HARDCORE;
|
||||
|
||||
static final ObservableList<GameType> items = FXCollections.observableList(Arrays.asList(values()));
|
||||
|
||||
static GameType of(int d, boolean hardcore) {
|
||||
if (hardcore && d == 0) return HARDCORE; // hardcore + survival
|
||||
return d >= 0 && d < 4 ? items.get(d) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private void changeWorldIcon() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("world.icon.choose.title"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
|
||||
fileChooser.setInitialFileName("icon.png");
|
||||
|
||||
Path iconPath = Controllers.showOpenDialog(fileChooser);
|
||||
if (iconPath == null) return;
|
||||
|
||||
Image image;
|
||||
try {
|
||||
image = FXUtils.loadImage(iconPath);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load image", e);
|
||||
Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR);
|
||||
return;
|
||||
}
|
||||
if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) {
|
||||
Path output = world.getFile().resolve("icon.png");
|
||||
saveWorldIcon(iconPath, image, output);
|
||||
} else {
|
||||
Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) {
|
||||
Image oldImage = iconImageView.getImage();
|
||||
try {
|
||||
FileUtils.copyFile(sourcePath, targetPath);
|
||||
iconImageView.setImage(image);
|
||||
Controllers.showToast(i18n("world.icon.change.succeed.toast"));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save world icon " + e.getMessage());
|
||||
iconImageView.setImage(oldImage);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearWorldIcon() {
|
||||
Path output = world.getFile().resolve("icon.png");
|
||||
try {
|
||||
Files.deleteIfExists(output);
|
||||
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete world icon ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,426 +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.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
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.ChunkBaseApp;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class WorldListPage extends ListPageBase<World> {
|
||||
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private Path savesDir;
|
||||
private List<World> worlds;
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
|
||||
|
||||
private int refreshCount = 0;
|
||||
|
||||
/// Creates a world list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public WorldListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
|
||||
installWorld(modpacks.get(0));
|
||||
});
|
||||
|
||||
showAll.addListener(e -> updateWorldList());
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<WorldListPage> createDefaultSkin() {
|
||||
return new WorldListPageSkin();
|
||||
}
|
||||
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.gameInstance = instance.instance();
|
||||
this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null;
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void updateWorldList() {
|
||||
if (worlds == null || gameInstance == null) {
|
||||
getItems().clear();
|
||||
} else if (showAll.get()) {
|
||||
getItems().setAll(worlds);
|
||||
} else {
|
||||
GameVersionNumber gameVersion = gameInstance.getVersion();
|
||||
getItems().setAll(worlds.stream()
|
||||
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (gameInstance == null || savesDir == null)
|
||||
return;
|
||||
|
||||
int currentRefresh = ++refreshCount;
|
||||
HMCLGameInstance gameInstance = this.gameInstance;
|
||||
|
||||
setLoading(true);
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
// Ensure the game version number is parsed
|
||||
gameInstance.getVersion();
|
||||
return World.getWorlds(savesDir);
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (refreshCount != currentRefresh) {
|
||||
// A newer refresh task is running, discard this result
|
||||
return;
|
||||
}
|
||||
|
||||
supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion()));
|
||||
|
||||
worlds = result;
|
||||
updateWorldList();
|
||||
|
||||
if (exception != null)
|
||||
LOG.warning("Failed to load world list page", exception);
|
||||
|
||||
setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void openSavesFolder() {
|
||||
if (gameInstance != null) {
|
||||
FXUtils.openFolder(gameInstance.getSavesDirectory());
|
||||
}
|
||||
}
|
||||
|
||||
public void add() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle(i18n("world.add.title"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.world"), "*.zip"));
|
||||
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
|
||||
|
||||
if (res == null || res.isEmpty()) return;
|
||||
installWorld(res.get(0));
|
||||
}
|
||||
|
||||
private void installWorld(Path zipFile) {
|
||||
// Only accept one world file because user is required to confirm the new world name
|
||||
// Or too many input dialogs are popped.
|
||||
Task.supplyAsync(() -> new World(zipFile))
|
||||
.whenComplete(Schedulers.javafx(), world -> {
|
||||
Controllers.prompt(i18n("world.name.enter"), (name, handler) -> {
|
||||
Task.runAsync(() -> world.install(savesDir, name))
|
||||
.whenComplete(Schedulers.javafx(), () -> {
|
||||
handler.resolve();
|
||||
refresh();
|
||||
}, e -> {
|
||||
if (e instanceof FileAlreadyExistsException)
|
||||
handler.reject(i18n("world.add.failed", i18n("world.add.already_exists")));
|
||||
else if (e instanceof IOException && e.getCause() instanceof InvalidPathException)
|
||||
handler.reject(i18n("world.add.failed", i18n("install.new_game.malformed")));
|
||||
else
|
||||
handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage()));
|
||||
}).start();
|
||||
}, world.getWorldName(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
|
||||
}, e -> {
|
||||
LOG.warning("Unable to parse world file " + zipFile, e);
|
||||
Controllers.dialog(i18n("world.add.invalid"));
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void showManagePage(World world) {
|
||||
if (gameInstance != null) {
|
||||
Controllers.navigate(new WorldManagePage(world, gameInstance));
|
||||
}
|
||||
}
|
||||
|
||||
public void export(World world) {
|
||||
WorldManageUIUtils.export(world);
|
||||
}
|
||||
|
||||
public void delete(World world) {
|
||||
WorldManageUIUtils.delete(world, this::refresh);
|
||||
}
|
||||
|
||||
public void copy(World world) {
|
||||
WorldManageUIUtils.copyWorld(world, this::refresh);
|
||||
}
|
||||
|
||||
public void reveal(World world) {
|
||||
FXUtils.openFolder(world.getFile());
|
||||
}
|
||||
|
||||
public void launch(World world) {
|
||||
if (gameInstance != null) {
|
||||
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
public void generateLaunchScript(World world) {
|
||||
if (gameInstance != null) {
|
||||
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
public BooleanProperty showAllProperty() {
|
||||
return showAll;
|
||||
}
|
||||
|
||||
public ReadOnlyBooleanProperty supportQuickPlayProperty() {
|
||||
return supportQuickPlay;
|
||||
}
|
||||
|
||||
private final class WorldListPageSkin extends ToolbarListPageSkin<World, WorldListPage> {
|
||||
|
||||
WorldListPageSkin() {
|
||||
super(WorldListPage.this);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("world.empty"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Node> initializeToolbar(WorldListPage skinnable) {
|
||||
JFXCheckBox chkShowAll = new JFXCheckBox(i18n("world.show_all"));
|
||||
chkShowAll.selectedProperty().bindBidirectional(skinnable.showAllProperty());
|
||||
|
||||
return Arrays.asList(
|
||||
chkShowAll,
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openSavesFolder)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ListCell<World> createListCell(JFXListView<World> listView) {
|
||||
return new WorldListCell(getSkinnable());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class WorldListCell extends ListCell<World> {
|
||||
|
||||
private final WorldListPage page;
|
||||
|
||||
private final RipplerContainer graphic;
|
||||
private final ImageContainer imageView;
|
||||
private final Tooltip leftTooltip;
|
||||
private final TwoLineListItem content;
|
||||
private final JFXButton btnLaunch;
|
||||
|
||||
/// Creates a world row with launch and management actions for the supplied page.
|
||||
public WorldListCell(WorldListPage page) {
|
||||
this.page = page;
|
||||
|
||||
var root = new BorderPane();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
|
||||
{
|
||||
StackPane left = new StackPane();
|
||||
this.leftTooltip = new Tooltip();
|
||||
FXUtils.installSlowTooltip(left, leftTooltip);
|
||||
root.setLeft(left);
|
||||
left.setPadding(new Insets(0, 8, 0, 0));
|
||||
|
||||
this.imageView = new ImageContainer(32);
|
||||
left.getChildren().add(imageView);
|
||||
}
|
||||
|
||||
{
|
||||
this.content = new TwoLineListItem();
|
||||
root.setCenter(content);
|
||||
}
|
||||
|
||||
{
|
||||
HBox right = new HBox(8);
|
||||
root.setRight(right);
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
btnLaunch = FXUtils.newToggleButton4(SVG.ROCKET_LAUNCH);
|
||||
btnLaunch.visibleProperty().bind(page.supportQuickPlayProperty());
|
||||
btnLaunch.managedProperty().bind(btnLaunch.visibleProperty());
|
||||
right.getChildren().add(btnLaunch);
|
||||
FXUtils.installFastTooltip(btnLaunch, i18n("instance.launch"));
|
||||
btnLaunch.setOnAction(event -> {
|
||||
World world = getItem();
|
||||
if (world != null)
|
||||
page.launch(world);
|
||||
});
|
||||
|
||||
JFXButton btnMore = FXUtils.newToggleButton4(SVG.MORE_VERT);
|
||||
right.getChildren().add(btnMore);
|
||||
btnMore.setOnAction(event -> {
|
||||
World world = getItem();
|
||||
if (world != null)
|
||||
showPopupMenu(world, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight());
|
||||
});
|
||||
}
|
||||
|
||||
this.graphic = new RipplerContainer(root);
|
||||
graphic.setOnMouseClicked(event -> {
|
||||
if (event.getClickCount() != 1)
|
||||
return;
|
||||
|
||||
World world = getItem();
|
||||
if (world == null)
|
||||
return;
|
||||
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
page.showManagePage(world);
|
||||
else if (event.getButton() == MouseButton.SECONDARY)
|
||||
showPopupMenu(world, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates world details and replaces version and lock tags together.
|
||||
@Override
|
||||
protected void updateItem(@Nullable World world, boolean empty) {
|
||||
@Nullable World oldWorld = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(world, empty);
|
||||
|
||||
if (oldWorld == world && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.resetFeedback();
|
||||
|
||||
if (empty || world == null) {
|
||||
content.getTags().clear();
|
||||
setGraphic(null);
|
||||
imageView.setImage(null);
|
||||
leftTooltip.setText("");
|
||||
content.setTitle("");
|
||||
content.setSubtitle("");
|
||||
} else {
|
||||
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
|
||||
leftTooltip.setText(world.getFile().toString());
|
||||
content.setTitle(world.getWorldName() != null ? parseColorEscapes(world.getWorldName()) : "");
|
||||
|
||||
var tags = new ArrayList<Tag>(2);
|
||||
if (world.getGameVersion() != null)
|
||||
tags.add(new Tag(I18n.getDisplayVersion(world.getGameVersion())));
|
||||
if (world.isLocked()) {
|
||||
tags.add(new Tag(i18n("world.locked")));
|
||||
btnLaunch.setDisable(true);
|
||||
} else {
|
||||
btnLaunch.setDisable(false);
|
||||
}
|
||||
|
||||
content.getTags().setAll(tags);
|
||||
content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
|
||||
|
||||
setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
|
||||
// Popup Menu
|
||||
|
||||
public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
|
||||
boolean worldLocked = world.isLocked();
|
||||
|
||||
PopupMenu popupMenu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(popupMenu);
|
||||
|
||||
if (supportQuickPlay) {
|
||||
|
||||
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch_and_enter_world"), () -> page.launch(world), popup);
|
||||
launchItem.setDisable(worldLocked);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
launchItem,
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> page.generateLaunchScript(world), popup),
|
||||
new MenuSeparator()
|
||||
);
|
||||
}
|
||||
|
||||
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), () -> page.showManagePage(world), popup));
|
||||
|
||||
if (ChunkBaseApp.isSupported(world)) {
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
|
||||
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
|
||||
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
|
||||
);
|
||||
|
||||
if (ChunkBaseApp.supportEndCity(world)) {
|
||||
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup));
|
||||
}
|
||||
}
|
||||
|
||||
IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup);
|
||||
exportMenuItem.setDisable(worldLocked);
|
||||
|
||||
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> page.delete(world), popup);
|
||||
deleteMenuItem.setDisable(worldLocked);
|
||||
|
||||
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
|
||||
duplicateMenuItem.setDisable(worldLocked);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
exportMenuItem,
|
||||
deleteMenuItem,
|
||||
duplicateMenuItem
|
||||
);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup)
|
||||
);
|
||||
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup);
|
||||
popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,250 +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.JFXPopup;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.*;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
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.util.ChunkBaseApp;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Displays world information, backups, and management actions.
|
||||
public final class WorldManagePage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
|
||||
private final World world;
|
||||
private final Path backupsDir;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final boolean supportQuickPlay;
|
||||
/// The active world lock, or null when the world is read-only.
|
||||
private @Nullable FileChannel sessionLockChannel;
|
||||
|
||||
private final ObjectProperty<State> state;
|
||||
private boolean isFirstNavigation = true;
|
||||
private final BooleanProperty refreshable = new SimpleBooleanProperty(true);
|
||||
private final BooleanProperty readOnly = new SimpleBooleanProperty(false);
|
||||
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
/// Manages tab selection and content transitions independently of the sidebar.
|
||||
private final TabController tabController = new TabController(transitionPane);
|
||||
/// The tab displaying information about this world.
|
||||
private final TabItem<WorldInfoPage> worldInfoTab;
|
||||
/// The tab for managing backups of this world.
|
||||
private final TabItem<WorldBackupsPage> worldBackupsTab;
|
||||
/// The tab for managing data packs in this world.
|
||||
private final TabItem<DataPackListPage> dataPackTab;
|
||||
|
||||
/// Loads world metadata and creates its tabs and sidebar actions.
|
||||
public WorldManagePage(World world, HMCLGameInstance gameInstance) {
|
||||
this.world = world;
|
||||
this.gameInstance = gameInstance;
|
||||
this.backupsDir = gameInstance.getBackupsDirectory();
|
||||
|
||||
updateSessionLockChannel();
|
||||
|
||||
try {
|
||||
this.world.reloadWorldData();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Can not load world data of world: " + this.world.getFile(), e);
|
||||
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail());
|
||||
}
|
||||
|
||||
this.worldInfoTab = new TabItem<>(() -> new WorldInfoPage(this));
|
||||
this.worldBackupsTab = new TabItem<>(() -> new WorldBackupsPage(this));
|
||||
this.dataPackTab = new TabItem<>(() -> new DataPackListPage(this));
|
||||
|
||||
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
|
||||
|
||||
supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion());
|
||||
|
||||
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
|
||||
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
|
||||
|
||||
setContent(transitionPane);
|
||||
initializeSidebar();
|
||||
setSidebarBottom(createToolBar());
|
||||
}
|
||||
|
||||
/// Reloads world metadata and refreshes only tab pages whose content is already initialized.
|
||||
@Override
|
||||
public void refresh() {
|
||||
updateSessionLockChannel();
|
||||
try {
|
||||
world.reloadWorldData();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Can not load world data of world: " + world.getFile(), e);
|
||||
closePageForLoadingFail();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var tab : tabController.getTabs()) {
|
||||
if (tab.isInitialized() && tab.getNode() instanceof WorldRefreshable r) {
|
||||
r.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closePageForLoadingFail() {
|
||||
Platform.runLater(() -> {
|
||||
fireEvent(new PageCloseEvent());
|
||||
Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateSessionLockChannel() {
|
||||
if (sessionLockChannel == null || !sessionLockChannel.isOpen()) {
|
||||
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
|
||||
readOnly.set(sessionLockChannel == null);
|
||||
}
|
||||
}
|
||||
|
||||
private void onNavigated(Navigator.NavigationEvent event) {
|
||||
if (isFirstNavigation)
|
||||
isFirstNavigation = false;
|
||||
else
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void onExited(Navigator.NavigationEvent event) {
|
||||
try {
|
||||
WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void launch() {
|
||||
fireEvent(new PageCloseEvent());
|
||||
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
|
||||
public void generateLaunchScript() {
|
||||
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.state.set(new DecoratorPage.State(title, null, true, true, true));
|
||||
}
|
||||
|
||||
public World getWorld() {
|
||||
return world;
|
||||
}
|
||||
|
||||
public Path getBackupsDir() {
|
||||
return backupsDir;
|
||||
}
|
||||
|
||||
public boolean isReadOnly() {
|
||||
return readOnly.get();
|
||||
}
|
||||
|
||||
public BooleanProperty readOnlyProperty() {
|
||||
return readOnly;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BooleanProperty refreshableProperty() {
|
||||
return refreshable;
|
||||
}
|
||||
|
||||
/// Registers supported world tabs and selects the information tab.
|
||||
private void initializeSidebar() {
|
||||
tabController.getTabs().addAll(worldInfoTab, worldBackupsTab);
|
||||
tabController.selectTab(worldInfoTab);
|
||||
|
||||
getSidebar().addNavigationDrawerTab(tabController, worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL)
|
||||
.addNavigationDrawerTab(tabController, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL);
|
||||
|
||||
if (world.supportDataPacks()) {
|
||||
tabController.getTabs().add(dataPackTab);
|
||||
getSidebar().addNavigationDrawerTab(tabController, dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates world actions and binds write operations to the lock state.
|
||||
private AdvancedListBox createToolBar() {
|
||||
AdvancedListBox toolbar = new AdvancedListBox();
|
||||
{
|
||||
if (supportQuickPlay) {
|
||||
toolbar.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> launch(), advancedListItem -> advancedListItem.disableProperty().bind(readOnlyProperty()));
|
||||
}
|
||||
|
||||
if (ChunkBaseApp.isSupported(world)) {
|
||||
PopupMenu chunkBasePopupMenu = new PopupMenu();
|
||||
JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu);
|
||||
|
||||
chunkBasePopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup),
|
||||
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup),
|
||||
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup)
|
||||
);
|
||||
|
||||
if (ChunkBaseApp.supportEndCity(world)) {
|
||||
chunkBasePopupMenu.getContent().add(
|
||||
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup));
|
||||
}
|
||||
|
||||
toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem ->
|
||||
chunkBaseMenuItem.setOnAction(e ->
|
||||
chunkBasePopup.show(chunkBaseMenuItem,
|
||||
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
|
||||
chunkBaseMenuItem.getWidth(), 0)));
|
||||
}
|
||||
|
||||
toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()));
|
||||
|
||||
{
|
||||
PopupMenu managePopupMenu = new PopupMenu();
|
||||
JFXPopup managePopup = new JFXPopup(managePopupMenu);
|
||||
|
||||
if (supportQuickPlay) {
|
||||
managePopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch"), () -> launch(), managePopup),
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> generateLaunchScript(), managePopup),
|
||||
new MenuSeparator()
|
||||
);
|
||||
}
|
||||
|
||||
managePopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup),
|
||||
new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup),
|
||||
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup)
|
||||
);
|
||||
|
||||
toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem ->
|
||||
{
|
||||
managePopupMenuItem.setOnAction(e ->
|
||||
managePopup.show(managePopupMenuItem,
|
||||
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
|
||||
managePopupMenuItem.getWidth(), 0));
|
||||
managePopupMenuItem.disableProperty().bind(readOnlyProperty());
|
||||
});
|
||||
}
|
||||
}
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
public interface WorldRefreshable {
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
@@ -1,127 +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 javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.game.WorldLockedException;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
|
||||
import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class WorldManageUIUtils {
|
||||
private WorldManageUIUtils() {
|
||||
}
|
||||
|
||||
public static void delete(World world, Runnable runnable) {
|
||||
delete(world, runnable, null);
|
||||
}
|
||||
|
||||
public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) {
|
||||
Controllers.confirm(
|
||||
i18n("button.remove.confirm"),
|
||||
i18n("world.delete"),
|
||||
() -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel))
|
||||
.thenRunAsync(world::delete)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
runnable.run();
|
||||
} else if (exception instanceof WorldLockedException) {
|
||||
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
|
||||
} else {
|
||||
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
|
||||
}
|
||||
}).start(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static void export(World world) {
|
||||
export(world, null);
|
||||
}
|
||||
|
||||
public static void export(World world, FileChannel sessionLockChannel) {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("world.export.title"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
|
||||
fileChooser.setInitialFileName(world.getWorldName() + ".zip");
|
||||
Path file = Controllers.showSaveDialog(fileChooser);
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
closeSessionLockChannel(world, sessionLockChannel);
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
|
||||
}
|
||||
|
||||
public static void copyWorld(World world, Runnable runnable) {
|
||||
Path worldPath = world.getFile();
|
||||
Controllers.prompt(
|
||||
i18n("world.duplicate.prompt"),
|
||||
(result, handler) -> {
|
||||
Path targetDir = worldPath.resolveSibling(result);
|
||||
if (Files.exists(targetDir)) {
|
||||
handler.reject(i18n("world.duplicate.failed.already_exists"));
|
||||
return;
|
||||
}
|
||||
|
||||
Task.runAsync(Schedulers.io(), () -> world.copy(result))
|
||||
.thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast")))
|
||||
.thenAcceptAsync(Schedulers.javafx(), (Void) -> {
|
||||
if (runnable != null) {
|
||||
runnable.run();
|
||||
}
|
||||
}
|
||||
).whenComplete(Schedulers.javafx(), (throwable) -> {
|
||||
if (throwable == null) {
|
||||
handler.resolve();
|
||||
} else {
|
||||
handler.reject(i18n("world.duplicate.failed"));
|
||||
LOG.warning("Failed to duplicate world " + world.getFile(), throwable);
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}, "", new RequiredValidator(), new Validator(i18n("world.duplicate.failed.invalid_name"), FileUtils::isNameValid));
|
||||
}
|
||||
|
||||
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
|
||||
if (sessionLockChannel != null) {
|
||||
try {
|
||||
sessionLockChannel.close();
|
||||
LOG.info("Closed session lock channel of the world " + world.getFileName());
|
||||
} catch (IOException e) {
|
||||
throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static FileChannel getSessionLockChannel(World world) {
|
||||
try {
|
||||
FileChannel lock = world.lock();
|
||||
LOG.info("Acquired lock on world " + world.getFileName());
|
||||
return lock;
|
||||
} catch (WorldLockedException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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 javafx.animation.Interpolator;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.TranslateTransition;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.scene.shape.Polygon;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.Shape;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Builds the line illustration on the home page: a floating block island with a tree and a blocky figure.
|
||||
///
|
||||
/// All shapes are unfilled strokes whose colors come from the `.home-art` style classes, so the drawing
|
||||
/// follows the launcher palette. The island drifts slowly up and down unless animations are disabled.
|
||||
@NotNullByDefault
|
||||
final class HomeArt {
|
||||
private HomeArt() {
|
||||
}
|
||||
|
||||
/// Creates the illustration, sized 290 by 250 pixels.
|
||||
///
|
||||
/// @return the illustration root node
|
||||
static Node create() {
|
||||
Group island = new Group(
|
||||
// Stepped underside of the island, then the grass line along its top.
|
||||
stroke(new Polygon(30, 150, 250, 150, 250, 166, 232, 166, 232, 184, 212, 184, 212, 204,
|
||||
176, 204, 176, 222, 104, 222, 104, 204, 68, 204, 68, 184, 48, 184, 48, 166, 30, 166), "home-art-line"),
|
||||
stroke(new Line(30, 158, 250, 158), "home-art-line"),
|
||||
// Tree: trunk and three stacked leaf blocks.
|
||||
stroke(rect(186, 96, 8, 54), "home-art-line"),
|
||||
stroke(rect(162, 70, 56, 26), "home-art-line"),
|
||||
stroke(rect(170, 50, 40, 20), "home-art-line"),
|
||||
stroke(rect(178, 34, 24, 16), "home-art-line"),
|
||||
// Generic blocky figure: legs, torso, arms and head.
|
||||
stroke(rect(88, 126, 10, 24), "home-art-figure"),
|
||||
stroke(rect(102, 126, 10, 24), "home-art-figure"),
|
||||
stroke(rect(88, 92, 24, 34), "home-art-figure"),
|
||||
stroke(rect(76, 92, 12, 34), "home-art-figure"),
|
||||
stroke(rect(112, 92, 12, 34), "home-art-figure"),
|
||||
stroke(rect(88, 68, 24, 24), "home-art-figure"));
|
||||
|
||||
Pane root = new Pane(
|
||||
stroke(new Line(20, 30, 54, 30), "home-art-line"),
|
||||
stroke(new Line(30, 38, 52, 38), "home-art-line"),
|
||||
stroke(new Line(200, 14, 240, 14), "home-art-line"),
|
||||
stroke(new Line(210, 22, 234, 22), "home-art-line"),
|
||||
stroke(rect(22, 62, 14, 14), "home-art-line"),
|
||||
stroke(rect(42, 100, 10, 10), "home-art-line"),
|
||||
stroke(rect(238, 108, 10, 10), "home-art-line"),
|
||||
island);
|
||||
root.setPrefSize(290, 250);
|
||||
root.setMaxSize(290, 250);
|
||||
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
TranslateTransition bob = new TranslateTransition(Duration.seconds(4), island);
|
||||
bob.setFromY(0);
|
||||
bob.setToY(-5);
|
||||
bob.setAutoReverse(true);
|
||||
bob.setCycleCount(Timeline.INDEFINITE);
|
||||
bob.setInterpolator(Interpolator.EASE_BOTH);
|
||||
bob.play();
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/// Creates an unfilled rectangle.
|
||||
private static Rectangle rect(double x, double y, double width, double height) {
|
||||
return new Rectangle(x, y, width, height);
|
||||
}
|
||||
|
||||
/// Makes a shape an unfilled outline styled by `styleClass`.
|
||||
private static <T extends Shape> T stroke(T shape, String styleClass) {
|
||||
shape.setFill(null);
|
||||
shape.getStyleClass().addAll("home-art", styleClass);
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,12 @@ package org.jackhuang.hmcl.ui.main;
|
||||
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
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.game.GameSettingsPage;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
@@ -23,14 +17,10 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
|
||||
/// Manages tab selection and content transitions independently of the sidebar.
|
||||
private final TabController tabController;
|
||||
/// The tab for editing game settings presets.
|
||||
private final TabItem<GameSettingsPage<GameSettings.Preset>> gameTab;
|
||||
/// The tab whose content manages installed Java runtimes.
|
||||
private final TabItem<JavaManagementPage> javaManagementTab;
|
||||
/// The tab for general launcher settings.
|
||||
private final TabItem<SettingsPage> settingsTab;
|
||||
/// The tab for launcher appearance settings.
|
||||
private final TabItem<PersonalizationPage> personalizationTab;
|
||||
/// The tab for download settings.
|
||||
private final TabItem<DownloadSettingsPage> downloadTab;
|
||||
/// The tab displaying launcher information.
|
||||
@@ -39,31 +29,22 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
|
||||
|
||||
/// Creates the settings tabs and fills the shared navigation sidebar.
|
||||
public LauncherSettingsPage() {
|
||||
gameTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Preset.class, null));
|
||||
javaManagementTab = new TabItem<>(JavaManagementPage::new);
|
||||
settingsTab = new TabItem<>(SettingsPage::new);
|
||||
personalizationTab = new TabItem<>(PersonalizationPage::new);
|
||||
downloadTab = new TabItem<>(DownloadSettingsPage::new);
|
||||
aboutTab = new TabItem<>(AboutPage::new);
|
||||
tabController = new TabController(transitionPane,
|
||||
gameTab,
|
||||
javaManagementTab,
|
||||
settingsTab,
|
||||
personalizationTab,
|
||||
downloadTab,
|
||||
aboutTab);
|
||||
|
||||
tabController.selectTab(gameTab);
|
||||
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())));
|
||||
tabController.selectTab(settingsTab);
|
||||
|
||||
getSidebar()
|
||||
.addNavigationDrawerTab(tabController, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL)
|
||||
.addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL)
|
||||
.startCategory(i18n("launcher").toUpperCase(Locale.ROOT))
|
||||
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
|
||||
.addNavigationDrawerTab(tabController, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL)
|
||||
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
|
||||
.startCategory(i18n("about").toUpperCase(Locale.ROOT))
|
||||
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
|
||||
|
||||
setContent(transitionPane);
|
||||
@@ -79,11 +60,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
|
||||
tabController.onPageHidden();
|
||||
}
|
||||
|
||||
public void showGameSettings(HMCLGameRepository repository) {
|
||||
gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
tabController.selectTab(gameTab, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state.getReadOnlyProperty();
|
||||
|
||||
@@ -4,41 +4,40 @@ package org.jackhuang.hmcl.ui.main;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.animation.RotateTransition;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.event.EventHandler;
|
||||
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.Tooltip;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
|
||||
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.ServerAddress;
|
||||
import org.jackhuang.hmcl.util.ServerStatusPing;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
@@ -59,14 +58,13 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
|
||||
.flatMap(HMCLGameRepository::snapshotProperty);
|
||||
|
||||
private final JFXButton menuButton;
|
||||
|
||||
{
|
||||
HBox titleNode = new HBox(8);
|
||||
titleNode.setPadding(new Insets(0, 0, 0, 2));
|
||||
titleNode.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
|
||||
Region titleIcon = new Region();
|
||||
titleIcon.getStyleClass().add("title-mark");
|
||||
Label titleLabel = new Label();
|
||||
titleLabel.textProperty().bind(Controllers.launcherTitleProperty());
|
||||
if (I18n.isUpsideDown()) {
|
||||
@@ -79,116 +77,146 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
state.setValue(new State(null, titleNode, false, false, true));
|
||||
|
||||
setPadding(new Insets(20));
|
||||
setPadding(new Insets(34, 55, 34, 55));
|
||||
|
||||
|
||||
|
||||
HBox launchPane = new HBox();
|
||||
launchPane.getStyleClass().add("launch-pane");
|
||||
FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList()));
|
||||
FXUtils.onScroll(launchPane, instances, list -> {
|
||||
@Nullable HMCLGameInstance currentGame = getCurrentGame();
|
||||
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, instance -> instance.getRepository().setSelectedInstance(instance));
|
||||
|
||||
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
|
||||
{
|
||||
JFXButton launchButton = new JFXButton();
|
||||
launchButton.getStyleClass().add("launch-button");
|
||||
launchButton.setDefaultButton(true);
|
||||
{
|
||||
VBox graphic = new VBox();
|
||||
graphic.setAlignment(Pos.CENTER);
|
||||
Label launchLabel = new Label();
|
||||
launchLabel.setStyle("-fx-font-size: 16px;");
|
||||
Label currentLabel = new Label();
|
||||
currentLabel.setStyle("-fx-font-size: 12px;");
|
||||
// Left column: greeting, headline and a short description.
|
||||
Label eyebrow = new Label(i18n("home.welcome"));
|
||||
eyebrow.getStyleClass().add("home-eyebrow");
|
||||
Label headline = new Label(i18n("home.title"));
|
||||
headline.getStyleClass().add("home-title");
|
||||
headline.setWrapText(true);
|
||||
Label subtitle = new Label(i18n("home.subtitle"));
|
||||
subtitle.getStyleClass().add("home-subtitle");
|
||||
subtitle.setWrapText(true);
|
||||
VBox hero = new VBox(8, eyebrow, headline, subtitle);
|
||||
hero.setPrefWidth(330);
|
||||
hero.setMaxWidth(330);
|
||||
headline.setPrefWidth(330);
|
||||
subtitle.setPrefWidth(330);
|
||||
hero.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
StackPane.setAlignment(hero, Pos.TOP_LEFT);
|
||||
hero.setPadding(new Insets(21, 0, 0, 0));
|
||||
|
||||
FXUtils.onChangeAndOperate(currentGameProperty(), new Consumer<>() {
|
||||
private Tooltip tooltip;
|
||||
// Line illustration on the right, behind the content.
|
||||
Node art = HomeArt.create();
|
||||
StackPane.setAlignment(art, Pos.TOP_RIGHT);
|
||||
art.setMouseTransparent(true);
|
||||
((Region) art).setMaxSize(290, 250);
|
||||
|
||||
@Override
|
||||
public void accept(@Nullable HMCLGameInstance currentGame) {
|
||||
if (currentGame == null) {
|
||||
launchLabel.setText(i18n("instance.launch.empty"));
|
||||
currentLabel.setText(null);
|
||||
graphic.getChildren().setAll(launchLabel);
|
||||
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launchNoGame);
|
||||
if (tooltip == null)
|
||||
tooltip = new Tooltip(i18n("instance.launch.empty.tooltip"));
|
||||
FXUtils.installFastTooltip(launchButton, tooltip);
|
||||
} else {
|
||||
launchLabel.setText(i18n("instance.launch"));
|
||||
currentLabel.setText(currentGame.getId().toString());
|
||||
graphic.getChildren().setAll(launchLabel, currentLabel);
|
||||
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
|
||||
if (tooltip != null)
|
||||
Tooltip.uninstall(launchButton, tooltip);
|
||||
}
|
||||
// Bottom card: what will be launched, and the join button.
|
||||
Label packLabel = factLabel(i18n("home.pack"));
|
||||
Label packValue = new Label();
|
||||
packValue.getStyleClass().add("home-fact-value");
|
||||
Label routeLabel = factLabel(i18n("home.route"));
|
||||
Label routeValue = new Label();
|
||||
routeValue.getStyleClass().add("home-fact-value");
|
||||
VBox packFact = new VBox(3, packLabel, packValue);
|
||||
VBox routeFact = new VBox(3, routeLabel, routeValue);
|
||||
Label serverLabel = factLabel(i18n("home.server"));
|
||||
Label serverValue = new Label();
|
||||
serverValue.getStyleClass().add("home-fact-value");
|
||||
VBox serverFact = new VBox(3, serverLabel, serverValue);
|
||||
HBox facts = new HBox(34, packFact, routeFact, serverFact);
|
||||
facts.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
JFXButton joinButton = new JFXButton();
|
||||
joinButton.getStyleClass().add("join-button");
|
||||
joinButton.setDefaultButton(true);
|
||||
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
HBox card = new HBox(21, facts, spacer, joinButton);
|
||||
card.getStyleClass().add("home-card");
|
||||
card.setAlignment(Pos.CENTER_LEFT);
|
||||
card.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
StackPane.setAlignment(card, Pos.BOTTOM_CENTER);
|
||||
|
||||
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
|
||||
if (currentGame == null) {
|
||||
joinButton.setText(i18n("instance.launch.empty"));
|
||||
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launchNoGame);
|
||||
packValue.setText(i18n("home.not_installed"));
|
||||
routeFact.setVisible(false);
|
||||
routeFact.setManaged(false);
|
||||
serverFact.setVisible(false);
|
||||
serverFact.setManaged(false);
|
||||
} else {
|
||||
joinButton.setText(i18n("server.join"));
|
||||
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
|
||||
packValue.setText(currentGame.getId().toString());
|
||||
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
|
||||
routeFact.setVisible(route != null);
|
||||
routeFact.setManaged(route != null);
|
||||
routeValue.setText(route != null ? route.name() : "");
|
||||
refreshServerStatus(route, serverFact, serverValue);
|
||||
|
||||
// With more than one route (for example direct and CDN), the route can be switched.
|
||||
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
|
||||
boolean switchable = routes.size() > 1;
|
||||
routeFact.setCursor(switchable ? Cursor.HAND : Cursor.DEFAULT);
|
||||
if (switchable && route != null) {
|
||||
routeValue.setText(route.name() + " ▾");
|
||||
}
|
||||
routeFact.setOnMouseClicked(switchable ? event -> {
|
||||
PopupMenu list = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(list);
|
||||
List<Node> items = new ArrayList<>();
|
||||
for (ServerEndpoint.Route candidate : routes) {
|
||||
items.add(new IconedMenuItem(null, candidate.name(), () -> {
|
||||
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
|
||||
routeValue.setText(candidate.name() + " ▾");
|
||||
refreshServerStatus(candidate, serverFact, serverValue);
|
||||
}, popup));
|
||||
}
|
||||
});
|
||||
|
||||
launchButton.setGraphic(graphic);
|
||||
list.getContent().setAll(items);
|
||||
popup.show(routeFact, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -routeFact.getHeight());
|
||||
} : null);
|
||||
}
|
||||
});
|
||||
|
||||
menuButton = new JFXButton();
|
||||
menuButton.getStyleClass().add("menu-button");
|
||||
menuButton.setOnAction(e -> {
|
||||
if (GameListPopupMenu.hideShowing(menuButton)) {
|
||||
return;
|
||||
}
|
||||
getChildren().addAll(art, hero, card);
|
||||
}
|
||||
|
||||
JFXPopup popup = GameListPopupMenu.showAndGetPopup(
|
||||
menuButton,
|
||||
JFXPopup.PopupVPosition.BOTTOM,
|
||||
JFXPopup.PopupHPosition.RIGHT,
|
||||
0,
|
||||
-menuButton.getHeight(),
|
||||
instances
|
||||
);
|
||||
|
||||
Node graphic = menuButton.getGraphic();
|
||||
if (graphic != null) {
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
Duration duration = Duration.millis(200);
|
||||
RotateTransition rotateOpen = new RotateTransition(duration, graphic);
|
||||
rotateOpen.setToAngle(-180);
|
||||
FXUtils.playAnimation(graphic, "arrow-rotation", rotateOpen);
|
||||
|
||||
popup.setOnHidden(windowEvent -> {
|
||||
RotateTransition rotateClose = new RotateTransition(duration, graphic);
|
||||
rotateClose.setToAngle(0);
|
||||
FXUtils.playAnimation(graphic, "arrow-rotation", rotateClose);
|
||||
});
|
||||
} else {
|
||||
graphic.setRotate(-180);
|
||||
popup.setOnHidden(windowEvent -> graphic.setRotate(0));
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.installFastTooltip(menuButton, i18n("instance.switch"));
|
||||
menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30));
|
||||
|
||||
EventHandler<MouseEvent> secondaryClickHandle = event -> {
|
||||
if (event.getButton() == MouseButton.SECONDARY && event.getClickCount() == 1) {
|
||||
menuButton.fire();
|
||||
event.consume();
|
||||
}
|
||||
};
|
||||
launchButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
|
||||
menuButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
|
||||
|
||||
launchPane.getChildren().setAll(launchButton, menuButton);
|
||||
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
|
||||
///
|
||||
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
|
||||
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
|
||||
String address = route == null ? "" : route.address().trim();
|
||||
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
|
||||
serverFact.setVisible(pingable);
|
||||
serverFact.setManaged(pingable);
|
||||
if (!pingable) {
|
||||
return;
|
||||
}
|
||||
serverValue.setText("…");
|
||||
Schedulers.io().execute(() -> {
|
||||
String text;
|
||||
try {
|
||||
ServerAddress parsed = ServerAddress.parse(address);
|
||||
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
|
||||
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
text = i18n("home.server.unreachable");
|
||||
}
|
||||
String result = text;
|
||||
FXUtils.runInFX(() -> serverValue.setText(result));
|
||||
});
|
||||
}
|
||||
|
||||
getChildren().addAll(launchPane);
|
||||
|
||||
/// Creates the small caption above a fact value in the home card.
|
||||
private static Label factLabel(String text) {
|
||||
Label label = new Label(text);
|
||||
label.getStyleClass().add("home-fact-label");
|
||||
return label;
|
||||
}
|
||||
|
||||
private void launch() {
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
Instances.launch(repository.getSelectedInstance());
|
||||
Instances.joinServer(repository.getSelectedInstance());
|
||||
}
|
||||
|
||||
/// Opens the server modpack install/update flow when no instance is selected.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,8 +4,6 @@ package org.jackhuang.hmcl.ui.main;
|
||||
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
@@ -21,14 +19,11 @@ 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.util.Lang;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
@@ -40,12 +35,12 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
/// Creates the home page and its navigation entries.
|
||||
public RootPage() {
|
||||
getStyleClass().remove("gray-background");
|
||||
getLeft().getStyleClass().add("gray-background");
|
||||
getLeft().getStyleClass().add("home-sidebar");
|
||||
|
||||
// first item in left sidebar
|
||||
AccountAdvancedListItem accountListItem = new AccountAdvancedListItem();
|
||||
accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage()));
|
||||
FXUtils.onSecondaryButtonClicked(accountListItem, () -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0));
|
||||
// Clicking the account opens a popup above it (the entry sits at the bottom-left): switch, add or manage.
|
||||
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
|
||||
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
|
||||
|
||||
// second item in left sidebar
|
||||
@@ -53,38 +48,19 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
gameInstanceItem.setOnAction(e -> {
|
||||
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
|
||||
if (instance == null) {
|
||||
Controllers.navigate(Controllers.getGameListPage());
|
||||
Instances.installServerModpack();
|
||||
} else {
|
||||
Instances.modifyGameSettings(instance);
|
||||
}
|
||||
});
|
||||
FXUtils.onScroll(gameInstanceItem, getMainPage().getInstances(), list -> {
|
||||
@Nullable HMCLGameInstance currentGame = getMainPage().getCurrentGame();
|
||||
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, instance -> instance.getRepository().setSelectedInstance(instance));
|
||||
FXUtils.onSecondaryButtonClicked(gameInstanceItem, () -> showGameListPopupMenu(gameInstanceItem));
|
||||
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
|
||||
}
|
||||
|
||||
// third item in left sidebar
|
||||
AdvancedListItem gameListItem = new AdvancedListItem();
|
||||
gameListItem.setLeftIcon(SVG.FORMAT_LIST_BULLETED);
|
||||
gameListItem.setTitle(i18n("instance.manage"));
|
||||
gameListItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage()));
|
||||
FXUtils.onSecondaryButtonClicked(gameListItem, () -> showGameListPopupMenu(gameListItem));
|
||||
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
gameListItem.setPrepareAction(Controllers::prepareGameListPage);
|
||||
}
|
||||
|
||||
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
|
||||
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
|
||||
launcherSettingsItem.setTitle(i18n("settings"));
|
||||
launcherSettingsItem.setOnAction(e -> {
|
||||
Controllers.getSettingsPage().showGameSettings(GameDirectoryManager.getSelectedRepository());
|
||||
Controllers.navigate(Controllers.getSettingsPage());
|
||||
});
|
||||
|
||||
@@ -92,15 +68,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
|
||||
}
|
||||
|
||||
// the left sidebar
|
||||
// The sidebar holds the modpack and settings entries; the account sits at the bottom-left.
|
||||
getSidebar()
|
||||
.startCategory(i18n("account").toUpperCase(Locale.ROOT))
|
||||
.add(accountListItem)
|
||||
.startCategory(i18n("instance").toUpperCase(Locale.ROOT))
|
||||
.add(gameInstanceItem)
|
||||
.add(gameListItem)
|
||||
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
|
||||
.add(launcherSettingsItem);
|
||||
setSidebarBottom(accountListItem);
|
||||
|
||||
// the root page, with the sidebar in left, navigator in center.
|
||||
setContent(getMainPage());
|
||||
@@ -133,14 +105,4 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
}
|
||||
return mainPage;
|
||||
}
|
||||
|
||||
/// Opens the instance selection menu beside the sidebar entry.
|
||||
private void showGameListPopupMenu(Region gameListItem) {
|
||||
GameListPopupMenu.show(gameListItem,
|
||||
JFXPopup.PopupVPosition.TOP,
|
||||
JFXPopup.PopupHPosition.LEFT,
|
||||
gameListItem.getWidth(),
|
||||
0,
|
||||
getMainPage().getInstances());
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
* {
|
||||
-monet-primary: #4352A5;
|
||||
-monet-on-primary: #FFFFFF;
|
||||
-monet-primary-container: #5C6BC0;
|
||||
-monet-on-primary-container: #F8F6FF;
|
||||
-monet-primary-fixed: #DEE0FF;
|
||||
-monet-primary-fixed-dim: #BAC3FF;
|
||||
-monet-on-primary-fixed: #00105B;
|
||||
-monet-on-primary-fixed-variant: #2F3F92;
|
||||
-monet-secondary: #575C7F;
|
||||
-monet-on-secondary: #FFFFFF;
|
||||
-monet-secondary-container: #D0D5FD;
|
||||
-monet-on-secondary-container: #565B7D;
|
||||
-monet-secondary-fixed: #DEE0FF;
|
||||
-monet-secondary-fixed-dim: #BFC4EC;
|
||||
-monet-on-secondary-fixed: #141938;
|
||||
-monet-on-secondary-fixed-variant: #3F4566;
|
||||
-monet-tertiary: #775200;
|
||||
-monet-on-tertiary: #FFFFFF;
|
||||
-monet-tertiary-container: #976900;
|
||||
-monet-on-tertiary-container: #FFF6EE;
|
||||
-monet-tertiary-fixed: #FFDEAC;
|
||||
-monet-tertiary-fixed-dim: #F6BD58;
|
||||
-monet-on-tertiary-fixed: #281900;
|
||||
-monet-on-tertiary-fixed-variant: #5F4100;
|
||||
-monet-error: #BA1A1A;
|
||||
-monet-on-error: #FFFFFF;
|
||||
-monet-error-container: #FFDAD6;
|
||||
-monet-on-error-container: #93000A;
|
||||
-monet-surface: #FBF8FF;
|
||||
-monet-on-surface: #1B1B21;
|
||||
-monet-surface-dim: #DBD9E1;
|
||||
-monet-surface-bright: #FBF8FF;
|
||||
-monet-surface-container-lowest: #FFFFFF;
|
||||
-monet-surface-container-low: #F5F2FA;
|
||||
-monet-surface-container: #EFEDF5;
|
||||
-monet-surface-container-high: #E9E7EF;
|
||||
-monet-surface-container-highest: #E3E1E9;
|
||||
-monet-surface-variant: #E2E1EF;
|
||||
-monet-on-surface-variant: #454651;
|
||||
-monet-background: #FBF8FF;
|
||||
-monet-on-background: #1B1B21;
|
||||
-monet-outline: #767683;
|
||||
-monet-outline-variant: #C6C5D3;
|
||||
-monet-shadow: #000000;
|
||||
-monet-scrim: #000000;
|
||||
-monet-inverse-surface: #303036;
|
||||
-monet-inverse-on-surface: #F2EFF7;
|
||||
-monet-inverse-primary: #BAC3FF;
|
||||
-monet-surface-tint: #4858AB;
|
||||
-monet-primary-seed: #5C6BC0;
|
||||
-monet-primary-transparent-50: #4352A580;
|
||||
-monet-primary-transparent-80: #4352A5CC;
|
||||
-monet-secondary-container-transparent-50: #D0D5FD80;
|
||||
-monet-surface-transparent-50: #FBF8FF80;
|
||||
-monet-surface-transparent-80: #FBF8FFCC;
|
||||
-monet-on-surface-variant-transparent-38: #45465161;
|
||||
-monet-surface-container-low-transparent-80: #F5F2FACC;
|
||||
-monet-secondary-container-transparent-80: #D0D5FDCC;
|
||||
-monet-inverse-surface-transparent-80: #303036CC;
|
||||
}
|
||||
@@ -2,7 +2,14 @@
|
||||
* SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
*/
|
||||
/* Body text uses the serif face; headings and controls use the sans face. Both are SIL OFL fonts. */
|
||||
.root {
|
||||
/* https://github.com/HMCL-dev/HMCL/pull/3423 */
|
||||
-fx-font-family: -fx-base-font-family;
|
||||
}
|
||||
-fx-font-family: "Noto Serif SC";
|
||||
}
|
||||
|
||||
.button, .jfx-button, .toggle-button, .jfx-toggle-button, .tab-label,
|
||||
.jfx-layout-heading, .jfx-decorator-title, .method-title, .advanced-list-item .title,
|
||||
.two-line-list-item .title, .announcement .title, .bold, .html-h3, .html-bold, .class-title,
|
||||
.navigation-drawer-item, .jfx-tool-bar .label {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
}
|
||||
@@ -460,7 +460,7 @@
|
||||
}
|
||||
|
||||
.launch-pane > .jfx-button > StackPane > .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-on-primary-container;
|
||||
-jfx-rippler-fill: transparent;
|
||||
-jfx-mask-type: CIRCLE;
|
||||
-fx-padding: 0;
|
||||
}
|
||||
@@ -494,11 +494,11 @@
|
||||
|
||||
/* TODO: It seems not actually used */
|
||||
.tab-rippler {
|
||||
-jfx-rippler-fill: derive(-monet-primary-container, 30%);
|
||||
-jfx-rippler-fill: transparent;
|
||||
}
|
||||
|
||||
.jfx-tab-pane .jfx-rippler {
|
||||
-jfx-rippler-fill: white;
|
||||
-jfx-rippler-fill: transparent;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
@@ -746,7 +746,7 @@
|
||||
}
|
||||
|
||||
.jfx-tool-bar-second .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-on-surface;
|
||||
-jfx-rippler-fill: transparent;
|
||||
}
|
||||
|
||||
.jfx-tool-bar-button {
|
||||
@@ -864,7 +864,7 @@
|
||||
}
|
||||
|
||||
.jfx-button .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-on-surface;
|
||||
-jfx-rippler-fill: transparent;
|
||||
-jfx-mask-type: CIRCLE;
|
||||
-fx-padding: 0.0;
|
||||
}
|
||||
@@ -874,7 +874,7 @@
|
||||
}
|
||||
|
||||
.jfx-button-raised .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-on-primary;
|
||||
-jfx-rippler-fill: transparent;
|
||||
-jfx-mask-type: CIRCLE;
|
||||
-fx-padding: 0.0;
|
||||
}
|
||||
@@ -997,7 +997,7 @@
|
||||
}
|
||||
|
||||
.jfx-list-cell .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-primary-container;
|
||||
-jfx-rippler-fill: transparent;
|
||||
}
|
||||
|
||||
/*.list-cell:odd:selected > .jfx-rippler > StackPane,*/
|
||||
@@ -1350,7 +1350,7 @@
|
||||
}
|
||||
|
||||
.toggle-icon4 .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-primary;
|
||||
-jfx-rippler-fill: transparent;
|
||||
-jfx-mask-type: CIRCLE;
|
||||
}
|
||||
|
||||
@@ -1379,7 +1379,7 @@
|
||||
}
|
||||
|
||||
.toggle-icon-tiny .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-primary;
|
||||
-jfx-rippler-fill: transparent;
|
||||
-jfx-mask-type: CIRCLE;
|
||||
}
|
||||
|
||||
@@ -1396,7 +1396,7 @@
|
||||
}
|
||||
|
||||
.announcement-close-button .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-primary;
|
||||
-jfx-rippler-fill: transparent;
|
||||
-jfx-mask-type: CIRCLE;
|
||||
}
|
||||
|
||||
@@ -1607,7 +1607,7 @@
|
||||
}
|
||||
|
||||
/*.combo-box-popup .list-view .jfx-list-cell .jfx-rippler {*/
|
||||
/* -jfx-rippler-fill: -monet-primary-container;*/
|
||||
/* -jfx-rippler-fill: transparent;*/
|
||||
/*}*/
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -1654,7 +1654,7 @@
|
||||
}
|
||||
|
||||
.custom-color-dialog .tab .jfx-rippler {
|
||||
-jfx-rippler-fill: -monet-on-surface;
|
||||
-jfx-rippler-fill: transparent;
|
||||
}
|
||||
|
||||
.custom-color-dialog .tab-selected-line {
|
||||
@@ -2171,3 +2171,256 @@
|
||||
.date-picker-popup .title {
|
||||
-fx-text-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
|
||||
/* ---- Home page ---- */
|
||||
.home-eyebrow {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-font-size: 12px;
|
||||
-fx-text-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-font-weight: bold;
|
||||
-fx-font-size: 39px;
|
||||
-fx-text-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.home-subtitle {
|
||||
-fx-font-size: 15px;
|
||||
-fx-line-spacing: 4px;
|
||||
-fx-text-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
-fx-background-color: -monet-surface-bright;
|
||||
-fx-background-radius: 21px;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
-fx-border-radius: 21px;
|
||||
-fx-padding: 21px 21px 21px 34px;
|
||||
}
|
||||
|
||||
.home-fact-label {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-font-size: 12px;
|
||||
-fx-text-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
.home-fact-value {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-font-size: 15px;
|
||||
-fx-text-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.join-button {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-font-size: 16px;
|
||||
-fx-background-color: -monet-primary;
|
||||
-fx-background-radius: 13px;
|
||||
-fx-text-fill: -monet-on-primary;
|
||||
-fx-padding: 13px 34px;
|
||||
-fx-min-width: 179px;
|
||||
-fx-cursor: hand;
|
||||
}
|
||||
|
||||
.join-button:hover {
|
||||
-fx-background-color: derive(-monet-primary, 8%);
|
||||
}
|
||||
|
||||
.join-button:pressed {
|
||||
-fx-scale-x: 0.985;
|
||||
-fx-scale-y: 0.985;
|
||||
}
|
||||
|
||||
.join-button .label {
|
||||
-fx-text-fill: -monet-on-primary;
|
||||
}
|
||||
|
||||
.home-art-line {
|
||||
-fx-stroke: -monet-outline-variant;
|
||||
-fx-stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.home-art-figure {
|
||||
-fx-stroke: -monet-primary;
|
||||
-fx-stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
/* Sidebar: a slightly darker panel with a hairline on its right edge. */
|
||||
.home-sidebar {
|
||||
-fx-background-color: -monet-surface-container;
|
||||
-fx-border-color: transparent -monet-outline-variant transparent transparent;
|
||||
-fx-border-width: 0 1px 0 0;
|
||||
}
|
||||
|
||||
/* ---- Window buttons ---- */
|
||||
.window-button {
|
||||
-fx-min-width: 46px;
|
||||
-fx-pref-width: 46px;
|
||||
-fx-max-width: 46px;
|
||||
-fx-min-height: 40px;
|
||||
-fx-pref-height: 40px;
|
||||
-fx-max-height: 40px;
|
||||
-fx-background-color: transparent;
|
||||
-fx-background-radius: 0;
|
||||
-fx-cursor: default;
|
||||
}
|
||||
|
||||
.window-button:hover {
|
||||
-fx-background-color: -monet-surface-container;
|
||||
}
|
||||
|
||||
.window-close-button:hover {
|
||||
-fx-background-color: #C42B1C;
|
||||
}
|
||||
|
||||
.window-close-button:hover .svg {
|
||||
-fx-fill: white;
|
||||
}
|
||||
|
||||
.mac-window-button {
|
||||
-fx-min-width: 12px;
|
||||
-fx-pref-width: 12px;
|
||||
-fx-max-width: 12px;
|
||||
-fx-min-height: 12px;
|
||||
-fx-pref-height: 12px;
|
||||
-fx-max-height: 12px;
|
||||
-fx-padding: 0;
|
||||
-fx-background-radius: 6px;
|
||||
-fx-border-radius: 6px;
|
||||
-fx-border-width: 0.5px;
|
||||
-fx-cursor: default;
|
||||
}
|
||||
|
||||
.mac-close { -fx-background-color: #FF5F57; -fx-border-color: #E0443E; }
|
||||
.mac-minimize { -fx-background-color: #FEBC2E; -fx-border-color: #DEA123; }
|
||||
.mac-zoom { -fx-background-color: #28C840; -fx-border-color: #1AAB29; }
|
||||
.mac-zoom:disabled { -fx-opacity: 1; -fx-background-color: #D5CEC1; -fx-border-color: #C2BAAB; }
|
||||
.mac-close:hover, .mac-minimize:hover { -fx-effect: innershadow(gaussian, rgba(0, 0, 0, 0.25), 3, 0.2, 0, 0); }
|
||||
|
||||
/* ---- Dialogs: one corner radius family (8 / 13 / 21) ---- */
|
||||
.jfx-dialog-layout, .task-executor-dialog-layout {
|
||||
-fx-background-color: -monet-surface-bright;
|
||||
-fx-background-radius: 21px;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
-fx-border-radius: 21px;
|
||||
-fx-border-width: 1px;
|
||||
-fx-effect: dropshadow(gaussian, rgba(60, 40, 20, 0.18), 34, 0.1, 0, 8);
|
||||
}
|
||||
|
||||
.dialog-accept, .dialog-cancel, .dialog-error {
|
||||
-fx-background-radius: 8px;
|
||||
}
|
||||
|
||||
/* ---- Progress bar: thin, round-ended ---- */
|
||||
.jfx-progress-bar > .track {
|
||||
-fx-background-color: -monet-surface-container-highest;
|
||||
-fx-pref-height: 6px;
|
||||
-fx-min-height: 6px;
|
||||
-fx-max-height: 6px;
|
||||
}
|
||||
|
||||
.jfx-progress-bar > .bar {
|
||||
-fx-background-color: -monet-primary;
|
||||
-fx-padding: 0;
|
||||
-fx-pref-height: 6px;
|
||||
-fx-min-height: 6px;
|
||||
-fx-max-height: 6px;
|
||||
}
|
||||
|
||||
.jfx-progress-bar > .track,
|
||||
.jfx-progress-bar > .bar {
|
||||
-fx-background-radius: 999px;
|
||||
}
|
||||
|
||||
.card, .card-no-padding, .card-non-transparent {
|
||||
-fx-background-radius: 13px;
|
||||
}
|
||||
|
||||
/* The small accent mark that stands in for a logo in the title bar. */
|
||||
.title-mark {
|
||||
-fx-background-color: -monet-primary;
|
||||
-fx-background-radius: 5px;
|
||||
-fx-min-width: 18px;
|
||||
-fx-pref-width: 18px;
|
||||
-fx-max-width: 18px;
|
||||
-fx-min-height: 18px;
|
||||
-fx-pref-height: 18px;
|
||||
-fx-max-height: 18px;
|
||||
}
|
||||
|
||||
/* ---- Unified list and card styling ---- */
|
||||
|
||||
/* Sidebar section labels: small muted text without an underline. */
|
||||
.class-title {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-padding: 13px 16px 4px 16px;
|
||||
}
|
||||
|
||||
.class-title Text {
|
||||
-fx-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
.class-title Rectangle {
|
||||
-fx-fill: transparent;
|
||||
}
|
||||
|
||||
/* Cards: a hairline border instead of a drop shadow. */
|
||||
.card, .card-no-padding, .card-non-transparent {
|
||||
-fx-background-color: -monet-surface-bright;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
-fx-border-radius: 13px;
|
||||
-fx-effect: null;
|
||||
}
|
||||
|
||||
/* Icon buttons use a softer ink than body text. */
|
||||
.toggle-icon4 .svg, .toggle-icon-tiny .svg {
|
||||
-fx-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
/* ---- Settings lists ---- */
|
||||
.component-list-title {
|
||||
-fx-font-family: "Noto Sans SC";
|
||||
-fx-font-weight: bold;
|
||||
-fx-font-size: 14px;
|
||||
-fx-text-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
-fx-background-radius: 13px;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
-fx-border-radius: 13px;
|
||||
-fx-effect: null;
|
||||
}
|
||||
|
||||
.options-list-item {
|
||||
-fx-background-color: -monet-surface-bright;
|
||||
}
|
||||
|
||||
.options-list-item:first {
|
||||
-fx-background-radius: 13px 13px 0 0;
|
||||
}
|
||||
|
||||
.options-list-item:last {
|
||||
-fx-background-radius: 0 0 13px 13px;
|
||||
}
|
||||
|
||||
.options-list-item:first:last {
|
||||
-fx-background-radius: 13px;
|
||||
}
|
||||
|
||||
/* Sidebar rows: a lighter selected and hover tint. */
|
||||
.advanced-list-item:selected .rippler-container,
|
||||
.advanced-list-item .rippler-container:hover {
|
||||
-fx-background-color: -monet-surface-container-high;
|
||||
}
|
||||
|
||||
/* Outlined buttons: one crisp hairline. */
|
||||
.jfx-button-border {
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
-fx-border-radius: 8px;
|
||||
-fx-border-width: 1px;
|
||||
-fx-background-radius: 8px;
|
||||
-fx-padding: 8px 16px;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Warm cream palette with a clay accent. Every launcher color is derived from these variables.
|
||||
*/
|
||||
* {
|
||||
-monet-primary: #A9532F;
|
||||
-monet-on-primary: #FFF8F1;
|
||||
-monet-primary-container: #EFD9CC;
|
||||
-monet-on-primary-container: #4A2312;
|
||||
-monet-primary-fixed: #F6E3D8;
|
||||
-monet-primary-fixed-dim: #E8B99F;
|
||||
-monet-on-primary-fixed: #2E1307;
|
||||
-monet-on-primary-fixed-variant: #7F3C1F;
|
||||
-monet-secondary: #6E665B;
|
||||
-monet-on-secondary: #FAF7F1;
|
||||
-monet-secondary-container: #E9E1D3;
|
||||
-monet-on-secondary-container: #4A433A;
|
||||
-monet-secondary-fixed: #EDE6D9;
|
||||
-monet-secondary-fixed-dim: #CFC6B5;
|
||||
-monet-on-secondary-fixed: #231F19;
|
||||
-monet-on-secondary-fixed-variant: #51493F;
|
||||
-monet-tertiary: #5F7F54;
|
||||
-monet-on-tertiary: #FFFFFF;
|
||||
-monet-tertiary-container: #DCE5D2;
|
||||
-monet-on-tertiary-container: #1F3018;
|
||||
-monet-tertiary-fixed: #DCE5D2;
|
||||
-monet-tertiary-fixed-dim: #B5C7A8;
|
||||
-monet-on-tertiary-fixed: #121E0D;
|
||||
-monet-on-tertiary-fixed-variant: #3E5A34;
|
||||
-monet-error: #B3261E;
|
||||
-monet-on-error: #FFFFFF;
|
||||
-monet-error-container: #F9DEDC;
|
||||
-monet-on-error-container: #410E0B;
|
||||
-monet-surface: #F3EEE6;
|
||||
-monet-on-surface: #2A2622;
|
||||
-monet-surface-dim: #E2DBCF;
|
||||
-monet-surface-bright: #FAF7F1;
|
||||
-monet-surface-container-lowest: #FDFBF7;
|
||||
-monet-surface-container-low: #F8F4EC;
|
||||
-monet-surface-container: #EBE5DA;
|
||||
-monet-surface-container-high: #E4DDCF;
|
||||
-monet-surface-container-highest: #DDD5C7;
|
||||
-monet-surface-variant: #E7E0D3;
|
||||
-monet-on-surface-variant: #6E665B;
|
||||
-monet-background: #F3EEE6;
|
||||
-monet-on-background: #2A2622;
|
||||
-monet-outline: #8C8377;
|
||||
-monet-outline-variant: #DDD5C7;
|
||||
-monet-shadow: #000000;
|
||||
-monet-scrim: #000000;
|
||||
-monet-inverse-surface: #2A2622;
|
||||
-monet-inverse-on-surface: #F3EEE6;
|
||||
-monet-inverse-primary: #E8A47F;
|
||||
-monet-surface-tint: #A9532F;
|
||||
-monet-primary-seed: #A9532F;
|
||||
-monet-primary-transparent-50: #A9532F80;
|
||||
-monet-primary-transparent-80: #A9532FCC;
|
||||
-monet-secondary-container-transparent-50: #E9E1D380;
|
||||
-monet-surface-transparent-50: #F3EEE680;
|
||||
-monet-surface-transparent-80: #F3EEE6CC;
|
||||
-monet-on-surface-variant-transparent-38: #6E665B61;
|
||||
-monet-surface-container-low-transparent-80: #F8F4ECCC;
|
||||
-monet-secondary-container-transparent-80: #E9E1D3CC;
|
||||
-monet-inverse-surface-transparent-80: #2A2622CC;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -791,6 +791,24 @@ instance.game.support_status.unsupported=Unsupported
|
||||
instance.game.support_status.untested=Untested
|
||||
instance.game.type=Type
|
||||
instance.launch=Launch Game
|
||||
home.welcome=Welcome back
|
||||
home.title=Join our world
|
||||
home.subtitle=One click syncs the modpack, prepares Java and connects you to the server.
|
||||
home.pack=Modpack
|
||||
home.route=Route
|
||||
home.not_installed=Not installed
|
||||
launcher.name=Server Launcher
|
||||
account.add=Add account
|
||||
account.open_manage=Manage accounts
|
||||
first_run.title=Pick a game name
|
||||
first_run.description=Your name is used when you join the server. You can sign in with a Microsoft account instead.
|
||||
first_run.name=Game name
|
||||
first_run.accept=Start playing
|
||||
first_run.microsoft=Sign in with Microsoft
|
||||
first_run.invalid=Use 1 to 16 letters, digits or underscores.
|
||||
home.server=Server
|
||||
home.server.unreachable=Unreachable
|
||||
server.join=Join Server
|
||||
instance.launch_and_enter_world=Play World
|
||||
instance.launch.empty=Start Game
|
||||
instance.launch.empty.installing=Installing Game
|
||||
|
||||
@@ -611,6 +611,24 @@ instance.game.snapshots=預版
|
||||
# instance.game.support_status.untested=
|
||||
instance.game.type=版類
|
||||
instance.launch=戲
|
||||
home.welcome=歸來
|
||||
home.title=入吾世
|
||||
home.subtitle=一點即同步、備器、入服。
|
||||
home.pack=整合包
|
||||
home.route=路
|
||||
home.not_installed=未裝
|
||||
launcher.name=服啟
|
||||
account.add=增帳
|
||||
account.open_manage=理帳
|
||||
first_run.title=署名
|
||||
first_run.description=入服用此名。可改登微軟之帳。
|
||||
first_run.name=名
|
||||
first_run.accept=始
|
||||
first_run.microsoft=登正版
|
||||
first_run.invalid=一至十六字,限英文數字底綫。
|
||||
home.server=服
|
||||
home.server.unreachable=不通
|
||||
server.join=入服
|
||||
# instance.launch_and_enter_world=
|
||||
instance.launch.empty=啟戲
|
||||
instance.launch.empty.installing=裝戲
|
||||
|
||||
@@ -614,6 +614,24 @@ instance.game.support_status.unsupported=不支持
|
||||
instance.game.support_status.untested=未经测试
|
||||
instance.game.type=版本类型
|
||||
instance.launch=启动游戏
|
||||
home.welcome=欢迎回来
|
||||
home.title=进入我们的世界
|
||||
home.subtitle=点一下就会同步整合包、准备好 Java,并直接连上服务器。
|
||||
home.pack=整合包
|
||||
home.route=线路
|
||||
home.not_installed=尚未安装
|
||||
launcher.name=服务器启动器
|
||||
account.add=添加账号
|
||||
account.open_manage=管理账号
|
||||
first_run.title=起个游戏名
|
||||
first_run.description=进服时会用这个名字。想用正版账号的话,可以改为登录微软账号。
|
||||
first_run.name=游戏名
|
||||
first_run.accept=开始游戏
|
||||
first_run.microsoft=登录正版账号
|
||||
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
|
||||
home.server=服务器
|
||||
home.server.unreachable=连接不上
|
||||
server.join=进入服务器
|
||||
instance.launch_and_enter_world=进入世界
|
||||
instance.launch.empty=开始游戏
|
||||
instance.launch.empty.installing=安装游戏
|
||||
|
||||
@@ -608,6 +608,24 @@ instance.game.support_status.unsupported=不支援
|
||||
instance.game.support_status.untested=未經測試
|
||||
instance.game.type=版本類型
|
||||
instance.launch=啟動遊戲
|
||||
home.welcome=歡迎回來
|
||||
home.title=進入我們的世界
|
||||
home.subtitle=點一下就會同步整合包、準備好 Java,並直接連上伺服器。
|
||||
home.pack=整合包
|
||||
home.route=線路
|
||||
home.not_installed=尚未安裝
|
||||
launcher.name=伺服器啟動器
|
||||
account.add=新增帳號
|
||||
account.open_manage=管理帳號
|
||||
first_run.title=取個遊戲名
|
||||
first_run.description=進服時會使用這個名字。想用正版帳號的話,可以改為登入微軟帳號。
|
||||
first_run.name=遊戲名
|
||||
first_run.accept=開始遊戲
|
||||
first_run.microsoft=登入正版帳號
|
||||
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
|
||||
home.server=伺服器
|
||||
home.server.unreachable=無法連線
|
||||
server.join=進入伺服器
|
||||
instance.launch_and_enter_world=進入世界
|
||||
instance.launch.empty=開始遊戲
|
||||
instance.launch.empty.installing=安裝遊戲
|
||||
|
||||
@@ -601,7 +601,7 @@ public final class JFXSwitchTest {
|
||||
/// Loads the same shared component styles and color palette used by the application.
|
||||
private static void applyStylesheets(Scene scene) {
|
||||
scene.getStylesheets().addAll(
|
||||
Objects.requireNonNull(JFXSwitchTest.class.getResource("/assets/css/blue.css")).toExternalForm(),
|
||||
Objects.requireNonNull(JFXSwitchTest.class.getResource("/assets/css/warm.css")).toExternalForm(),
|
||||
Objects.requireNonNull(JFXSwitchTest.class.getResource("/assets/css/root.css")).toExternalForm());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// Tests how [ServerEndpoint] resolves routes without an installed modpack manifest.
|
||||
public final class ServerEndpointTest {
|
||||
@AfterEach
|
||||
void clearProperty() {
|
||||
System.clearProperty("hmcl.server.address");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noConfigurationMeansNoRoute(@TempDir Path dir) {
|
||||
assertTrue(ServerEndpoint.routes(dir.resolve("modpack.json")).isEmpty());
|
||||
assertNull(ServerEndpoint.selected(dir.resolve("modpack.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDefaultAddress(@TempDir Path dir) {
|
||||
System.setProperty("hmcl.server.address", " play.example.net:25565 ");
|
||||
var routes = ServerEndpoint.routes(dir.resolve("modpack.json"));
|
||||
assertEquals(1, routes.size());
|
||||
assertEquals("play.example.net:25565", routes.get(0).address());
|
||||
assertEquals(routes.get(0), ServerEndpoint.selected(dir.resolve("modpack.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectionIsRememberedNextToTheConfiguration(@TempDir Path dir) throws Exception {
|
||||
System.setProperty("hmcl.server.address", "wss://cdn.example.net/mc");
|
||||
Path file = dir.resolve("modpack.json");
|
||||
ServerEndpoint.select(file, new ServerEndpoint.Route("cdn", "wss://cdn.example.net/mc"));
|
||||
assertEquals("cdn", Files.readString(dir.resolve("server-route.txt")));
|
||||
}
|
||||
}
|
||||
@@ -1,44 +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.theme;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.function.Executable;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/// Tests for built-in launcher wallpapers.
|
||||
@NotNullByDefault
|
||||
public final class BuiltinBackgroundTest {
|
||||
/// Checks precomputed MonetFX seed colors against dynamic extraction.
|
||||
@Test
|
||||
public void precomputedThemeColorsMatchDynamicExtraction() {
|
||||
List<Executable> checks = Arrays.stream(BuiltinBackground.values())
|
||||
.<Executable>map(background -> () -> assertEquals(
|
||||
WallpaperColorExtractor.extract(loadWallpaper(background), ThemeColor.DEFAULT),
|
||||
background.themeColor(),
|
||||
background.id()))
|
||||
.toList();
|
||||
assertAll(checks);
|
||||
}
|
||||
|
||||
/// Loads the bundled wallpaper for a built-in background.
|
||||
private static Image loadWallpaper(BuiltinBackground background) throws Exception {
|
||||
String fileName = background.id() + ".jpg";
|
||||
@Nullable InputStream input = BuiltinBackgroundTest.class.getResourceAsStream(
|
||||
"/assets/img/wallpapers/" + fileName);
|
||||
if (input == null) {
|
||||
throw new AssertionError("Missing built-in wallpaper: " + fileName);
|
||||
}
|
||||
return FXUtils.loadImage(input, fileName);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +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.theme;
|
||||
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/// Tests launcher background opacity storage.
|
||||
@NotNullByDefault
|
||||
public final class LauncherBackgroundTest {
|
||||
/// Verifies opacity changes reuse the unmodified JavaFX background.
|
||||
@Test
|
||||
public void changesOpacityWithoutChangingBackground() {
|
||||
Background background = new Background(
|
||||
new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY));
|
||||
LauncherBackground changed = new LauncherBackground(background, 0.4);
|
||||
|
||||
assertSame(background, changed.background());
|
||||
assertEquals(0.4, changed.opacity());
|
||||
assertEquals(1.0, ((Color) changed.background().getFills().get(0).getFill()).getOpacity());
|
||||
}
|
||||
|
||||
/// Verifies invalid node opacity values are rejected.
|
||||
@Test
|
||||
public void rejectsInvalidOpacity() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, -0.1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, 1.1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, Double.NaN));
|
||||
}
|
||||
}
|
||||
@@ -1,156 +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.theme;
|
||||
|
||||
import org.jackhuang.hmcl.setting.BackgroundType;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
|
||||
/// Tests for theme-pack import, export, and resolution behavior.
|
||||
@NotNullByDefault
|
||||
public final class ThemePackManagerTest {
|
||||
/// Theme-pack image assets keep their entry names when exported from unpacked theme directories.
|
||||
@Test
|
||||
public void exportsUnpackedThemeResourceWithEntryName() throws Exception {
|
||||
Field launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings");
|
||||
launcherSettingsField.setAccessible(true);
|
||||
Object previousLauncherSettings = launcherSettingsField.get(null);
|
||||
|
||||
String packId = "example.unpacked-export";
|
||||
Path themePacksDirectory = ThemePackManager.THEME_PACKS_DIRECTORY.toAbsolutePath().normalize();
|
||||
Path themeDirectory = themePacksDirectory.resolve(packId).normalize();
|
||||
if (!themeDirectory.startsWith(themePacksDirectory)) {
|
||||
throw new AssertionError("Theme-pack test directory escapes the theme-pack directory: " + themeDirectory);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(themeDirectory)) {
|
||||
FileUtils.forceDelete(themeDirectory);
|
||||
}
|
||||
Path wallpaper = themeDirectory.resolve("assets/wallpapers/wallpaper.png");
|
||||
Files.createDirectories(wallpaper.getParent());
|
||||
byte[] body = "unpacked image bytes".getBytes(StandardCharsets.US_ASCII);
|
||||
Files.write(wallpaper, body);
|
||||
Files.writeString(themeDirectory.resolve(ThemePackExporter.MANIFEST_ENTRY), """
|
||||
{
|
||||
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
|
||||
"id": "example.unpacked-export",
|
||||
"version": "1.0.0",
|
||||
"name": "Unpacked",
|
||||
"theme": {
|
||||
"background": {
|
||||
"type": "image",
|
||||
"path": "assets/wallpapers/wallpaper.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
LauncherSettings launcherSettings = new LauncherSettings();
|
||||
launcherSettings.selectedThemeProperty().set(new ThemeReference(packId, null));
|
||||
launcherSettingsField.set(null, launcherSettings);
|
||||
|
||||
ThemePackManager.ExportedThemePack exported =
|
||||
ThemePackManager.createCurrent("example.export", "1.0", "Example", "Tester");
|
||||
Theme theme = exported.manifest().themes().get(0);
|
||||
ThemeBackgroundSettings background = Objects.requireNonNull(theme.appearance().background());
|
||||
ThemeBackground.Image image = assertInstanceOf(ThemeBackground.Image.class, background.source());
|
||||
|
||||
assertEquals("assets/wallpapers/wallpaper.png", image.path());
|
||||
assertEquals(1, exported.assets().size());
|
||||
assertEquals("assets/wallpapers/wallpaper.png", exported.assets().get(0).entryName());
|
||||
try (InputStream input = exported.assets().get(0).source().openStream()) {
|
||||
assertArrayEquals(body, input.readAllBytes());
|
||||
}
|
||||
} finally {
|
||||
launcherSettingsField.set(null, previousLauncherSettings);
|
||||
if (Files.exists(themeDirectory)) {
|
||||
FileUtils.forceDelete(themeDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Network launcher backgrounds are downloaded and exported as local theme-pack image assets.
|
||||
@Test
|
||||
public void exportsNetworkBackgroundAsImageAsset() throws Exception {
|
||||
Field launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings");
|
||||
launcherSettingsField.setAccessible(true);
|
||||
Object previousLauncherSettings = launcherSettingsField.get(null);
|
||||
|
||||
byte[] body = "fake image bytes".getBytes(StandardCharsets.US_ASCII);
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try (ServerSocket server = new ServerSocket(0)) {
|
||||
Future<?> serverTask = executor.submit(() -> {
|
||||
try (Socket socket = server.accept();
|
||||
InputStream input = socket.getInputStream();
|
||||
OutputStream output = socket.getOutputStream()) {
|
||||
int matched = 0;
|
||||
byte[] endOfHeaders = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
|
||||
int nextByte;
|
||||
while (matched < endOfHeaders.length && (nextByte = input.read()) >= 0) {
|
||||
matched = nextByte == endOfHeaders[matched] ? matched + 1 : 0;
|
||||
}
|
||||
byte[] header = ("HTTP/1.1 200 OK\r\n"
|
||||
+ "Content-Type: image/png\r\n"
|
||||
+ "Content-Length: " + body.length + "\r\n"
|
||||
+ "Connection: close\r\n"
|
||||
+ "\r\n").getBytes(StandardCharsets.US_ASCII);
|
||||
output.write(header);
|
||||
output.write(body);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
LauncherSettings launcherSettings = new LauncherSettings();
|
||||
launcherSettings.backgroundTypeProperty().set(BackgroundType.NETWORK);
|
||||
launcherSettings.networkBackgroundImageUrlProperty().set(
|
||||
"http://127.0.0.1:" + server.getLocalPort() + "/wallpaper.png");
|
||||
launcherSettings.networkBackgroundImageCachePolicyProperty().set(NetworkBackgroundImageCachePolicy.DISABLED);
|
||||
launcherSettings.getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND);
|
||||
launcherSettingsField.set(null, launcherSettings);
|
||||
|
||||
ThemePackManager.ExportedThemePack exported =
|
||||
ThemePackManager.createCurrent("example.export", "1.0", "Example", "Tester");
|
||||
try {
|
||||
Theme theme = exported.manifest().themes().get(0);
|
||||
ThemeBackgroundSettings background = Objects.requireNonNull(theme.appearance().background());
|
||||
ThemeBackground.Image image = assertInstanceOf(ThemeBackground.Image.class, background.source());
|
||||
|
||||
assertEquals("assets/wallpapers/wallpaper.png", image.path());
|
||||
assertEquals(1, exported.assets().size());
|
||||
try (InputStream input = exported.assets().get(0).source().openStream()) {
|
||||
assertArrayEquals(body, input.readAllBytes());
|
||||
}
|
||||
serverTask.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
for (Path temporaryFile : exported.temporaryFiles()) {
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
launcherSettingsField.set(null, previousLauncherSettings);
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +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.theme;
|
||||
|
||||
import org.glavo.monetfx.Brightness;
|
||||
import org.jackhuang.hmcl.setting.BackgroundType;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/// Tests for theme-pack manifest parsing and resolution behavior.
|
||||
@NotNullByDefault
|
||||
public final class ThemePackManifestTest {
|
||||
/// Invalid optional metadata fields are ignored instead of rejecting the whole manifest.
|
||||
@Test
|
||||
public void invalidOptionalManifestFieldsAreIgnored() {
|
||||
ThemePackManifest manifest = Objects.requireNonNull(JsonUtils.fromJson("""
|
||||
{
|
||||
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
|
||||
"id": "example.invalid-optionals",
|
||||
"version": "1.0.0",
|
||||
"name": "Example",
|
||||
"authors": "invalid",
|
||||
"description": "",
|
||||
"icon": "../bad.png",
|
||||
"theme": {
|
||||
"id": "../bad",
|
||||
"name": "",
|
||||
"description": [],
|
||||
"icon": "../theme.png"
|
||||
}
|
||||
}
|
||||
""", ThemePackManifest.class));
|
||||
|
||||
assertTrue(manifest.authors().isEmpty());
|
||||
assertNull(manifest.description());
|
||||
assertNull(manifest.icon());
|
||||
|
||||
Theme theme = manifest.themes().get(0);
|
||||
assertNull(theme.id());
|
||||
assertNull(theme.name());
|
||||
assertNull(theme.description());
|
||||
assertNull(theme.icon());
|
||||
}
|
||||
|
||||
/// Applying a theme without a background should resolve to the launcher default background.
|
||||
@Test
|
||||
public void emptyThemeUsesDefaultBackgroundAfterApplying() throws Exception {
|
||||
ThemePackManifest manifest = Objects.requireNonNull(JsonUtils.fromJson("""
|
||||
{
|
||||
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
|
||||
"id": "example.empty-theme",
|
||||
"version": "1.0.0",
|
||||
"name": "Example",
|
||||
"theme": {}
|
||||
}
|
||||
""", ThemePackManifest.class));
|
||||
Theme theme = manifest.themes().get(0);
|
||||
|
||||
ThemePackManager.ResolvedBackground background = ThemePackManager.resolveBackgroundAfterApplyingTheme(
|
||||
new ThemePackManager.InstalledThemePack(Path.of("example.hmcl-theme"), manifest),
|
||||
theme,
|
||||
new ThemeResolveContext(Brightness.LIGHT, "linux", "en"));
|
||||
|
||||
assertEquals(BackgroundType.DEFAULT, background.type());
|
||||
assertEquals(1.0, background.opacity(), 0.0);
|
||||
assertNull(theme.appearance().windowTransparent());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -327,6 +327,10 @@ public class LaunchOptions implements Serializable {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Path getGameDir() {
|
||||
return options.gameDir;
|
||||
}
|
||||
|
||||
public Builder setJava(JavaRuntime java) {
|
||||
options.java = java;
|
||||
return this;
|
||||
|
||||
@@ -356,19 +356,25 @@ public class DefaultLauncher extends Launcher {
|
||||
if (options.getQuickPlayOption() instanceof QuickPlayOption.MultiPlayer multiPlayer) {
|
||||
String address = multiPlayer.serverIP();
|
||||
|
||||
try {
|
||||
ServerAddress parsed = ServerAddress.parse(address);
|
||||
if (World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) {
|
||||
res.add("--quickPlayMultiplayer");
|
||||
res.add(parsed.port() >= 0 ? address : parsed.host() + ":25565");
|
||||
} else {
|
||||
res.add("--server");
|
||||
res.add(parsed.host());
|
||||
res.add("--port");
|
||||
res.add(parsed.port() >= 0 ? String.valueOf(parsed.port()) : "25565");
|
||||
if (isWebSocketAddress(address)) {
|
||||
// WebSocket routes are resolved by the client-side WebSocket mod; hand the URL over untouched.
|
||||
res.add("--server");
|
||||
res.add(address);
|
||||
} else {
|
||||
try {
|
||||
ServerAddress parsed = ServerAddress.parse(address);
|
||||
if (World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) {
|
||||
res.add("--quickPlayMultiplayer");
|
||||
res.add(parsed.port() >= 0 ? address : parsed.host() + ":25565");
|
||||
} else {
|
||||
res.add("--server");
|
||||
res.add(parsed.host());
|
||||
res.add("--port");
|
||||
res.add(parsed.port() >= 0 ? String.valueOf(parsed.port()) : "25565");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Invalid server address: " + address, e);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Invalid server address: " + address, e);
|
||||
}
|
||||
} else if (options.getQuickPlayOption() instanceof QuickPlayOption.SinglePlayer singlePlayer
|
||||
&& World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) {
|
||||
@@ -1049,4 +1055,13 @@ public class DefaultLauncher extends Launcher {
|
||||
Path javaNativeFolder,
|
||||
Charset encoding) {
|
||||
}
|
||||
|
||||
/// Returns whether the address is a `ws://` or `wss://` URL rather than a `host[:port]` pair.
|
||||
///
|
||||
/// @param address the configured server address
|
||||
/// @return `true` for WebSocket URLs
|
||||
private static boolean isWebSocketAddress(String address) {
|
||||
String lower = address.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return lower.startsWith("ws://") || lower.startsWith("wss://");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user