diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index 78b69a8bd8..74a9e97197 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -141,4 +141,18 @@ public final class Metadata { return null; } + /// 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; empty by default. + public static String getServerName() { + return System.getProperty("hmcl.server.name", "").trim(); + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java index 318e827657..85783102e7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -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")) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java new file mode 100644 index 0000000000..77d15dd629 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java @@ -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 routes(Path configurationFile) { + List 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 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 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; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java index 2627ff5be2..953964a306 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java @@ -82,7 +82,28 @@ public final class FontManager { private static final ObjectProperty 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")); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java index 978b5de0db..a8623fd62a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java @@ -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. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java index 7945ddfbce..edef092a35 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java @@ -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) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java deleted file mode 100644 index 6db69cdae2..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/LauncherBackground.java +++ /dev/null @@ -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); - } - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java deleted file mode 100644 index ea2768830a..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ResolvedTheme.java +++ /dev/null @@ -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(); - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java deleted file mode 100644 index bf9460e4c5..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java +++ /dev/null @@ -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 authors, - @Nullable LocalizedText description, - @Nullable String icon, - ThemeAppearance appearance, - @Unmodifiable List 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 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 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; - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java deleted file mode 100644 index e8246f5c0b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeAppearance.java +++ /dev/null @@ -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"; - }; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java deleted file mode 100644 index 3568c18533..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java +++ /dev/null @@ -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"); - } - - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java deleted file mode 100644 index f47eef18e3..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackgroundSettings.java +++ /dev/null @@ -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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java deleted file mode 100644 index 091299335e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColorSource.java +++ /dev/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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java deleted file mode 100644 index cdb67e8dc0..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeCondition.java +++ /dev/null @@ -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> 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 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> copy = new LinkedHashMap<>(); - for (Map.Entry> entry : requirements.entrySet()) { - String key = normalizeKey(entry.getKey()); - Set values = entry.getValue(); - if (values.isEmpty()) { - throw new IllegalArgumentException("Theme condition field has no accepted values: " + key); - } - - LinkedHashSet 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> requirements = new LinkedHashMap<>(); - for (Map.Entry 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> 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> 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 readAcceptedValues(String key, JsonElement element) throws JsonParseException { - LinkedHashSet 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; - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java deleted file mode 100644 index 2abef978e2..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeOverride.java +++ /dev/null @@ -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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java deleted file mode 100644 index 1d12195bed..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAsset.java +++ /dev/null @@ -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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java deleted file mode 100644 index 16a261b1fa..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackAuthor.java +++ /dev/null @@ -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 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 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 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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java deleted file mode 100644 index c6e547a3ac..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackExporter.java +++ /dev/null @@ -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 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 assets) throws IOException { - Set 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()); - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java deleted file mode 100644 index b26444d070..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManager.java +++ /dev/null @@ -1,1607 +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.JsonParseException; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import kala.compress.archivers.zip.ZipArchiveEntry; -import kala.compress.archivers.zip.ZipArchiveReader; -import org.glavo.monetfx.Brightness; -import org.glavo.monetfx.ColorRole; -import org.glavo.monetfx.ColorStyle; -import org.glavo.monetfx.Contrast; -import org.glavo.url.WebURL; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.BackgroundType; -import org.jackhuang.hmcl.setting.LauncherSettings; -import org.jackhuang.hmcl.setting.SettingsManager; -import org.jackhuang.hmcl.setting.ThemeColorType; -import org.jackhuang.hmcl.task.CacheFileTask; -import org.jackhuang.hmcl.util.MathUtils; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.i18n.LocalizedText; -import org.jackhuang.hmcl.util.io.ContentEncoding; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.IOUtils; -import org.jackhuang.hmcl.util.io.NetworkUtils; -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.io.OutputStream; -import java.net.HttpURLConnection; -import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -/// Imports, applies, and exports launcher theme packs. -@NotNullByDefault -public final class ThemePackManager { - /// Directory where imported theme packs are stored for the launcher UI. - public static final Path THEME_PACKS_DIRECTORY = Metadata.HMCL_LOCAL_HOME.resolve("themes"); - - /// Directory where user-wide theme packs are discovered. - private static final Path USER_THEME_PACKS_DIRECTORY = Metadata.HMCL_USER_HOME.resolve("themes"); - - /// Default version used when exporting the current launcher appearance. - public static final String CURRENT_THEME_PACK_VERSION = "1.0.0"; - - /// IDs of launcher-bundled theme packs in display order. - private static final @Unmodifiable List BUILTIN_THEME_PACK_IDS = List.of( - "hmcl.default", - "hmcl.classic" - ); - - /// Built-in default theme reference. - public static final ThemeReference BUILTIN_DEFAULT_THEME_REFERENCE = LauncherSettings.DEFAULT_THEME_REFERENCE; - - /// Built-in theme packs bundled with the launcher. - private static final @Unmodifiable List BUILTIN_THEME_PACKS = loadBuiltinThemePacks(); - - /// Prevents instantiation. - private ThemePackManager() { - } - - /// A theme pack loaded from a zip-compatible theme-pack file. - /// - /// @param file the source theme-pack file - /// @param manifest the parsed manifest - public record LoadedThemePack(Path file, ThemePackManifest manifest) { - /// Creates a loaded theme-pack descriptor. - /// - /// @param file the source theme-pack file - /// @param manifest the parsed manifest - public LoadedThemePack { - file = Objects.requireNonNull(file).toAbsolutePath().normalize(); - Objects.requireNonNull(manifest); - } - } - - /// Identifies where an installed theme pack is stored. - public sealed interface ThemePackLocation permits ThemePackLocation.Local, ThemePackLocation.Builtin { - /// Returns whether this location points to launcher-bundled resources. - /// - /// @return `true` for built-in theme packs - boolean builtin(); - - /// Returns the backing filesystem path for local theme packs. - /// - /// @return the backing file or directory, or `null` for non-filesystem locations - @Nullable Path file(); - - /// A theme pack stored as a filesystem file or directory. - /// - /// @param file the backing file or directory - @NotNullByDefault - record Local(Path file) implements ThemePackLocation { - /// Creates a local theme-pack location. - /// - /// @param file the backing file or directory - public Local { - file = Objects.requireNonNull(file).toAbsolutePath().normalize(); - } - - /// Returns whether this location points to launcher-bundled resources. - @Override - public boolean builtin() { - return false; - } - } - - /// A theme pack bundled in launcher classpath resources. - /// - /// @param id the built-in theme-pack ID - @NotNullByDefault - record Builtin(String id) implements ThemePackLocation { - /// Creates a built-in theme-pack location. - /// - /// @param id the built-in theme-pack ID - public Builtin { - id = ThemePackManifest.requirePackageId(id); - } - - /// Returns whether this location points to launcher-bundled resources. - @Override - public boolean builtin() { - return true; - } - - /// Returns the backing filesystem path for local theme packs. - @Override - public @Nullable Path file() { - return null; - } - } - } - - /// A theme pack available to the launcher. - /// - /// @param location where this theme pack is stored - /// @param manifest the parsed manifest - public record InstalledThemePack(ThemePackLocation location, ThemePackManifest manifest) { - /// Creates an installed user theme-pack descriptor. - /// - /// @param file the installed theme-pack file - /// @param manifest the parsed manifest - public InstalledThemePack(Path file, ThemePackManifest manifest) { - this(new ThemePackLocation.Local(file), manifest); - } - - /// Creates an available theme-pack descriptor. - /// - /// @param location where this theme pack is stored - /// @param manifest the parsed manifest - public InstalledThemePack { - Objects.requireNonNull(location); - Objects.requireNonNull(manifest); - } - - /// Returns whether this package is bundled with the launcher resources. - /// - /// @return `true` for built-in theme packs - public boolean builtin() { - return location.builtin(); - } - - /// Returns the backing filesystem path for local theme packs. - /// - /// @return the backing file or directory, or `null` for built-in theme packs - public @Nullable Path file() { - return location.file(); - } - } - - /// A theme pack assembled from launcher settings and ready to export. - /// - /// @param manifest the manifest to write - /// @param assets the asset files to include - /// @param temporaryFiles temporary files owned by this export result - public record ExportedThemePack( - ThemePackManifest manifest, - @Unmodifiable List assets, - @Unmodifiable List temporaryFiles) { - /// Creates an exportable theme-pack descriptor without owned temporary files. - /// - /// @param manifest the manifest to write - /// @param assets the asset files to include - public ExportedThemePack(ThemePackManifest manifest, @Unmodifiable List assets) { - this(manifest, assets, List.of()); - } - - /// Creates an exportable theme-pack descriptor. - /// - /// @param manifest the manifest to write - /// @param assets the asset files to include - /// @param temporaryFiles temporary files owned by this export result - public ExportedThemePack { - Objects.requireNonNull(manifest); - assets = List.copyOf(assets); - temporaryFiles = List.copyOf(temporaryFiles); - } - } - - /// A concrete launcher background resolved from either custom settings or a selected theme. - /// - /// @param type the launcher background source type - /// @param builtinBackgroundId the selected built-in wallpaper ID, or `null` when not using a built-in wallpaper - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param imageResource the resolved theme-pack image resource, or `null` when not using a theme-pack image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public record ResolvedBackground( - BackgroundType type, - @Nullable String builtinBackgroundId, - @Nullable Path imagePath, - @Nullable ThemePackResource imageResource, - @Nullable String networkImageUrl, - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy, - @Nullable Paint paint, - double opacity) { - /// Creates a resolved launcher background without a built-in wallpaper ID. - /// - /// @param type the launcher background source type - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground( - BackgroundType type, - @Nullable Path imagePath, - @Nullable String networkImageUrl, - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy, - @Nullable Paint paint, - double opacity) { - this(type, null, imagePath, null, networkImageUrl, networkImageCachePolicy, paint, opacity); - } - - /// Creates a resolved launcher background with a theme-pack image resource. - /// - /// @param type the launcher background source type - /// @param imagePath the resolved local image file, or `null` when the resource is not a direct file - /// @param imageResource the resolved theme-pack image resource - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground( - BackgroundType type, - @Nullable Path imagePath, - ThemePackResource imageResource, - double opacity) { - this(type, null, imagePath, imageResource, null, null, null, opacity); - } - - /// Creates a resolved launcher background. - /// - /// @param type the launcher background source type - /// @param builtinBackgroundId the selected built-in wallpaper ID, or `null` when not using a built-in wallpaper - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground( - BackgroundType type, - @Nullable String builtinBackgroundId, - @Nullable Path imagePath, - @Nullable String networkImageUrl, - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy, - @Nullable Paint paint, - double opacity) { - this(type, builtinBackgroundId, imagePath, null, networkImageUrl, networkImageCachePolicy, paint, opacity); - } - - /// Creates a resolved launcher background. - /// - /// @param type the launcher background source type - /// @param builtinBackgroundId the selected built-in wallpaper ID, or `null` when not using a built-in wallpaper - /// @param imagePath the resolved local image file or directory, or `null` when not using a local image - /// @param imageResource the resolved theme-pack image resource, or `null` when not using a theme-pack image - /// @param networkImageUrl the remote image URL, or `null` when not using a network image - /// @param networkImageCachePolicy whether the remote image cache policy is explicitly overridden, or `null` for default behavior - /// @param paint the resolved background paint, or `null` when not using a paint background - /// @param opacity the background opacity clamped to `[0, 1]` - public ResolvedBackground { - Objects.requireNonNull(type); - if (builtinBackgroundId != null) { - builtinBackgroundId = builtinBackgroundId.trim(); - if (builtinBackgroundId.isEmpty()) { - throw new IllegalArgumentException("Resolved background built-in wallpaper ID is blank"); - } - } - opacity = Double.isFinite(opacity) - ? MathUtils.clamp(opacity, 0.0, 1.0) - : 1.0; - } - - /// Returns a copy with a different opacity. - public ResolvedBackground withOpacity(double opacity) { - return new ResolvedBackground( - type, - builtinBackgroundId, - imagePath, - imageResource, - networkImageUrl, - networkImageCachePolicy, - paint, - opacity); - } - - /// Returns a copy with a different remote image cache policy. - /// - /// @param networkImageCachePolicy the new remote image cache policy - /// @return the copied background - public ResolvedBackground withNetworkImageCachePolicy( - @Nullable NetworkBackgroundImageCachePolicy networkImageCachePolicy) { - return new ResolvedBackground( - type, - builtinBackgroundId, - imagePath, - imageResource, - networkImageUrl, - networkImageCachePolicy, - paint, - opacity); - } - - } - - /// Loads and parses a theme-pack file or unpacked theme-pack directory. - /// - /// @param file the theme-pack file or directory - /// @return the loaded theme pack - /// @throws IOException if the file cannot be read or the manifest is invalid - public static LoadedThemePack load(Path file) throws IOException { - Objects.requireNonNull(file); - - Path normalizedFile = file.toAbsolutePath().normalize(); - try { - if (Files.isDirectory(normalizedFile)) { - Path manifestFile = normalizedFile.resolve(ThemePackExporter.MANIFEST_ENTRY); - if (!Files.isRegularFile(manifestFile)) { - throw new IOException("Theme pack directory does not contain " + ThemePackExporter.MANIFEST_ENTRY); - } - - ThemePackManifest manifest = JsonUtils.fromJsonFile(manifestFile, ThemePackManifest.class); - if (manifest == null) { - throw new JsonParseException("Manifest is null"); - } - return new LoadedThemePack(normalizedFile, manifest); - } - - try (var reader = new ZipArchiveReader(normalizedFile)) { - var manifestEntry = reader.getEntry(ThemePackExporter.MANIFEST_ENTRY); - if (manifestEntry == null || manifestEntry.isDirectory()) { - throw new IOException("Theme pack does not contain " + ThemePackExporter.MANIFEST_ENTRY); - } - - ThemePackManifest manifest; - try (var inputStream = reader.getInputStream(manifestEntry)) { - manifest = JsonUtils.fromNonNullJsonFully(inputStream, ThemePackManifest.class); - } - return new LoadedThemePack(normalizedFile, manifest); - } - } catch (JsonParseException | IllegalArgumentException e) { - throw new IOException("Invalid theme-pack manifest", e); - } - } - - /// Loads built-in theme pack manifests from launcher resources. - private static @Unmodifiable List loadBuiltinThemePacks() { - ArrayList themePacks = new ArrayList<>(); - for (String id : BUILTIN_THEME_PACK_IDS) { - try { - ThemePackManifest manifest; - - try (InputStream input = ThemePackManager.class.getResourceAsStream( - "/assets/themes/" + id + "/" + ThemePackExporter.MANIFEST_ENTRY)) { - if (input == null) { - throw new IOException("Missing built-in theme-pack manifest: " + id); - } - - manifest = JsonUtils.fromNonNullJsonFully(input, ThemePackManifest.class); - } - if (!manifest.id().equals(id)) { - throw new IOException("Built-in theme-pack id does not match resource directory: " + id); - } - themePacks.add(new InstalledThemePack( - new ThemePackLocation.Builtin(manifest.id()), - manifest)); - } catch (IOException | JsonParseException | IllegalArgumentException e) { - throw new ExceptionInInitializerError(e); - } - } - - if (themePacks.isEmpty()) { - throw new ExceptionInInitializerError("No built-in theme packs are declared"); - } - return List.copyOf(themePacks); - } - - /// Loads a manifest from an installed theme-pack file. - /// - /// @param file the installed theme-pack file - /// @return the installed theme pack - /// @throws IOException if the installed manifest cannot be read or parsed - public static InstalledThemePack loadInstalled(Path file) throws IOException { - LoadedThemePack loadedThemePack = load(file); - return new InstalledThemePack(loadedThemePack.file(), loadedThemePack.manifest()); - } - - /// Loads the installed theme pack referenced by a theme reference. - /// - /// @param reference the referenced installed theme - /// @return the installed theme pack, or `null` if the selected package file does not exist - /// @throws IOException if the installed manifest cannot be read or parsed - public static @Nullable InstalledThemePack findInstalled(ThemeReference reference) throws IOException { - Objects.requireNonNull(reference); - - @Nullable InstalledThemePack themePack = findInstalled(THEME_PACKS_DIRECTORY, reference.packId()); - if (themePack != null) { - return themePack; - } - - themePack = findInstalled(USER_THEME_PACKS_DIRECTORY, reference.packId()); - if (themePack != null) { - return themePack; - } - - return findBuiltinThemePack(reference.packId()); - } - - /// Lists all installed theme packs. - /// - /// @return installed theme packs with built-in package IDs first and local packages overriding user packages - /// @throws IOException if installed theme-pack files cannot be listed - public static @Unmodifiable List listInstalled() throws IOException { - LinkedHashMap highestPriorityThemePacks = new LinkedHashMap<>(); - for (InstalledThemePack themePack : BUILTIN_THEME_PACKS) { - highestPriorityThemePacks.put(themePack.manifest().id(), themePack); - } - - for (InstalledThemePack themePack : listInstalled(USER_THEME_PACKS_DIRECTORY)) { - highestPriorityThemePacks.put(themePack.manifest().id(), themePack); - } - for (InstalledThemePack themePack : listInstalled(THEME_PACKS_DIRECTORY)) { - highestPriorityThemePacks.put(themePack.manifest().id(), themePack); - } - - ArrayList result = new ArrayList<>(); - for (InstalledThemePack themePack : BUILTIN_THEME_PACKS) { - @Nullable InstalledThemePack selectedThemePack = highestPriorityThemePacks.remove(themePack.manifest().id()); - if (selectedThemePack != null) { - result.add(selectedThemePack); - } - } - - ArrayList remainingThemePacks = new ArrayList<>(highestPriorityThemePacks.values()); - remainingThemePacks.sort(Comparator - .comparing((InstalledThemePack themePack) -> themePack.manifest().displayName(), String.CASE_INSENSITIVE_ORDER) - .thenComparing(themePack -> themePack.manifest().id(), String.CASE_INSENSITIVE_ORDER)); - result.addAll(remainingThemePacks); - return List.copyOf(result); - } - - /// Installs a theme-pack file under the launcher's local theme-pack directory. - /// - /// Existing files for the same package ID are replaced. - /// - /// @param file the theme-pack file - /// @return the installed theme pack - /// @throws IOException if the theme pack cannot be validated or installed - public static InstalledThemePack install(Path file) throws IOException { - LoadedThemePack loadedThemePack = load(file); - validateThemePackFile(loadedThemePack.file()); - - Path targetFile = installedThemePackFile(THEME_PACKS_DIRECTORY, loadedThemePack.manifest().id()); - Path temporaryFile = FileUtils.tmpSaveFile(targetFile); - - Files.createDirectories(Objects.requireNonNull(targetFile.getParent())); - deleteIfExists(temporaryFile); - try { - Files.copy(loadedThemePack.file(), temporaryFile, StandardCopyOption.REPLACE_EXISTING); - moveReplacing(temporaryFile, targetFile); - return new InstalledThemePack(targetFile, loadedThemePack.manifest()); - } catch (IOException | RuntimeException e) { - try { - deleteIfExists(temporaryFile); - } catch (IOException suppressed) { - e.addSuppressed(suppressed); - } - throw e; - } - } - - /// Removes an installed theme pack from a managed theme-pack directory. - /// - /// If the removed package is currently selected, the stored theme reference is cleared. - /// - /// @param themePack the installed theme pack to remove - /// @throws IOException if the installed package cannot be removed - public static void uninstall(InstalledThemePack themePack) throws IOException { - Objects.requireNonNull(themePack); - - if (themePack.builtin()) { - throw new IOException("Cannot delete a built-in theme pack: " + themePack.manifest().id()); - } - - @Nullable Path file = themePack.file(); - if (file == null) { - throw new IOException("Theme pack does not have a local file: " + themePack.manifest().id()); - } - - Path targetFile = file.toAbsolutePath().normalize(); - Path localDirectory = THEME_PACKS_DIRECTORY.toAbsolutePath().normalize(); - Path userDirectory = USER_THEME_PACKS_DIRECTORY.toAbsolutePath().normalize(); - boolean localThemePack = targetFile.startsWith(localDirectory) && !targetFile.equals(localDirectory); - boolean userThemePack = targetFile.startsWith(userDirectory) && !targetFile.equals(userDirectory); - if (!localThemePack && !userThemePack) { - throw new IOException("Theme-pack file is outside the managed directory: " + targetFile); - } - - deleteIfExists(targetFile); - - ThemeReference reference = settings().getSelectedThemeOrDefault(); - ThemePackManifest manifest = themePack.manifest(); - if (reference.packId().equals(manifest.id())) { - @Nullable InstalledThemePack replacementThemePack = findInstalled(reference); - @Nullable Theme replacementTheme = replacementThemePack == null - ? null - : replacementThemePack.manifest().findTheme(reference.themeId()); - if (replacementTheme == null) { - settings().selectedThemeProperty().set(BUILTIN_DEFAULT_THEME_REFERENCE); - } - } - } - - /// Applies one theme from a loaded theme pack to current launcher settings. - /// - /// @param themePack the loaded theme pack - /// @param theme the theme to apply - /// @throws IOException if the pack cannot be installed, referenced assets cannot be read, or settings cannot be applied - public static void apply(LoadedThemePack themePack, Theme theme) throws IOException { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - - InstalledThemePack installedThemePack = install(themePack.file()); - @Nullable Theme installedTheme = installedThemePack.manifest().findTheme(theme.id()); - if (installedTheme == null) { - throw new IOException("Installed theme pack does not contain theme: " + theme.id()); - } - apply(installedThemePack, installedTheme); - } - - /// Applies one theme from an installed theme pack to current launcher settings. - /// - /// @param themePack the installed theme pack - /// @param theme the theme to apply - /// @throws IOException if referenced assets cannot be read or settings cannot be applied - public static void apply(InstalledThemePack themePack, Theme theme) throws IOException { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - - apply(themePack.manifest(), theme); - } - - /// Applies one theme to current launcher settings. - /// - /// @param manifest the parsed manifest - /// @param theme the theme to apply - /// @throws IOException if referenced assets cannot be read or settings cannot be applied - private static void apply(ThemePackManifest manifest, Theme theme) throws IOException { - Objects.requireNonNull(manifest); - Objects.requireNonNull(theme); - - ThemeReference reference = new ThemeReference(manifest.id(), theme.id()); - LauncherSettings currentSettings = settings(); - currentSettings.getThemeAppearanceOverrides().clear(); - currentSettings.selectedThemeProperty().set(reference); - } - - /// Exports the current launcher appearance to a theme-pack file. - /// - /// @param outputFile the target theme-pack file - /// @param packId the exported package identifier - /// @param version the exported package version - /// @param packName the exported package display name - /// @param authorName the exported package author name - /// @throws IOException if the current appearance cannot be exported - public static void exportCurrent(Path outputFile, String packId, String version, String packName, String authorName) throws IOException { - Objects.requireNonNull(outputFile); - - ExportedThemePack themePack = createCurrent(packId, version, packName, authorName); - try { - ThemePackExporter.export(themePack.manifest(), themePack.assets(), outputFile); - } finally { - for (Path temporaryFile : themePack.temporaryFiles()) { - try { - Files.deleteIfExists(temporaryFile); - } catch (IOException e) { - LOG.warning("Failed to delete temporary theme-pack asset: " + temporaryFile, e); - } - } - } - } - - /// Creates an exportable theme pack from the current launcher appearance. - /// - /// @param packId the exported package identifier - /// @param version the exported package version - /// @param packName the exported package display name - /// @param authorName the exported package author name - /// @return the exportable theme-pack descriptor - /// @throws IOException if the current appearance cannot be represented as a theme pack - public static ExportedThemePack createCurrent(String packId, String version, String packName, String authorName) throws IOException { - packId = ThemePackManifest.requirePackageId(packId); - version = requireNonBlank(version, "version"); - packName = requireNonBlank(packName, "packName"); - authorName = requireNonBlank(authorName, "authorName"); - - List assets = new ArrayList<>(); - List temporaryFiles = new ArrayList<>(); - try { - ThemeBackgroundSettings background = createCurrentBackground(assets, temporaryFiles); - ThemeAppearance appearance = new ThemeAppearance( - currentThemeColorSource(), - currentControlledBrightness(), - currentColorStyle(), - null, - background, - new ThemeTitleBar(currentTitleBarTransparent()), - currentWindowTransparent()); - Theme theme = new Theme( - null, - null, - List.of(), - null, - null, - appearance, - List.of()); - ThemePackManifest manifest = new ThemePackManifest( - packId, - version, - LocalizedText.plain(packName), - List.of(new ThemePackAuthor(LocalizedText.plain(authorName))), - background.source() instanceof ThemeBackground.Image image ? image.path() : null, - null, - List.of(theme)); - - return new ExportedThemePack(manifest, assets, temporaryFiles); - } catch (IOException | RuntimeException e) { - for (Path temporaryFile : temporaryFiles) { - try { - Files.deleteIfExists(temporaryFile); - } catch (IOException suppressed) { - e.addSuppressed(suppressed); - } - } - throw e; - } - } - - /// Returns the current launcher theme color source as a theme-pack directive. - private static ThemeColorSource currentThemeColorSource() throws IOException { - BackgroundType backgroundType = resolveCurrentBackground(currentResolveContext()).type(); - if (!SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR)) { - @Nullable ThemeColorSource colorSource = resolveCurrentThemeColorSource(currentResolveContext()); - if (colorSource != null) { - if (colorSource instanceof ThemeColorSource.Wallpaper && backgroundType == BackgroundType.THEME_COLOR) { - return ThemeColorSource.DEFAULT; - } - return colorSource; - } - return ThemeColorSource.DEFAULT; - } - - ThemeColorType themeColorType = Objects.requireNonNullElse( - settings().themeColorTypeProperty().get(), - ThemeColorType.DEFAULT); - if (themeColorType == ThemeColorType.DEFAULT) { - return ThemeColorSource.DEFAULT; - } - if (themeColorType == ThemeColorType.SYSTEM) { - return ThemeColorSource.custom(Themes.getSystemThemeColor()); - } - if (themeColorType == ThemeColorType.BACKGROUND) { - if (backgroundType == BackgroundType.THEME_COLOR) { - return ThemeColorSource.DEFAULT; - } - return ThemeColorSource.wallpaper(); - } - - ThemeColor color = Objects.requireNonNullElse(settings().customThemeColorProperty().get(), ThemeColor.DEFAULT); - return ThemeColorSource.custom(color); - } - - /// Returns the current launcher color style as a theme-pack directive. - private static ColorStyle currentColorStyle() throws IOException { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR_STYLE) - ? Objects.requireNonNullElse(settings().themeColorStyleProperty().get(), ResolvedTheme.DEFAULT.colorStyle()) - : Objects.requireNonNullElse( - resolveCurrentThemeColorStyle(currentResolveContext()), - ResolvedTheme.DEFAULT.colorStyle()); - } - - /// Returns the current launcher brightness as an explicit theme-pack directive, or `null` for auto mode. - private static @Nullable Brightness currentControlledBrightness() throws IOException { - if (!SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - return resolveCurrentThemeBrightness(currentResolveContext()); - } - - String brightness = settings().themeBrightnessModeProperty().get(); - if (StringUtils.isBlank(brightness)) { - return null; - } - return switch (brightness.trim().toLowerCase(Locale.ROOT)) { - case "light" -> Brightness.LIGHT; - case "dark" -> Brightness.DARK; - default -> null; - }; - } - - /// Returns whether the current title bar should be transparent. - private static boolean currentTitleBarTransparent() throws IOException { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_TITLE_BAR_TRANSPARENT) - ? settings().titleBarTransparentProperty().get() - : Objects.requireNonNullElse(resolveCurrentTitleBarTransparent(currentResolveContext()), false); - } - - /// Returns whether the current launcher window should be transparent. - private static boolean currentWindowTransparent() throws IOException { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT) - ? settings().windowTransparentProperty().get() - : Objects.requireNonNullElse(resolveCurrentWindowTransparent(currentResolveContext()), false); - } - - /// Resolves the selected theme's controlled brightness. - /// - /// @param context the condition resolution context - /// @return the selected theme brightness directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Brightness resolveCurrentThemeBrightness(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.brightness() : null; - } - - /// Resolves the selected theme color style directive. - /// - /// @param context the condition resolution context - /// @return the selected theme color style directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable ColorStyle resolveCurrentThemeColorStyle(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.colorStyle() : null; - } - - /// Resolves the selected theme contrast directive. - /// - /// @param context the condition resolution context - /// @return the selected theme contrast directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Contrast resolveCurrentThemeContrast(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.contrast() : null; - } - - /// Resolves the selected theme title-bar transparency directive. - /// - /// @param context the condition resolution context - /// @return the selected theme title-bar transparency directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Boolean resolveCurrentTitleBarTransparent(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null && appearance.titleBar() != null ? appearance.titleBar().transparent() : null; - } - - /// Resolves the selected theme window transparency directive. - /// - /// @param context the condition resolution context - /// @return the selected theme window transparency directive, or `null` when unavailable - /// @throws IOException if the selected theme pack cannot be read - public static @Nullable Boolean resolveCurrentWindowTransparent(ThemeResolveContext context) throws IOException { - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.windowTransparent() : null; - } - - /// Resolves the current selected theme color source without extracting any wallpaper pixels. - /// - /// @param context the condition resolution context - /// @return the selected theme color source, or `null` when unavailable - /// @throws IOException if the selected theme pack manifest cannot be read - public static @Nullable ThemeColorSource resolveCurrentThemeColorSource(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - @Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context); - return appearance != null ? appearance.color() : null; - } - - /// Resolves the selected theme appearance for a condition context. - private static @Nullable ThemeAppearance resolveCurrentThemeAppearance(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - ThemeReference reference = settings().getSelectedThemeOrDefault(); - - @Nullable InstalledThemePack themePack = findInstalled(reference); - if (themePack == null) { - return null; - } - - @Nullable Theme theme = themePack.manifest().findTheme(reference.themeId()); - if (theme == null) { - return null; - } - - return theme.resolve(context); - } - - /// Returns the current condition resolution context. - /// - /// @return the current resolution context - public static ThemeResolveContext currentResolveContext() { - return ThemeResolveContext.current(Themes.getThemeConditionBrightness()); - } - - /// Resolves the background currently selected by launcher settings. - /// - /// When the launcher background setting is inherited, this resolves the selected launcher theme background first. - /// Otherwise it resolves the direct launcher background fields. - /// - /// @param context the condition resolution context - /// @return the currently effective launcher background - /// @throws IOException if the referenced theme pack exists but its background cannot be resolved - public static ResolvedBackground resolveCurrentBackground(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - if (!SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND)) { - ThemeReference reference = settings().getSelectedThemeOrDefault(); - @Nullable ResolvedBackground resolved = resolveThemeBackground(reference, context); - if (resolved != null) { - return resolved; - } - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - currentBackgroundOpacityOrDefault()); - } - - return resolveCustomBackground(); - } - - /// Resolves the background that will be effective after applying the given theme. - /// - /// Applying a theme clears launcher appearance overrides, so a theme without a - /// background resolves to the launcher default background with built-in opacity. - /// - /// @param themePack the theme pack to apply - /// @param theme the theme to apply - /// @param context the condition resolution context - /// @return the background that should be displayed after the theme is applied - /// @throws IOException if the theme background cannot be resolved - public static ResolvedBackground resolveBackgroundAfterApplyingTheme( - InstalledThemePack themePack, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - Objects.requireNonNull(context); - - @Nullable ResolvedBackground resolved = resolveThemeBackground(themePack, theme, context); - if (resolved != null) { - return resolved; - } - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - 1.0); - } - - /// Resolves the fallback background used when the current launcher background cannot be loaded. - /// - /// @param context the condition resolution context - /// @return the effective fallback background - /// @throws IOException if the selected theme fallback exists but cannot be resolved - public static ResolvedBackground resolveCurrentBackgroundFallback(ThemeResolveContext context) throws IOException { - Objects.requireNonNull(context); - - BackgroundType fallbackType = Objects.requireNonNullElse( - settings().backgroundFallbackTypeProperty().get(), - BackgroundType.BUILTIN); - double opacity = currentBackgroundOpacityOrDefault(); - return switch (fallbackType) { - case BUILTIN -> new ResolvedBackground( - BackgroundType.BUILTIN, - BuiltinBackground.FALLBACK.id(), - null, - null, - null, - null, - opacity); - case PAINT -> new ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - settings().backgroundFallbackPaintProperty().get(), - opacity); - case THEME_COLOR -> new ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), - opacity); - case CUSTOM, NETWORK, DEFAULT -> new ResolvedBackground( - BackgroundType.BUILTIN, - BuiltinBackground.FALLBACK.id(), - null, - null, - null, - null, - opacity); - }; - } - - /// Resolves the background loading policy used by current launcher settings. - /// - /// @return the effective background loading policy - public static BackgroundLoadPolicy resolveCurrentBackgroundLoadPolicy() { - return Objects.requireNonNullElse( - settings().backgroundLoadPolicyProperty().get(), - BackgroundLoadPolicy.WAIT_FOR_BACKGROUND); - } - - /// Resolves the launcher custom background fields without considering theme-pack background references. - /// - /// @return the custom launcher background settings - public static ResolvedBackground resolveCustomBackground() { - BackgroundType type = Objects.requireNonNullElse(settings().backgroundTypeProperty().get(), BackgroundType.DEFAULT); - double opacity = currentBackgroundOpacityOrDefault(); - return switch (type) { - case DEFAULT -> new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - opacity); - case BUILTIN -> new ResolvedBackground( - BackgroundType.BUILTIN, - currentBuiltinBackgroundId(), - null, - null, - null, - null, - opacity); - case CUSTOM -> { - @Nullable String customBackgroundImagePath = settings().customBackgroundImagePathProperty().get(); - @Nullable Path imagePath = StringUtils.isBlank(customBackgroundImagePath) - ? null - : Path.of(customBackgroundImagePath).toAbsolutePath().normalize(); - yield new ResolvedBackground( - BackgroundType.CUSTOM, - imagePath, - null, - null, - null, - opacity); - } - case NETWORK -> { - @Nullable String networkBackgroundImageUrl = settings().networkBackgroundImageUrlProperty().get(); - yield new ResolvedBackground( - BackgroundType.NETWORK, - null, - StringUtils.isBlank(networkBackgroundImageUrl) ? null : networkBackgroundImageUrl.trim(), - Objects.requireNonNullElse( - settings().networkBackgroundImageCachePolicyProperty().get(), - NetworkBackgroundImageCachePolicy.ENABLED), - null, - opacity); - } - case PAINT -> new ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - settings().customBackgroundPaintProperty().get(), - opacity); - case THEME_COLOR -> new ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), - opacity); - }; - } - - /// Resolves the background contributed by a selected installed theme. - /// - /// @param reference the selected theme reference - /// @param context the condition resolution context - /// @return the resolved background, or `null` when the theme or background is not available - /// @throws IOException if the installed theme pack exists but its background cannot be resolved - public static @Nullable ResolvedBackground resolveThemeBackground( - ThemeReference reference, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(reference); - Objects.requireNonNull(context); - - @Nullable InstalledThemePack themePack = findInstalled(reference); - if (themePack == null) { - return null; - } - - @Nullable Theme theme = themePack.manifest().findTheme(reference.themeId()); - if (theme == null) { - return null; - } - - @Nullable ResolvedBackground resolved = resolveThemeBackground(themePack, theme, context); - if (resolved == null) { - return null; - } - if (SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY)) { - resolved = resolved.withOpacity(currentBackgroundOpacity()); - } - if (resolved.type() == BackgroundType.NETWORK) { - resolved = resolved.withNetworkImageCachePolicy(Objects.requireNonNullElse( - settings().networkBackgroundImageCachePolicyProperty().get(), - NetworkBackgroundImageCachePolicy.ENABLED)); - } - return resolved; - } - - /// Resolves the background contributed by one theme without applying launcher appearance overrides. - /// - /// @param themePackFile the installed theme-pack file - /// @param theme the theme to resolve - /// @param context the condition resolution context - /// @return the resolved background, or `null` when the theme does not define one - /// @throws IOException if the theme background cannot be resolved - public static @Nullable ResolvedBackground resolveThemeBackground( - Path themePackFile, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(themePackFile); - Objects.requireNonNull(theme); - Objects.requireNonNull(context); - - return resolveThemeBackground(new ThemePackLocation.Local(themePackFile), theme, context); - } - - /// Resolves the background contributed by one installed theme without applying launcher appearance overrides. - /// - /// @param themePack the installed theme pack - /// @param theme the theme to resolve - /// @param context the condition resolution context - /// @return the resolved background, or `null` when the theme does not define one - /// @throws IOException if the theme background cannot be resolved - public static @Nullable ResolvedBackground resolveThemeBackground( - InstalledThemePack themePack, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(themePack); - return resolveThemeBackground(themePack.location(), theme, context); - } - - /// Resolves the background contributed by one theme without applying launcher appearance overrides. - private static @Nullable ResolvedBackground resolveThemeBackground( - ThemePackLocation location, - Theme theme, - ThemeResolveContext context) throws IOException { - Objects.requireNonNull(location); - Objects.requireNonNull(theme); - Objects.requireNonNull(context); - - ThemeAppearance appearance = theme.resolve(context); - @Nullable ThemeBackgroundSettings background = appearance.background(); - if (background == null) { - return null; - } - return resolveBackground( - location, - background, - 1.0, - appearance.toResolvedTheme(context).toColorScheme().getColor(ColorRole.SURFACE_CONTAINER)); - } - - /// Resolves a concrete launcher background from a theme-pack background object. - private static ResolvedBackground resolveBackground( - ThemePackLocation location, - ThemeBackgroundSettings background, - double fallbackOpacity, - Paint themeColorPaint) throws IOException { - double opacity = Objects.requireNonNullElse(background.opacity(), fallbackOpacity); - @Nullable ThemeBackground source = background.source(); - if (source instanceof ThemeBackground.Default) { - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - opacity); - } - if (source instanceof ThemeBackground.Builtin builtin) { - return new ResolvedBackground( - BackgroundType.BUILTIN, - resolveBuiltinBackgroundId(builtin.id()), - null, - null, - null, - null, - opacity); - } - if (source instanceof ThemeBackground.Image image) { - ThemePackResource resource = resolveInstalledAsset( - location, - requireNonBlank(image.path(), "background.path")); - return new ResolvedBackground( - BackgroundType.CUSTOM, - resource.file(), - resource, - opacity); - } - if (source instanceof ThemeBackground.ThemeColor) { - return new ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - themeColorPaint, - opacity); - } - if (source instanceof ThemeBackground.Paint paint) { - return new ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - parsePaint(requireNonBlank(paint.paint(), "background.paint")), - opacity); - } - return new ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - opacity); - } - - /// Resolves one asset referenced by an installed theme pack. - /// - /// @param location the installed theme-pack location - /// @param entryName the theme-pack relative asset entry name - /// @return the resolved resource - /// @throws IOException if the asset cannot be read - public static ThemePackResource resolveInstalledAsset(ThemePackLocation location, String entryName) throws IOException { - Objects.requireNonNull(location); - String normalizedEntryName = ThemePackAsset.normalizeEntryName(entryName); - if (location instanceof ThemePackLocation.Builtin builtin) { - return resolveBuiltinAsset(builtin, normalizedEntryName); - } - - @Nullable Path file = location.file(); - if (file == null) { - throw new IOException("Theme pack location has no readable file: " + location); - } - Path installedFile = file.toAbsolutePath().normalize(); - if (Files.isDirectory(installedFile)) { - Path assetFile = installedFile.resolve(normalizedEntryName).normalize(); - if (!assetFile.startsWith(installedFile)) { - throw new IOException("Theme-pack asset escapes the installed directory: " + normalizedEntryName); - } - if (!Files.isRegularFile(assetFile)) { - throw new IOException("Installed theme-pack asset is missing: " + normalizedEntryName); - } - return new ThemePackResource.File(assetFile, normalizedEntryName); - } - if (!Files.isRegularFile(installedFile)) { - throw new IOException("Installed theme-pack file is missing: " + installedFile); - } - - try (ZipArchiveReader zipFile = new ZipArchiveReader(installedFile, StandardCharsets.UTF_8)) { - ZipArchiveEntry entry = zipFile.getEntry(normalizedEntryName); - if (entry == null || entry.isDirectory()) { - throw new IOException("Installed theme-pack asset is missing: " + normalizedEntryName); - } - } - return new ThemePackResource.Zip(installedFile, normalizedEntryName); - } - - /// Resolves one asset stored in a bundled theme pack. - private static ThemePackResource resolveBuiltinAsset(ThemePackLocation.Builtin builtin, String entryName) throws IOException { - String id = ThemePackManifest.requirePackageId(builtin.id()); - String resourcePath = "/assets/themes/" + id + "/" + entryName; - try (InputStream input = ThemePackManager.class.getResourceAsStream(resourcePath)) { - if (input != null) { - return new ThemePackResource.Builtin(resourcePath, entryName); - } - } - throw new IOException("Built-in theme-pack asset is missing: " + entryName); - } - - /// Returns the installed theme-pack file under a theme-pack directory for one package ID. - private static Path installedThemePackFile(Path themePacksDirectory, String packId) { - String id = ThemePackManifest.requirePackageId(packId); - return themePacksDirectory - .resolve(id + ThemePackExporter.FILE_EXTENSION) - .toAbsolutePath() - .normalize(); - } - - /// Returns whether a path is an installed theme-pack file candidate. - private static boolean isInstalledThemePackFile(Path path) { - String fileName = path.getFileName().toString(); - return !fileName.startsWith(".") - && fileName.endsWith(ThemePackExporter.FILE_EXTENSION); - } - - /// Returns whether a path is an unpacked installed theme-pack directory candidate. - private static boolean isInstalledThemePackDirectory(Path path) { - String fileName = path.getFileName().toString(); - return !fileName.startsWith(".") - && Files.isDirectory(path) - && Files.isRegularFile(path.resolve(ThemePackExporter.MANIFEST_ENTRY)); - } - - /// Returns whether a path is an installed theme-pack candidate. - private static boolean isInstalledThemePackPath(Path path) { - return Files.isRegularFile(path) - ? isInstalledThemePackFile(path) - : isInstalledThemePackDirectory(path); - } - - /// Lists theme packs from one filesystem theme-pack directory. - private static @Unmodifiable List listInstalled(Path themePacksDirectory) throws IOException { - if (!Files.isDirectory(themePacksDirectory)) { - return List.of(); - } - - ArrayList themePacks = new ArrayList<>(); - try (Stream themePackFiles = Files.list(themePacksDirectory)) { - for (Path themePackFile : themePackFiles - .filter(ThemePackManager::isInstalledThemePackPath) - .toList()) { - try { - themePacks.add(loadInstalled(themePackFile)); - } catch (IOException | RuntimeException e) { - LOG.warning("Failed to load installed theme pack: " + themePackFile, e); - } - } - } - - themePacks.sort(Comparator - .comparing((InstalledThemePack themePack) -> themePack.manifest().displayName(), String.CASE_INSENSITIVE_ORDER) - .thenComparing(themePack -> themePack.manifest().id(), String.CASE_INSENSITIVE_ORDER)); - return List.copyOf(themePacks); - } - - /// Finds a theme pack by package ID from one filesystem theme-pack directory. - private static @Nullable InstalledThemePack findInstalled(Path themePacksDirectory, String packId) throws IOException { - Path file = installedThemePackFile(themePacksDirectory, packId); - if (Files.isRegularFile(file)) { - InstalledThemePack themePack = loadInstalled(file); - if (themePack.manifest().id().equals(packId)) { - return themePack; - } - LOG.warning("Installed theme-pack file name does not match manifest id: " + file); - } - return findInstalledByManifestId(themePacksDirectory, packId, file); - } - - /// Finds an installed theme-pack path by package ID stored in its manifest. - private static @Nullable InstalledThemePack findInstalledByManifestId( - Path themePacksDirectory, - String packId, - Path excludedPath) throws IOException { - if (!Files.isDirectory(themePacksDirectory)) { - return null; - } - - Path excluded = excludedPath.toAbsolutePath().normalize(); - try (Stream themePackPaths = Files.list(themePacksDirectory)) { - for (Path themePackPath : themePackPaths - .filter(ThemePackManager::isInstalledThemePackPath) - .sorted(Comparator.comparing( - (Path path) -> path.getFileName().toString(), - String.CASE_INSENSITIVE_ORDER)) - .toList()) { - if (themePackPath.toAbsolutePath().normalize().equals(excluded)) { - continue; - } - try { - InstalledThemePack themePack = loadInstalled(themePackPath); - if (themePack.manifest().id().equals(packId)) { - return themePack; - } - } catch (IOException | RuntimeException e) { - LOG.warning("Failed to load installed theme pack: " + themePackPath, e); - } - } - } - return null; - } - - /// Finds a bundled theme pack by package ID. - private static @Nullable InstalledThemePack findBuiltinThemePack(String packId) { - for (InstalledThemePack themePack : BUILTIN_THEME_PACKS) { - if (themePack.manifest().id().equals(packId)) { - return themePack; - } - } - return null; - } - - /// Validates all zip entries in a theme-pack file. - private static void validateThemePackFile(Path themePackFile) throws IOException { - Set entries = new HashSet<>(); - boolean hasManifest = false; - - try (ZipArchiveReader zipFile = new ZipArchiveReader(themePackFile, StandardCharsets.UTF_8)) { - for (ZipArchiveEntry entry : zipFile.getEntries()) { - String rawEntryName = entry.getName(); - String entryName = normalizeThemePackEntryName(rawEntryName); - String canonicalEntryName = entry.isDirectory() ? entryName + "/" : entryName; - if (!canonicalEntryName.equals(rawEntryName)) { - throw new IOException("Theme-pack entry name is not normalized: " + rawEntryName); - } - checkSupportedThemePackEntry(entryName); - - if (!entries.add(entryName)) { - throw new IOException("Duplicate theme-pack entry: " + entryName); - } - if (ThemePackExporter.MANIFEST_ENTRY.equals(entryName) && !entry.isDirectory()) { - hasManifest = true; - } - } - } - - if (!hasManifest) { - throw new IOException("Theme pack does not contain " + ThemePackExporter.MANIFEST_ENTRY); - } - } - - /// Moves a file into place, using an atomic move when the platform supports it. - private static void moveReplacing(Path source, Path target) throws IOException { - try { - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - /// Returns a normalized and safe theme-pack zip entry name. - private static String normalizeThemePackEntryName(String entryName) throws IOException { - Objects.requireNonNull(entryName); - - String normalized = entryName.trim().replace('\\', '/'); - if (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - if (normalized.isEmpty()) { - throw new IOException("Theme-pack entry is empty"); - } - if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) { - throw new IOException("Theme-pack entry must be relative: " + entryName); - } - - for (String segment : normalized.split("/")) { - if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { - throw new IOException("Theme-pack 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 IOException("Theme-pack entry contains a control character: " + entryName); - } - } - } - return normalized; - } - - /// Checks that a theme-pack zip entry belongs to the current file layout. - private static void checkSupportedThemePackEntry(String entryName) throws IOException { - if (!ThemePackExporter.MANIFEST_ENTRY.equals(entryName) - && !"assets".equals(entryName) - && !entryName.startsWith("assets/")) { - throw new IOException("Unsupported theme-pack entry: " + entryName); - } - } - - /// Deletes an existing file, symbolic link, or directory tree. - private static void deleteIfExists(Path path) throws IOException { - if (Files.exists(path) || Files.isSymbolicLink(path)) { - FileUtils.forceDelete(path); - } - } - - /// Creates the background model for the current launcher settings. - private static ThemeBackgroundSettings createCurrentBackground( - List assets, - List temporaryFiles) throws IOException { - ResolvedBackground background = resolveCurrentBackground(currentResolveContext()); - Double opacity = background.opacity(); - return switch (background.type()) { - case DEFAULT -> new ThemeBackgroundSettings( - new ThemeBackground.Default(), - opacity); - case BUILTIN -> new ThemeBackgroundSettings( - new ThemeBackground.Builtin(background.builtinBackgroundId()), - opacity); - case CUSTOM -> new ThemeBackgroundSettings( - createCurrentImageBackgroundSource(assets, background), - opacity); - case NETWORK -> new ThemeBackgroundSettings( - createCurrentNetworkBackgroundSource(assets, temporaryFiles, background), - opacity); - case PAINT -> new ThemeBackgroundSettings( - new ThemeBackground.Paint(Objects.requireNonNullElse(background.paint(), Color.WHITE).toString()), - opacity); - case THEME_COLOR -> new ThemeBackgroundSettings( - new ThemeBackground.ThemeColor(), - opacity); - }; - } - - /// Creates the image background source for the resolved current background image. - private static ThemeBackground.Image createCurrentImageBackgroundSource( - List assets, - ResolvedBackground background) throws IOException { - @Nullable ThemePackResource imageResource = background.imageResource(); - if (imageResource != null) { - assets.add(new ThemePackAsset(imageResource, imageResource.name())); - return new ThemeBackground.Image(imageResource.name()); - } - - @Nullable Path imagePath = background.imagePath(); - if (imagePath == null) { - throw new IOException("Theme background image path is not configured"); - } - Path source = imagePath.toAbsolutePath().normalize(); - if (Files.isDirectory(source)) { - throw new IOException("Cannot export a background directory as a theme-pack asset: " + source); - } - if (!Files.isRegularFile(source)) { - throw new IOException("Theme background image does not exist: " + source); - } - - String entryName = "assets/wallpapers/" + sanitizePathSegment(source.getFileName().toString()); - assets.add(new ThemePackAsset(source, entryName)); - return new ThemeBackground.Image(entryName); - } - - /// Downloads the current network background and exports it as a theme-pack image asset. - private static ThemeBackground.Image createCurrentNetworkBackgroundSource( - List assets, - List temporaryFiles, - ResolvedBackground background) throws IOException { - String url = requireNonBlank(background.networkImageUrl(), "background.url"); - WebURL webUrl = WebURL.parse(url); - if (!NetworkUtils.isHttpUri(webUrl)) { - throw new IOException("Theme background URL must be HTTP or HTTPS: " + url); - } - - String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl); - if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) { - try { - @Nullable Path cachedFile = new CacheFileTask(webUrl).run(); - if (cachedFile != null && Files.isRegularFile(cachedFile)) { - assets.add(new ThemePackAsset(cachedFile, entryName)); - return new ThemeBackground.Image(entryName); - } - } catch (Exception e) { - LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e); - } - } - - Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp"); - boolean success = false; - try { - downloadNetworkBackground(webUrl, temporaryFile); - assets.add(new ThemePackAsset(temporaryFile, entryName)); - temporaryFiles.add(temporaryFile); - success = true; - return new ThemeBackground.Image(entryName); - } finally { - if (!success) { - try { - deleteIfExists(temporaryFile); - } catch (IOException e) { - LOG.warning("Failed to delete temporary theme-pack asset: " + temporaryFile, e); - } - } - } - } - - /// Downloads one network background into a temporary file without installing it into the persistent image cache. - private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException { - HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url)); - try { - int responseCode = connection.getResponseCode(); - if (responseCode / 100 != 2) { - throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url); - } - - ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection); - try (InputStream input = contentEncoding.wrap(connection.getInputStream()); - OutputStream output = Files.newOutputStream(outputFile)) { - IOUtils.copyTo(input, output, new byte[IOUtils.DEFAULT_BUFFER_SIZE]); - } - } finally { - connection.disconnect(); - } - } - - /// Returns a safe theme-pack asset file name for a downloaded network background. - private static String networkBackgroundAssetName(WebURL url) { - String path = Objects.toString(url.getPath(), ""); - @Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName(); - String fileName = fileNamePath != null ? fileNamePath.toString() : ""; - String sanitized = sanitizePathSegment(fileName); - return "_".equals(sanitized) ? "network-background" : sanitized; - } - - /// Returns the current launcher background opacity. - private static double currentBackgroundOpacity() { - double opacity = settings().backgroundOpacityProperty().get(); - if (!Double.isFinite(opacity)) { - return 1.0; - } - return Math.max(0.0, Math.min(1.0, opacity)); - } - - /// Returns the effective custom launcher background opacity, or the built-in default when not overridden. - private static double currentBackgroundOpacityOrDefault() { - return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY) - ? currentBackgroundOpacity() - : 1.0; - } - - /// Returns the currently selected built-in wallpaper ID. - private static String currentBuiltinBackgroundId() { - return BuiltinBackground.fromIdOrFallback(settings().builtinBackgroundIdProperty().get()).id(); - } - - /// Resolves a theme-pack built-in wallpaper ID. - private static String resolveBuiltinBackgroundId(@Nullable String id) throws IOException { - String normalizedId = StringUtils.isBlank(id) - ? BuiltinBackground.FALLBACK.id() - : id.trim().toLowerCase(Locale.ROOT); - if (BuiltinBackground.fromId(normalizedId) != null) { - return normalizedId; - } - throw new IOException("Theme packs cannot reference built-in wallpaper: " + normalizedId); - } - - /// Parses a serialized JavaFX paint value. - private static Paint parsePaint(String value) throws IOException { - try { - return Paint.valueOf(value); - } catch (IllegalArgumentException e) { - throw new IOException("Invalid theme background paint: " + value, e); - } - } - - /// Returns a non-blank string value. - private static String requireNonBlank(@Nullable String value, String name) throws IOException { - if (StringUtils.isBlank(value)) { - throw new IOException("Theme pack value is missing: " + name); - } - return value.trim(); - } - - /// Sanitizes one path segment used for exported asset files. - private static String sanitizePathSegment(String value) { - String sanitized = value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); - if (sanitized.isBlank()) { - return "_"; - } - return sanitized; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java deleted file mode 100644 index 2367c095b3..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackManifest.java +++ /dev/null @@ -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 authors, - @Nullable String icon, - @Nullable LocalizedText description, - @Unmodifiable List 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 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 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 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 localizedValues = new LinkedHashMap<>(); - for (Map.Entry 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 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; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java deleted file mode 100644 index 832f195150..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePackResource.java +++ /dev/null @@ -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; - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java deleted file mode 100644 index 5628224f0b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeReference.java +++ /dev/null @@ -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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java deleted file mode 100644 index f50bd0fee9..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeResolveContext.java +++ /dev/null @@ -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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java deleted file mode 100644 index 1c61e9c1ab..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeTitleBar.java +++ /dev/null @@ -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(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java index 9d54e6019c..cd5acb9d9e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java @@ -1,1187 +1,95 @@ -// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors +// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors // SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL package org.jackhuang.hmcl.theme; -import com.sun.jna.Pointer; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; -import javafx.beans.binding.ObjectBinding; -import javafx.beans.binding.ObjectExpression; -import javafx.beans.property.*; -import javafx.beans.value.ChangeListener; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.value.ObservableValue; -import javafx.collections.SetChangeListener; -import javafx.event.EventHandler; -import javafx.geometry.Insets; -import javafx.scene.image.Image; -import javafx.scene.layout.Background; -import javafx.scene.layout.BackgroundFill; -import javafx.scene.layout.BackgroundImage; -import javafx.scene.layout.BackgroundPosition; -import javafx.scene.layout.BackgroundRepeat; -import javafx.scene.layout.BackgroundSize; -import javafx.scene.layout.CornerRadii; +import javafx.beans.value.ObservableValueBase; import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; import javafx.stage.Stage; -import javafx.stage.WindowEvent; import org.glavo.monetfx.Brightness; import org.glavo.monetfx.ColorScheme; -import org.glavo.monetfx.ColorRole; +import org.glavo.monetfx.ColorSpecVersion; import org.glavo.monetfx.ColorStyle; import org.glavo.monetfx.Contrast; import org.glavo.monetfx.beans.property.ColorSchemeProperty; import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty; import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty; -import org.glavo.url.WebURL; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.BackgroundType; -import org.jackhuang.hmcl.setting.LauncherSettings; -import org.jackhuang.hmcl.setting.ThemeColorType; -import org.jackhuang.hmcl.task.CacheFileTask; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.MacOSNativeUtils; -import org.jackhuang.hmcl.ui.WindowsNativeUtils; -import org.jackhuang.hmcl.util.MathUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.NativeUtils; -import org.jackhuang.hmcl.util.platform.OSVersion; -import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.SystemUtils; -import org.jackhuang.hmcl.util.platform.windows.Dwmapi; -import org.jackhuang.hmcl.util.platform.windows.WinConstants; -import org.jackhuang.hmcl.util.platform.windows.WinReg; -import org.jackhuang.hmcl.util.platform.windows.WinTypes; import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.*; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage; -import static org.jackhuang.hmcl.util.io.FileUtils.getExtension; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -/// Provides the current launcher MonetFX theme and derived color bindings. +/// Provides the launcher's fixed warm light theme. +/// +/// The launcher no longer supports user themes, wallpapers or dark mode. The MonetFX color scheme here +/// is only used by the few controls that paint colors from Java; style sheets use `/assets/css/warm.css`. @NotNullByDefault public final class Themes { - /// The stage property retaining the listener for native appearance updates and marking completed registration. - private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener"; + /// The seed of the clay accent color. + private static final Color SEED = Color.web("#A9532F"); - /// The seed color extracted from the last loaded wallpaper image. - private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>(); + /// The fixed color scheme. + private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); - /// The resolved launcher theme used to build the current MonetFX color scheme. - private static final ObjectExpression theme = new ObjectBinding<>() { - { - List observables = new ArrayList<>(); + /// Always `false`: the launcher has a light appearance only. + private static final BooleanBinding darkMode = Bindings.createBooleanBinding(() -> false); - observables.add(settings().selectedThemeProperty()); - observables.add(settings().getThemeAppearanceOverrides()); - observables.add(settings().themeBrightnessModeProperty()); - observables.add(settings().customThemeColorProperty()); - observables.add(settings().themeColorTypeProperty()); - observables.add(settings().themeColorStyleProperty()); - observables.add(settings().backgroundTypeProperty()); - observables.add(settings().customBackgroundImagePathProperty()); - observables.add(settings().customBackgroundPaintProperty()); - observables.add(wallpaperThemeColor); - if (FXUtils.DARK_MODE != null) { - observables.add(FXUtils.DARK_MODE); - } - if (FXUtils.ACCENT_COLOR != null) { - observables.add(FXUtils.ACCENT_COLOR); - } - bind(observables.toArray(new Observable[0])); - } + /// Always `false`: the launcher does not follow the system brightness. + private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper(false); - /// Returns the configured MonetFX color style. - private ColorStyle getColorStyle() { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR_STYLE)) { - return Objects.requireNonNullElse( - settings().themeColorStyleProperty().get(), - ResolvedTheme.DEFAULT.colorStyle()); - } - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeColorStyle(ThemePackManager.currentResolveContext()), - ResolvedTheme.DEFAULT.colorStyle()); - } catch (IOException | RuntimeException e) { - return ResolvedTheme.DEFAULT.colorStyle(); - } - } - - /// Returns the configured MonetFX contrast. - private Contrast getContrast() { - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeContrast(ThemePackManager.currentResolveContext()), - ResolvedTheme.DEFAULT.contrast()); - } catch (IOException | RuntimeException e) { - return ResolvedTheme.DEFAULT.contrast(); - } - } - - /// Computes the resolved launcher theme. + /// The title text color. + private static final ObservableValue titleFill = new ObservableValueBase<>() { @Override - protected ResolvedTheme computeValue() { - return new ResolvedTheme(resolveCurrentThemeColor(), getCurrentBrightness(), getColorStyle(), getContrast()); + public Color getValue() { + return WarmPalette.INK; } }; - /// Returns the effective theme color for the current launcher settings. - static ThemeColor resolveCurrentThemeColor() { - ThemeColor fallback = settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR) - ? Objects.requireNonNullElse(settings().customThemeColorProperty().get(), ThemeColor.DEFAULT) - : ThemeColor.DEFAULT; - ThemeResolveContext context = ThemePackManager.currentResolveContext(); - BackgroundType backgroundType; - try { - backgroundType = ThemePackManager.resolveCurrentBackground(context).type(); - } catch (IOException | RuntimeException e) { - backgroundType = BackgroundType.DEFAULT; - } - return resolveThemeColor( - fallback, - settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR) - ? Objects.requireNonNullElse(settings().themeColorTypeProperty().get(), ThemeColorType.DEFAULT) - : null, - backgroundType, - context); - } - - /// Resolves a Monet seed color from configured theme color and background sources. - static ThemeColor resolveThemeColor( - ThemeColor fallback, - @Nullable ThemeColorType themeColorType, - BackgroundType backgroundType, - ThemeResolveContext context) { - Objects.requireNonNull(fallback); - Objects.requireNonNull(backgroundType); - Objects.requireNonNull(context); - - if (themeColorType == null) { - try { - return resolveThemeColorSource( - fallback, - ThemePackManager.resolveCurrentThemeColorSource(context), - backgroundType); - } catch (IOException | RuntimeException e) { - return fallback; - } - } - return switch (themeColorType) { - case DEFAULT -> ThemeColor.DEFAULT; - case SYSTEM -> getSystemThemeColor(); - case CUSTOM -> fallback; - case BACKGROUND -> resolveWallpaperThemeColor(fallback, backgroundType); - }; - } - - /// Resolves a theme-pack color source into a concrete Monet seed color. - private static ThemeColor resolveThemeColorSource( - ThemeColor fallback, - @Nullable ThemeColorSource source, - BackgroundType backgroundType) { - if (source == null) { - return fallback; - } - if (source instanceof ThemeColorSource.Default) { - return ThemeColor.DEFAULT; - } - if (source instanceof ThemeColorSource.Custom custom) { - return custom.color(); - } - return resolveWallpaperThemeColor(fallback, backgroundType); - } - - /// Resolves the Monet seed color represented by the effective wallpaper. - private static ThemeColor resolveWallpaperThemeColor(ThemeColor fallback, BackgroundType backgroundType) { - if (backgroundType == BackgroundType.THEME_COLOR) { - return ThemeColor.DEFAULT; - } - @Nullable ThemeColor loadedWallpaperColor = wallpaperThemeColor.get(); - if (loadedWallpaperColor != null) { - return loadedWallpaperColor; - } - return getBackgroundThemeColor(fallback); - } - - /// Returns a Monet seed color extracted from the current background when possible. - private static ThemeColor getBackgroundThemeColor(ThemeColor fallback) { - try { - ThemePackManager.ResolvedBackground background = - ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()); - return switch (background.type()) { - case CUSTOM -> getImageThemeColor(background.imagePath(), background.imageResource(), fallback); - case BUILTIN -> BuiltinBackground.fromIdOrFallback(background.builtinBackgroundId()).themeColor(); - case PAINT -> background.paint() instanceof Color color ? ThemeColor.of(color) : fallback; - case THEME_COLOR -> ThemeColor.DEFAULT; - case DEFAULT, NETWORK -> fallback; - }; - } catch (IOException | RuntimeException e) { - return fallback; - } - } - - /// Returns a Monet seed color extracted from the current custom background image. - private static ThemeColor getImageThemeColor( - @Nullable Path imageFile, - @Nullable ThemePackResource imageResource, - ThemeColor fallback) { - if (imageResource != null) { - try { - return WallpaperColorExtractor.extract(imageResource, fallback); - } catch (IOException | RuntimeException e) { - return fallback; - } - } - - if (imageFile == null) { - return fallback; - } - - try { - if (!Files.isRegularFile(imageFile)) { - return fallback; - } - return WallpaperColorExtractor.extract(imageFile, fallback); - } catch (IOException | RuntimeException e) { - return fallback; - } - } - - /// The current MonetFX color scheme generated from [#theme]. - private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); - - /// Whether the current color scheme uses dark brightness. - private static final BooleanBinding darkMode = Bindings.createBooleanBinding( - () -> colorScheme.get().getBrightness() == Brightness.DARK, - colorScheme - ); - - /// Whether the current color scheme brightness is inherited from system. - private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper(); - - /// The current JavaFX launcher background and its node opacity. - private static final ReadOnlyObjectWrapper background = new ReadOnlyObjectWrapper<>( - new LauncherBackground( - new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY)), - 1.0)); - - /// The last loaded background source used to rebuild the JavaFX background without reloading images. - private static @Nullable LoadedBackground loadedBackground; - - /// Current background load generation used to ignore obsolete asynchronous updates. - private static int backgroundLoadGeneration = 0; - - /// Whether JavaFX background loading has been requested by the UI. - private static boolean backgroundUpdatesStarted = false; - - /// Whether theme application is already installing a preloaded background. - private static boolean suppressBackgroundRefresh = false; - - /// A loaded JavaFX background source and the wallpaper color extracted while loading it. - private sealed interface LoadedBackground { - /// Returns the loaded JavaFX background and its node opacity. - LauncherBackground background(); - - /// Returns whether this background is the configured fallback background. - boolean fallbackBackground(); - - /// Returns the seed color extracted from the actual wallpaper image, or `null` when unavailable. - @Nullable ThemeColor wallpaperThemeColor(); - - /// A loaded image background. - /// - /// @param background the loaded JavaFX background - /// @param image the loaded image source - /// @param wallpaperThemeColor the seed color extracted from [#image], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - record Image( - LauncherBackground background, - javafx.scene.image.Image image, - @Nullable ThemeColor wallpaperThemeColor, - boolean fallbackBackground) implements LoadedBackground { - /// Creates a loaded image background. - /// - /// @param background the loaded JavaFX background - /// @param image the loaded image source - /// @param wallpaperThemeColor the seed color extracted from [#image], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - public Image { - Objects.requireNonNull(background); - Objects.requireNonNull(image); - } - } - - /// A loaded fixed-paint background. - /// - /// @param background the loaded JavaFX background - /// @param paint the paint source, or `null` for the launcher default paint - /// @param wallpaperThemeColor the seed color represented by [#paint], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - record Paint( - LauncherBackground background, - @Nullable javafx.scene.paint.Paint paint, - @Nullable ThemeColor wallpaperThemeColor, - boolean fallbackBackground) implements LoadedBackground { - /// Creates a loaded fixed-paint background. - /// - /// @param background the loaded JavaFX background - /// @param paint the paint source, or `null` for the launcher default paint - /// @param wallpaperThemeColor the seed color represented by [#paint], or `null` when unavailable - /// @param fallbackBackground whether this background is the configured fallback background - public Paint { - Objects.requireNonNull(background); - } - } - - /// A loaded background whose paint follows the current theme color scheme. - /// - /// @param background the loaded JavaFX background - /// @param fallbackBackground whether this background is the configured fallback background - record ThemeColorFill( - LauncherBackground background, - boolean fallbackBackground) implements LoadedBackground { - /// Creates a loaded theme-color background. - /// - /// @param background the loaded JavaFX background - /// @param fallbackBackground whether this background is the configured fallback background - public ThemeColorFill { - Objects.requireNonNull(background); - } - - @Override - public @Nullable ThemeColor wallpaperThemeColor() { - return null; - } - } - } - static { - Supplier<@Nullable Brightness> effectiveBrightnessSupplier = () -> { - try { - return ThemePackManager.resolveCurrentThemeBrightness(ThemePackManager.currentResolveContext()); - } catch (IOException | RuntimeException e) { - return null; - } - }; - InvalidationListener autoBrightnessListener = o -> { - boolean auto; - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - String val = settings().themeBrightnessModeProperty().get(); - auto = val == null || switch (val.trim().toLowerCase(Locale.ROOT)) { - case "light", "dark" -> false; - default -> true; - }; - } else { - auto = effectiveBrightnessSupplier.get() == null; - } - autoBrightness.set(auto); - }; - settings().selectedThemeProperty().addListener(autoBrightnessListener); - settings().getThemeAppearanceOverrides().addListener(autoBrightnessListener); - settings().themeBrightnessModeProperty().addListener(autoBrightnessListener); - settings().backgroundTypeProperty().addListener(autoBrightnessListener); - if (FXUtils.DARK_MODE != null) { - FXUtils.DARK_MODE.addListener(autoBrightnessListener); - } - autoBrightnessListener.invalidated(null); - - ChangeListener listener = (observable, oldValue, newValue) -> { - if (!Objects.equals(oldValue, newValue)) { - colorScheme.set(newValue.toColorScheme()); - } - }; - colorScheme.set(theme.get().toColorScheme()); - theme.addListener(listener); - - InvalidationListener backgroundListener = ignored -> { - if (backgroundUpdatesStarted && !suppressBackgroundRefresh) { - refreshBackground(); - } - }; - settings().selectedThemeProperty().addListener(backgroundListener); - settings().getThemeAppearanceOverrides().addListener((SetChangeListener) change -> { - String setting = change.wasAdded() ? change.getElementAdded() : change.getElementRemoved(); - switch (setting) { - case LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE, - LauncherSettings.THEME_APPEARANCE_BACKGROUND -> - backgroundListener.invalidated(settings().getThemeAppearanceOverrides()); - case LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY -> refreshBackgroundOpacity(); - default -> { - } - } - }); - settings().backgroundTypeProperty().addListener(backgroundListener); - settings().builtinBackgroundIdProperty().addListener(backgroundListener); - settings().customBackgroundImagePathProperty().addListener(backgroundListener); - settings().networkBackgroundImageUrlProperty().addListener(backgroundListener); - settings().networkBackgroundImageCachePolicyProperty().addListener(backgroundListener); - settings().customBackgroundPaintProperty().addListener(backgroundListener); - settings().backgroundOpacityProperty().addListener(ignored -> refreshBackgroundOpacity()); - settings().backgroundFallbackTypeProperty().addListener(ignored -> refreshFallbackBackground()); - settings().backgroundFallbackPaintProperty().addListener(ignored -> refreshFallbackBackground()); - settings().themeBrightnessModeProperty().addListener(backgroundListener); - if (FXUtils.DARK_MODE != null) { - FXUtils.DARK_MODE.addListener(backgroundListener); - } - colorSchemeProperty().addListener(ignored -> refreshThemeColorBackground()); + colorScheme.set(ColorScheme.newBuilder() + .setPrimaryColorSeed(SEED) + .setColorStyle(ColorStyle.FIDELITY) + .setBrightness(Brightness.LIGHT) + .setSpecVersion(ColorSpecVersion.SPEC_2025) + .setContrast(Contrast.DEFAULT) + .build()); } - /// Cached system default brightness. - private static @Nullable Brightness defaultBrightness; - - /// Returns the brightness used for resolving theme conditions without reading the selected theme's brightness value. - /// - /// @return the brightness used by theme condition matching - public static Brightness getThemeConditionBrightness() { - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - return getAutomaticBrightness(); - } - - String themeBrightnessMode = settings().themeBrightnessModeProperty().get(); - return switch (Objects.toString(themeBrightnessMode, "").trim().toLowerCase(Locale.ROOT)) { - case "light" -> Brightness.LIGHT; - case "dark" -> Brightness.DARK; - default -> getAutomaticBrightness(); - }; - } - - /// Returns the effective brightness from launcher, theme, and system settings. - /// - /// @return the effective launcher brightness - public static Brightness getCurrentBrightness() { - Brightness contextBrightness = getThemeConditionBrightness(); - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) { - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeBrightness(ThemeResolveContext.current(contextBrightness)), - contextBrightness); - } catch (IOException | RuntimeException ignored) { - } - } - return contextBrightness; - } - - /// Returns the brightness requested by the current system or platform settings. - private static Brightness getAutomaticBrightness() { - if (FXUtils.DARK_MODE != null) { - return FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT; - } - return getDefaultBrightness(); - } - - /// Detects and returns the system default brightness. - private static Brightness getDefaultBrightness() { - if (defaultBrightness != null) - return defaultBrightness; - - LOG.info("Detecting system theme brightness"); - Brightness brightness = Brightness.DEFAULT; - if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { - WinReg reg = WinReg.INSTANCE; - if (reg != null) { - Object appsUseLightTheme = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "AppsUseLightTheme"); - if (appsUseLightTheme instanceof Integer value) { - brightness = value == 0 ? Brightness.DARK : Brightness.LIGHT; - } - } - } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { - try { - String result = SystemUtils.run("/usr/bin/defaults", "read", "-g", "AppleInterfaceStyle").trim(); - brightness = "Dark".equalsIgnoreCase(result) ? Brightness.DARK : Brightness.LIGHT; - } catch (Exception e) { - // If the key does not exist, it means Light mode is used - brightness = Brightness.LIGHT; - } - } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) { - Path dbusSend = SystemUtils.which("dbus-send"); - if (dbusSend != null) { - try { - String[] result = SystemUtils.run(List.of( - FileUtils.getAbsolutePath(dbusSend), - "--session", - "--print-reply=literal", - "--reply-timeout=1000", - "--dest=org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings.Read", - "string:org.freedesktop.appearance", - "string:color-scheme" - ), Duration.ofSeconds(2)).trim().split(" "); - - if (result.length > 0) { - String value = result[result.length - 1]; - // 1: prefer dark - // 2: prefer light - if ("1".equals(value)) { - brightness = Brightness.DARK; - } else if ("2".equals(value)) { - brightness = Brightness.LIGHT; - } - } - } catch (Exception e) { - LOG.warning("Failed to get system theme from D-Bus", e); - } - } - } - LOG.info("Detected system theme brightness: " + brightness); - - return defaultBrightness = brightness; - } - - /// Returns the current operating system accent color, or the launcher default when unavailable. - public static ThemeColor getSystemThemeColor() { - if (FXUtils.ACCENT_COLOR == null) { - return ThemeColor.DEFAULT; - } - @Nullable Color accentColor = FXUtils.ACCENT_COLOR.get(); - return accentColor != null ? ThemeColor.of(accentColor) : ThemeColor.DEFAULT; - } - - /// Returns the current resolved launcher theme. - public static ResolvedTheme getTheme() { - return theme.get(); - } - - /// Returns the current MonetFX color scheme property. + /// Returns the color scheme property. public static ReadOnlyColorSchemeProperty colorSchemeProperty() { return colorScheme; } - /// Returns the current MonetFX color scheme. + /// Returns the color scheme. public static ColorScheme getColorScheme() { return colorScheme.get(); } - /// Returns the current JavaFX launcher background and node opacity property. - public static ReadOnlyObjectProperty backgroundProperty() { - startBackgroundUpdates(); - return background.getReadOnlyProperty(); - } - - /// Returns whether applying a theme should wait for its background before changing settings. - public static boolean shouldWaitForThemeBackground() { - return backgroundUpdatesStarted && getBackgroundLoadPolicy() == BackgroundLoadPolicy.WAIT_FOR_BACKGROUND; - } - - /// Creates a task that applies one theme and waits for its background when requested. - /// - /// @param themePack the installed theme pack - /// @param theme the selected theme - /// @return the task applying the theme - public static Task applyTheme(ThemePackManager.InstalledThemePack themePack, Theme theme) { - Objects.requireNonNull(themePack); - Objects.requireNonNull(theme); - - if (!backgroundUpdatesStarted || getBackgroundLoadPolicy() != BackgroundLoadPolicy.WAIT_FOR_BACKGROUND) { - return Task.runAsync(Schedulers.javafx(), () -> ThemePackManager.apply(themePack, theme)); - } - - ThemeResolveContext context = ThemeResolveContext.current(getAutomaticBrightness()); - return Task.supplyAsync(Schedulers.io(), () -> loadBackground(themePack, theme, context)) - .thenAcceptAsync(Schedulers.javafx(), loadedBackground -> { - suppressBackgroundRefresh = true; - try { - ThemePackManager.apply(themePack, theme); - applyLoadedBackground(loadedBackground, ++backgroundLoadGeneration); - } finally { - suppressBackgroundRefresh = false; - } - }); - } - - /// Starts JavaFX launcher background loading when the UI first requests it. - private static void startBackgroundUpdates() { - if (!backgroundUpdatesStarted) { - backgroundUpdatesStarted = true; - if (getBackgroundLoadPolicy() == BackgroundLoadPolicy.WAIT_FOR_BACKGROUND) { - loadInitialBackground(); - } else { - refreshBackground(); - } - } - } - - /// Returns the configured launcher background loading policy. - private static BackgroundLoadPolicy getBackgroundLoadPolicy() { - try { - return ThemePackManager.resolveCurrentBackgroundLoadPolicy(); - } catch (RuntimeException e) { - return BackgroundLoadPolicy.WAIT_FOR_BACKGROUND; - } - } - - /// Loads the initial JavaFX launcher background synchronously before the UI uses it. - private static void loadInitialBackground() { - final int currentGeneration = ++backgroundLoadGeneration; - applyLoadedBackground(loadBackground(), currentGeneration); - } - - /// Refreshes the current JavaFX launcher background asynchronously. - public static void refreshBackground() { - final int currentGeneration = ++backgroundLoadGeneration; - if (getBackgroundLoadPolicy() == BackgroundLoadPolicy.SHOW_FALLBACK_WHILE_LOADING) { - applyLoadedBackground(loadFallbackBackground(), currentGeneration); - } - Task.supplyAsync(Schedulers.io(), Themes::loadBackground) - .setName("Update background") - .whenComplete(Schedulers.javafx(), (newLoadedBackground, exception) -> { - if (exception == null) { - applyLoadedBackground(newLoadedBackground, currentGeneration); - } else { - LOG.warning("Failed to update background", exception); - } - }).start(); - } - - /// Applies one loaded background when it is still the newest requested update. - private static void applyLoadedBackground(LoadedBackground newLoadedBackground, int generation) { - if (backgroundLoadGeneration != generation) { - return; - } - - loadedBackground = newLoadedBackground; - background.set(newLoadedBackground.background()); - wallpaperThemeColor.set(newLoadedBackground.wallpaperThemeColor()); - } - - /// Loads the current JavaFX launcher background. - private static LoadedBackground loadBackground() { - @Nullable LoadedBackground loaded = tryLoadBackground(); - return loaded != null ? loaded : loadFallbackBackground(); - } - - /// Loads the background that will become effective after applying the given theme. - private static LoadedBackground loadBackground( - ThemePackManager.InstalledThemePack themePack, - Theme theme, - ThemeResolveContext context) { - try { - ThemePackManager.ResolvedBackground resolved = ThemePackManager.resolveBackgroundAfterApplyingTheme( - themePack, - theme, - context); - @Nullable LoadedBackground loaded = tryCreateResolvedBackground(resolved, false); - if (loaded != null) { - return loaded; - } - } catch (IOException | RuntimeException e) { - LOG.warning("Couldn't load theme background before applying theme", e); - } - return loadFallbackBackground(theme, context); - } - - /// Attempts to load the configured JavaFX launcher background. - private static @Nullable LoadedBackground tryLoadBackground() { - try { - return tryCreateResolvedBackground( - ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - LOG.warning("Couldn't resolve background", e); - return null; - } - } - - /// Creates a JavaFX launcher background from a resolved theme-pack background. - private static @Nullable LoadedBackground tryCreateResolvedBackground( - ThemePackManager.ResolvedBackground resolvedBackground, - boolean fallbackBackground) { - @Nullable Image image = null; - @Nullable ThemeColor imageThemeColor = null; - switch (resolvedBackground.type()) { - case CUSTOM: - @Nullable ThemePackResource imageResource = resolvedBackground.imageResource(); - if (imageResource != null) { - try { - image = FXUtils.loadImage(imageResource.openStream(), imageResource.name()); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - } - } else if (resolvedBackground.imagePath() != null) { - Path imagePath = resolvedBackground.imagePath(); - try { - image = Files.isDirectory(imagePath) - ? randomImageIn(imagePath) - : tryLoadImage(imagePath); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - } - } - break; - case NETWORK: - @Nullable String networkBackgroundImageUrl = resolvedBackground.networkImageUrl(); - if (networkBackgroundImageUrl != null) { - try { - image = loadNetworkBackgroundImage( - networkBackgroundImageUrl, - resolvedBackground.networkImageCachePolicy()); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - } - } - break; - case BUILTIN: - BuiltinBackground builtinBackground = - BuiltinBackground.fromIdOrFallback(resolvedBackground.builtinBackgroundId()); - image = loadBuiltinBackgroundImage(builtinBackground.id()); - imageThemeColor = builtinBackground.themeColor(); - break; - case PAINT: - @Nullable Paint paint = resolvedBackground.paint(); - return new LoadedBackground.Paint( - createPaintBackground(paint, resolvedBackground.opacity()), - paint, - paint instanceof Color color ? ThemeColor.of(color) : null, - fallbackBackground); - case THEME_COLOR: - return new LoadedBackground.ThemeColorFill( - createPaintBackground(resolvedBackground.paint(), resolvedBackground.opacity()), - fallbackBackground); - case DEFAULT: - image = loadLocalDefaultBackgroundImage(); - if (image == null) { - image = loadBuiltinBackgroundImage(BuiltinBackground.FALLBACK.id()); - imageThemeColor = BuiltinBackground.FALLBACK.themeColor(); - } - break; - } - if (image == null) { - return null; - } - return new LoadedBackground.Image( - createBackgroundWithOpacity(image, resolvedBackground.opacity()), - image, - imageThemeColor != null ? imageThemeColor : extractWallpaperThemeColor(image), - fallbackBackground); - } - - /// Loads the deterministic fallback background configured by launcher settings. - private static LoadedBackground loadFallbackBackground() { - try { - @Nullable LoadedBackground loaded = tryCreateResolvedBackground( - ThemePackManager.resolveCurrentBackgroundFallback(ThemePackManager.currentResolveContext()), - true); - if (loaded != null) { - return loaded; - } - } catch (IOException | RuntimeException e) { - LOG.warning("Couldn't resolve fallback background", e); - } - - Image image = loadBuiltinBackgroundImage(BuiltinBackground.FALLBACK.id()); - return new LoadedBackground.Image( - createBackgroundWithOpacity(image, getLoadedBackgroundOpacity(true)), - image, - BuiltinBackground.FALLBACK.themeColor(), - true); - } - - /// Loads the fallback background used while applying a not-yet-selected theme. - private static LoadedBackground loadFallbackBackground(Theme theme, ThemeResolveContext context) { - double opacity = 1.0; - ThemeAppearance appearance = theme.resolve(context); - @Nullable ThemeBackgroundSettings background = appearance.background(); - if (background != null && background.opacity() != null) { - opacity = background.opacity(); - } - - Paint themeColorPaint = appearance.toResolvedTheme(context).toColorScheme().getColor(ColorRole.SURFACE_CONTAINER); - BackgroundType fallbackType = Objects.requireNonNullElse( - settings().backgroundFallbackTypeProperty().get(), - BackgroundType.BUILTIN); - ThemePackManager.ResolvedBackground resolvedBackground = switch (fallbackType) { - case PAINT -> new ThemePackManager.ResolvedBackground( - BackgroundType.PAINT, - null, - null, - null, - settings().backgroundFallbackPaintProperty().get(), - opacity); - case THEME_COLOR -> new ThemePackManager.ResolvedBackground( - BackgroundType.THEME_COLOR, - null, - null, - null, - themeColorPaint, - opacity); - case DEFAULT, BUILTIN, CUSTOM, NETWORK -> new ThemePackManager.ResolvedBackground( - BackgroundType.BUILTIN, - BuiltinBackground.FALLBACK.id(), - null, - null, - null, - null, - opacity); - }; - @Nullable LoadedBackground loaded = tryCreateResolvedBackground(resolvedBackground, true); - if (loaded != null) { - return loaded; - } - - Image image = loadBuiltinBackgroundImage(BuiltinBackground.FALLBACK.id()); - return new LoadedBackground.Image( - createBackgroundWithOpacity(image, opacity), - image, - BuiltinBackground.FALLBACK.themeColor(), - true); - } - - /// Updates the background paint when the background explicitly follows the current color scheme. - private static void refreshThemeColorBackground() { - if (!backgroundUpdatesStarted) { - return; - } - @Nullable LoadedBackground loaded = loadedBackground; - if (loaded instanceof LoadedBackground.ThemeColorFill themeColorFill) { - LauncherBackground newBackground = - createThemeColorBackground(getLoadedBackgroundOpacity(loaded.fallbackBackground())); - loadedBackground = new LoadedBackground.ThemeColorFill(newBackground, themeColorFill.fallbackBackground()); - background.set(newBackground); - } - } - - /// Rebuilds the current fallback background after fallback settings change. - private static void refreshFallbackBackground() { - if (!backgroundUpdatesStarted) { - return; - } - @Nullable LoadedBackground loaded = loadedBackground; - if (loaded != null && loaded.fallbackBackground()) { - LoadedBackground fallback = loadFallbackBackground(); - loadedBackground = fallback; - background.set(fallback.background()); - wallpaperThemeColor.set(fallback.wallpaperThemeColor()); - } - } - - /// Rebuilds the current JavaFX background after opacity changes without reloading its source. - private static void refreshBackgroundOpacity() { - if (!backgroundUpdatesStarted) { - return; - } - @Nullable LoadedBackground loaded = loadedBackground; - if (loaded == null) { - return; - } - - double opacity = getLoadedBackgroundOpacity(loaded.fallbackBackground()); - LoadedBackground refreshed; - if (loaded instanceof LoadedBackground.Image imageBackground) { - refreshed = new LoadedBackground.Image( - new LauncherBackground( - imageBackground.background().background(), - MathUtils.clamp(opacity, 0., 1.)), - imageBackground.image(), - imageBackground.wallpaperThemeColor(), - imageBackground.fallbackBackground()); - } else if (loaded instanceof LoadedBackground.ThemeColorFill themeColorFill) { - refreshed = new LoadedBackground.ThemeColorFill( - new LauncherBackground( - themeColorFill.background().background(), - MathUtils.clamp(opacity, 0., 1.)), - themeColorFill.fallbackBackground()); - } else if (loaded instanceof LoadedBackground.Paint paintBackground) { - refreshed = new LoadedBackground.Paint( - new LauncherBackground( - paintBackground.background().background(), - MathUtils.clamp(opacity, 0., 1.)), - paintBackground.paint(), - paintBackground.wallpaperThemeColor(), - paintBackground.fallbackBackground()); - } else { - return; - } - - loadedBackground = refreshed; - background.set(refreshed.background()); - } - - /// Resolves the opacity for the currently loaded primary or fallback background. - private static double getLoadedBackgroundOpacity(boolean fallbackBackground) { - try { - return fallbackBackground - ? ThemePackManager.resolveCurrentBackgroundFallback(ThemePackManager.currentResolveContext()).opacity() - : ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()).opacity(); - } catch (IOException | RuntimeException e) { - double configured = settings().backgroundOpacityProperty().get(); - return settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY) && Double.isFinite(configured) - ? MathUtils.clamp(configured, 0., 1.) - : 1.0; - } - } - - /// Extracts a seed color from a loaded wallpaper image. - private static @Nullable ThemeColor extractWallpaperThemeColor(Image image) { - try { - ThemeColor fallback = !settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR) - ? ThemeColor.DEFAULT - : Objects.requireNonNullElse(settings().customThemeColorProperty().get(), ThemeColor.DEFAULT); - return WallpaperColorExtractor.extract(image, fallback); - } catch (RuntimeException e) { - LOG.warning("Couldn't extract theme color from background image", e); - return null; - } - } - - /// Loads one remote background image using the requested cache policy. - private static Image loadNetworkBackgroundImage( - String url, - @Nullable NetworkBackgroundImageCachePolicy cachePolicy) throws Exception { - if (cachePolicy == NetworkBackgroundImageCachePolicy.DISABLED) { - return FXUtils.loadImage(WebURL.parseBrowserInput(url)); - } - return FXUtils.loadImage(new CacheFileTask(url).run()); - } - - /// Creates a JavaFX paint background with a separately stored node opacity. - private static LauncherBackground createPaintBackground(@Nullable Paint paint, double opacity) { - return new LauncherBackground( - new Background(new BackgroundFill( - Objects.requireNonNullElse(paint, Color.WHITE), - CornerRadii.EMPTY, - Insets.EMPTY)), - MathUtils.clamp(opacity, 0., 1.)); - } - - /// Creates a JavaFX background from the current theme color scheme surface container. - private static LauncherBackground createThemeColorBackground(double opacity) { - return createPaintBackground(getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), opacity); - } - - /// Creates a JavaFX image background with a separately stored node opacity. - private static LauncherBackground createBackgroundWithOpacity(Image image, double opacity) { - Background imageBackground = new Background(new BackgroundImage( - image, - BackgroundRepeat.NO_REPEAT, - BackgroundRepeat.NO_REPEAT, - BackgroundPosition.DEFAULT, - new BackgroundSize( - BackgroundSize.AUTO, - BackgroundSize.AUTO, - false, - false, - false, - true) - )); - return new LauncherBackground(imageBackground, MathUtils.clamp(opacity, 0., 1.)); - } - - /// Loads a default launcher background image from local workspace files. - private static @Nullable Image loadLocalDefaultBackgroundImage() { - @Nullable Image image = randomImageIn(Metadata.HMCL_LOCAL_HOME.resolve("background")); - if (image != null) { - return image; - } - - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - image = tryLoadImage(Metadata.HMCL_LOCAL_HOME.resolve("background." + extension)); - if (image != null) { - return image; - } - } - - image = randomImageIn(Metadata.CURRENT_DIRECTORY.resolve("bg")); - if (image != null) { - return image; - } - - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - image = tryLoadImage(Metadata.CURRENT_DIRECTORY.resolve("background." + extension)); - if (image != null) { - return image; - } - } - - return null; - } - - /// Loads one built-in launcher wallpaper by ID. - private static Image loadBuiltinBackgroundImage(@Nullable String id) { - String wallpaperId = BuiltinBackground.fromIdOrFallback(id).id(); - return newBuiltinImage("/assets/img/wallpapers/" + wallpaperId + ".jpg"); - } - - /// Loads a random readable image from a directory. - private static @Nullable Image randomImageIn(Path imageDir) { - if (!Files.isDirectory(imageDir)) { - return null; - } - - ArrayList candidates; - try (Stream stream = Files.list(imageDir)) { - candidates = stream - .filter(it -> FXUtils.IMAGE_EXTENSIONS.contains(getExtension(it).toLowerCase(Locale.ROOT))) - .filter(Files::isReadable) - .collect(Collectors.toCollection(ArrayList::new)); - } catch (IOException e) { - LOG.warning("Failed to list files in " + imageDir, e); - return null; - } - - Random random = new Random(); - while (!candidates.isEmpty()) { - int selected = random.nextInt(candidates.size()); - @Nullable Image loaded = tryLoadImage(candidates.get(selected)); - if (loaded != null) { - return loaded; - } else { - candidates.remove(selected); - } - } - return null; - } - - /// Loads a readable image file. - private static @Nullable Image tryLoadImage(Path path) { - if (!Files.isReadable(path)) { - return null; - } - - try { - return FXUtils.loadImage(path); - } catch (Exception e) { - LOG.warning("Couldn't load background image", e); - return null; - } - } - - /// Whether the title area should be transparent after applying launcher and theme settings. - private static final BooleanBinding titleBarTransparent = Bindings.createBooleanBinding( - () -> { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_TITLE_BAR_TRANSPARENT)) { - return settings().titleBarTransparentProperty().get(); - } - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentTitleBarTransparent(ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - return false; - } - }, - settings().titleBarTransparentProperty(), - settings().getThemeAppearanceOverrides(), - settings().selectedThemeProperty(), - settings().themeBrightnessModeProperty(), - FXUtils.DARK_MODE != null ? FXUtils.DARK_MODE : settings().themeBrightnessModeProperty() - ); - - /// Whether the launcher window should be transparent after applying launcher and theme settings. - private static final BooleanBinding windowTransparent = Bindings.createBooleanBinding( - () -> { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT)) { - return settings().windowTransparentProperty().get(); - } - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentWindowTransparent(ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - return false; - } - }, - settings().windowTransparentProperty(), - settings().getThemeAppearanceOverrides(), - settings().selectedThemeProperty(), - settings().themeBrightnessModeProperty(), - FXUtils.DARK_MODE != null ? FXUtils.DARK_MODE : settings().themeBrightnessModeProperty() - ); - - /// The title text fill derived from the current color scheme. - private static final ObjectBinding titleFill = Bindings.createObjectBinding( - () -> titleBarTransparent.get() - ? getColorScheme().getOnSurface() - : getColorScheme().getOnPrimaryContainer(), - colorSchemeProperty(), - titleBarTransparent - ); - - /// Returns the title text fill property derived from the current color scheme. + /// Returns the title text color, which never changes. public static ObservableValue titleFillProperty() { return titleFill; } - /// Returns whether the title area should be transparent after applying launcher and theme settings. - public static BooleanBinding titleBarTransparentProperty() { - return titleBarTransparent; - } - - /// Returns whether the launcher window should be transparent after applying launcher and theme settings. + /// Returns whether the window is transparent; it never is. public static BooleanBinding windowTransparentProperty() { - return windowTransparent; + return darkMode; } - /// Returns whether the current color scheme uses dark brightness. + /// Returns whether the current appearance is dark; it never is. public static BooleanBinding darkModeProperty() { return darkMode; } - /// Returns whether the current color scheme brightness is inherited from system. + /// Returns whether the brightness is inherited from the system; it never is. public static ReadOnlyBooleanProperty autoBrightnessProperty() { return autoBrightness.getReadOnlyProperty(); } - /// Registers native dark-mode integration once per stage where the platform supports it. + /// Kept for callers that prepare secondary windows; there is no dark mode to apply. /// - /// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains - /// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do - /// nothing, including after hiding the stage or replacing its scene. - /// - /// @param stage the stage retaining the registration, accessed on the JavaFX application thread + /// @param stage the stage that would receive the native appearance public static void applyNativeDarkMode(Stage stage) { - if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) { - return; - } - if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) { - ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> { - if (stage.isShowing()) { - WindowsNativeUtils.getWindowHandle(stage).ifPresent(handle -> { - if (handle == WinTypes.HANDLE.INVALID_VALUE) - return; - - Dwmapi.INSTANCE.DwmSetWindowAttribute( - new WinTypes.HANDLE(Pointer.createConstant(handle)), - WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE, - new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)), - WinTypes.BOOL.SIZE - ); - }); - } - }); - stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener); - - if (stage.isShowing()) { - listener.changed(null, false, Themes.darkModeProperty().get()); - } else { - stage.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() { - @Override - public void handle(WindowEvent event) { - stage.removeEventFilter(WindowEvent.WINDOW_SHOWN, this); - listener.changed(null, false, Themes.darkModeProperty().get()); - } - }); - } - } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) { - MacOSNativeUtils.setAppearance(darkModeProperty().get()); - - ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance); - stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener); - } } /// Prevents instantiation. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java deleted file mode 100644 index 4d083b46d9..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/WallpaperColorExtractor.java +++ /dev/null @@ -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); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/WarmPalette.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/WarmPalette.java new file mode 100644 index 0000000000..9bddc098f6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/WarmPalette.java @@ -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"); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 36b6b433f4..88a8d16e06 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -39,10 +39,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 +86,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 +126,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; @@ -713,7 +670,6 @@ public final class Controllers { onApplicationStop(); rootPage = null; gameInstancePage = null; - gameListPage = null; accountListPage = null; settingsPage = null; decorator = null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPopupMenu.java index a5f997d516..384cef24bb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPopupMenu.java @@ -13,7 +13,10 @@ import javafx.scene.control.Label; import javafx.scene.layout.StackPane; 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 static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -31,12 +34,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(); @@ -52,6 +69,20 @@ public final class AccountListPopupMenu extends StackPane { box.add(item); } + // Footer actions: add another account, or open the full account page. + AdvancedListItem addItem = footerItem(SVG.ADD_CIRCLE, i18n("account.add")); + addItem.setOnAction(e -> { + hidePopup(); + Controllers.dialog(new CreateAccountPane()); + }); + AdvancedListItem manageItem = footerItem(SVG.SETTINGS, i18n("account.open_manage")); + manageItem.setOnAction(e -> { + hidePopup(); + Controllers.navigate(Controllers.getAccountListPage()); + }); + box.add(addItem); + box.add(manageItem); + // https://github.com/HMCL-dev/HMCL/issues/5343 Platform.runLater(() -> box.setVvalue(0.0)); }; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index a7b462c8e2..3ba9c324d3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -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(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/FirstRunAccountDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/FirstRunAccountDialog.java new file mode 100644 index 0000000000..700f371ac6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/FirstRunAccountDialog.java @@ -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(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java index 439be301f2..705a24809b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java @@ -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), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java index 02973b83b6..1cb0ef9eb2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java @@ -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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java deleted file mode 100644 index 7813ccce75..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java +++ /dev/null @@ -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); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java deleted file mode 100644 index d7ea14c192..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java +++ /dev/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 { - - 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; - }); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java deleted file mode 100644 index fe1b5f3363..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java +++ /dev/null @@ -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 extends TableCell { - public static Callback, TableCell> forTableColumn( - final TableColumn 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()) - )); - } - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineFileChooserButton.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineFileChooserButton.java deleted file mode 100644 index 835ed4d7e8..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineFileChooserButton.java +++ /dev/null @@ -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; - - public ObjectProperty 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 initialFileName; - - public final ObjectProperty 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 extensionFilters; - - public ObservableList 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 - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableSwitch.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableSwitch.java index b0b91a9f53..2e3c36f85c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableSwitch.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableSwitch.java @@ -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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoneMultipleSelectionModel.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoneMultipleSelectionModel.java deleted file mode 100644 index 44b586916c..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoneMultipleSelectionModel.java +++ /dev/null @@ -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 extends MultipleSelectionModel { - - public NoneMultipleSelectionModel() { - } - - @Override - public ObservableList getSelectedIndices() { - return FXCollections.emptyObservableList(); - } - - @Override - public ObservableList 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() { - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java index 28d7ce7e87..8e21c52d9c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java @@ -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; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java index ff94c48283..1b2d8257bd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java @@ -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. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java deleted file mode 100644 index 64da1d4482..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ /dev/null @@ -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)); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java deleted file mode 100644 index 213aaad28d..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ /dev/null @@ -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(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java index d2a0af4ca1..01a09b25d7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java @@ -1348,6 +1348,9 @@ public final class GameSettingsPage 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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java deleted file mode 100644 index 731a9aa83c..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/AddonUpdatesPage.java +++ /dev/null @@ -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 = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update"))); - - private final DownloadProvider downloadProvider; - private final Path localAddonDirectory; - private final ObservableList objects; - - private final JFXListView 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 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 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 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 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 { - 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 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(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(); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DataPackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DataPackListPage.java deleted file mode 100644 index dbd7a7ac90..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DataPackListPage.java +++ /dev/null @@ -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 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 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 res = Controllers.showOpenMultipleDialog(chooser); - if (res != null) { - installMultiDataPack(res); - } - - dataPack.loadFromDir(); - } - - void removeSelected(ObservableList selectedItems) { - List 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 selectedItems, boolean enabled) { - List 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()); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DataPackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DataPackListPageSkin.java deleted file mode 100644 index aec84dd601..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DataPackListPageSkin.java +++ /dev/null @@ -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 { - - private final TransitionPane toolbarPane; - private final HBox searchBar; - private final HBox normalToolbar; - private final HBox selectingToolbar; - - private final JFXListView 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 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) 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 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 { - 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 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())); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java deleted file mode 100644 index fca50e264b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ /dev/null @@ -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 = new ReadOnlyObjectWrapper<>(); - private final BooleanProperty loading = new SimpleBooleanProperty(false); - private final BooleanProperty failed = new SimpleBooleanProperty(false); - private final boolean instanceSelection; - private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); - private final IntegerProperty pageOffset = new SimpleIntegerProperty(0); - private final IntegerProperty pageCount = new SimpleIntegerProperty(-1); - private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); - private final ObservableList instances = FXCollections.observableArrayList(); - private final ObjectProperty selectedInstance = new SimpleObjectProperty<>(); - private final DownloadPage.DownloadCallback callback; - private boolean searchInitialized = false; - protected final BooleanProperty supportChinese = new SimpleBooleanProperty(); - private final ObservableList actions = FXCollections.observableArrayList(); - protected final ListProperty 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 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 stateProperty() { - return state.getReadOnlyProperty(); - } - - @Override - protected Skin createDefaultSkin() { - return new ModDownloadListPageSkin(this); - } - - private static class ModDownloadListPageSkin extends SkinBase { - private final JFXListView 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 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 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 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 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 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 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 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 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 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 actions = new AggregatedObservableList<>(); - - Holder 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 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 { - private final DownloadListPage page; - private final JFXListView 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 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); - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java deleted file mode 100644 index 039b64b1c5..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java +++ /dev/null @@ -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 = 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> 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 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> sortVersions(Stream versions) { - SimpleMultimap> 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 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 stateProperty() { - return state.getReadOnlyProperty(); - } - - @Override - protected Skin createDefaultSkin() { - return new DownloadPageSkin(this); - } - - private static class DownloadPageSkin extends SkinBase { - - 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 addonVersions = control.versions.get(gameVersion); - if (addonVersions != null && !addonVersions.isEmpty()) { - Set 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 target) { - var sublist = new ComponentSublist(() -> { - ArrayList items = new ArrayList<>(); - for (String gv : target) { - List 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 releases = new ArrayList<>(); - final List snapshots = new ArrayList<>(); - } - - TreeMap 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 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 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>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class); - AtomicBoolean hasBroken = new AtomicBoolean(false); - List> 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 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); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 673c2fc04f..21a7b0ccb4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -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> gameSettingsTab; - /// The component management tab bound to the current game instance. - private final TabItem installerListTab; - /// The installed mods tab bound to the current game instance. - private final TabItem modListTab; - /// The saved worlds tab bound to the current game instance. - private final TabItem worldListTab; - /// The installed resource packs tab bound to the current game instance. - private final TabItem resourcePackTab; /// The screenshots tab bound to the current game instance. private final TabItem 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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java deleted file mode 100644 index 396a1d0e2e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ /dev/null @@ -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; - - 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 imageProperty() { - init(); - return image; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java deleted file mode 100644 index bd7c713d65..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java +++ /dev/null @@ -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 { - - 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 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; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java deleted file mode 100644 index d155e63111..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ /dev/null @@ -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); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java deleted file mode 100644 index 9083161e94..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java +++ /dev/null @@ -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 = 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 stateProperty() { - return state.getReadOnlyProperty(); - } - - private static class GameList extends ListPageBase { - private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - - private final ObservableList sourceList = FXCollections.observableArrayList(); - private final FilteredList 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 instanceItems = repository.getDisplayInstances() - .map(GameListItem::new) - .toList(); - - sourceList.setAll(instanceItems); - - if (instanceItems.isEmpty()) { - setFailedReason(i18n("instance.empty.hint")); - } - - setLoading(false); - } - - private Predicate 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 { - 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 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); - } - } - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java deleted file mode 100644 index 05c737281b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java +++ /dev/null @@ -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 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 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 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 getItems() { - return listView.getItems(); - } - - private static final class Cell extends ListCell { - - 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 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()); - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java deleted file mode 100644 index fb553768d2..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java +++ /dev/null @@ -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"); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java deleted file mode 100644 index e780a63b8b..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java +++ /dev/null @@ -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 { - 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 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 { - - InstallerListPageSkin() { - super(InstallerListPage.this); - } - - @Override - protected List initializeToolbar(InstallerListPage skinnable) { - return Collections.emptyList(); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index 5c2a137f23..416e175124 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -7,7 +7,6 @@ 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; @@ -18,7 +17,7 @@ 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; @@ -329,6 +328,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 +362,8 @@ public final class Instances { private static void ensureSelectedAccount(Consumer 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 +379,6 @@ public final class Instances { } public static void modifyGlobalSettings(HMCLGameRepository repository) { - Controllers.getSettingsPage().showGameSettings(repository); Controllers.navigate(Controllers.getSettingsPage()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java deleted file mode 100644 index 86bbc73fa7..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ /dev/null @@ -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 implements PageAware { - private final ReentrantLock lock = new ReentrantLock(); - private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - - private ModManager modManager; - private @Nullable HMCLGameInstance gameInstance; - - final EnumSet 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 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 res = Controllers.showOpenMultipleDialog(chooser); - - if (res == null) return; - - // It's guaranteed that succeeded and failed are thread safe here. - List succeeded = new ArrayList<>(res.size()); - List 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 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 selectedItems) { - List 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 selectedItems, boolean enabled) { - List 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 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 { - - private final TransitionPane toolbarPane; - private final HBox searchBar; - private final HBox toolbarNormal; - private final HBox toolbarSelecting; - - private final JFXListView 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 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) 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 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 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 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 versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile()); - if (versionOptional.isPresent()) { - RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId()); - FXUtils.runInFX(() -> { - Set 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 menu = new Lazy<>(PopupMenu::new); - private static final Lazy popup = new Lazy<>(() -> new JFXPopup(menu.get())); - - private static final class ModInfoListCell extends MDListCell { - 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 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 warning = new ArrayList<>(); - - var tags = new ArrayList(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); - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java deleted file mode 100644 index ea9289de18..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ /dev/null @@ -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 { - - 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 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 files) { - if (resourcePackManager == null) return; - - List 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 files = Controllers.showOpenMultipleDialog(fileChooser); - if (files != null && !files.isEmpty()) { - addFiles(files); - } - } - - private void onOpenFolder() { - if (resourcePackDirectory != null) { - FXUtils.openFolder(resourcePackDirectory); - } - } - - private void setSelectedEnabled(List 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 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 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 { - private final JFXListView 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 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 iconCache; - - public ResourcePackInfoObject(Pair 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 { - 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 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 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 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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldBackupTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldBackupTask.java deleted file mode 100644 index 38dbc79650..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldBackupTask.java +++ /dev/null @@ -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 { - - 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() { - @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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldBackupsPage.java deleted file mode 100644 index 18afa131c9..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldBackupsPage.java +++ /dev/null @@ -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 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("(?[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?[0-9]+))?\\.zip"); - - refresh(); - } - - public void refresh() { - setLoading(true); - Task.supplyAsync(() -> { - if (Files.isDirectory(backupsDir)) { - try (Stream paths = Files.list(backupsDir)) { - ArrayList 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(); - } - }).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 { - - 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 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 { - 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 { - - 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)); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldExportPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldExportPage.java deleted file mode 100644 index e9e59bd370..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldExportPage.java +++ /dev/null @@ -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())); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldExportPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldExportPageSkin.java deleted file mode 100644 index d7a472d55d..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldExportPageSkin.java +++ /dev/null @@ -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 { - - 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); - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldInfoPage.java deleted file mode 100644 index b738cd2afa..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldInfoPage.java +++ /dev/null @@ -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(); - { - 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(); - { - 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 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 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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java deleted file mode 100644 index d49749d032..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java +++ /dev/null @@ -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 { - private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); - private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - - private Path savesDir; - private List 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 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 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 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 { - - 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 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 createListCell(JFXListView listView) { - return new WorldListCell(getSkinnable()); - } - } - - private static final class WorldListCell extends ListCell { - - 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(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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java deleted file mode 100644 index cab26df1a0..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java +++ /dev/null @@ -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; - 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 worldInfoTab; - /// The tab for managing backups of this world. - private final TabItem worldBackupsTab; - /// The tab for managing data packs in this world. - private final TabItem 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 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(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManageUIUtils.java deleted file mode 100644 index b264f3e32d..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManageUIUtils.java +++ /dev/null @@ -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; - } - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/HomeArt.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/HomeArt.java new file mode 100644 index 0000000000..40486e53a9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/HomeArt.java @@ -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 stroke(T shape, String styleClass) { + shape.setFill(null); + shape.getStyleClass().addAll("home-art", styleClass); + return shape; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index 32e58c4fcc..5a4e971039 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -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 = 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> gameTab; /// The tab whose content manages installed Java runtimes. private final TabItem javaManagementTab; /// The tab for general launcher settings. private final TabItem settingsTab; - /// The tab for launcher appearance settings. - private final TabItem personalizationTab; /// The tab for download settings. private final TabItem 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 stateProperty() { return state.getReadOnlyProperty(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 702850522a..003aad6206 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -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 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 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 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. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java deleted file mode 100644 index 96496cc5bd..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ /dev/null @@ -1,1159 +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.main; - -import com.jfoenix.controls.*; -import com.jfoenix.effects.JFXDepthManager; -import javafx.application.Platform; -import javafx.beans.InvalidationListener; -import javafx.beans.binding.Bindings; -import javafx.beans.binding.StringBinding; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.Property; -import javafx.css.PseudoClass; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.*; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.*; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import javafx.scene.text.Font; -import javafx.scene.text.FontSmoothingType; -import org.glavo.monetfx.Brightness; -import org.glavo.monetfx.ColorStyle; -import org.jackhuang.hmcl.setting.*; -import org.jackhuang.hmcl.theme.*; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.Holder; -import org.jackhuang.hmcl.util.javafx.SafeStringConverter; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.Locale; -import java.util.Objects; -import java.util.Optional; -import java.util.function.Supplier; - -import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -/// Configures launcher appearance, background, and launcher font settings. -@NotNullByDefault -public class PersonalizationPage extends StackPane { - /// Property key for override-state button tooltips. - private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object(); - - /// Pseudo class used when an appearance setting overrides the selected theme. - private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden"); - - /// Style class shared with compact setting-state icon buttons. - private static final String INHERIT_BUTTON_STYLE_CLASS = "toggle-icon-tiny"; - - /// Size of compact setting-state icons. - private static final int INHERIT_BUTTON_ICON_SIZE = 12; - - /// Time format used by default exported theme names. - private static final DateTimeFormatter EXPORTED_THEME_NAME_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.ROOT); - - /// Snaps a percent value to the nearest 5% opacity tick. - private static double snapOpacity(double val) { - if (val <= 0) { - return 0.; - } else if (Double.isNaN(val) || val >= 100.) { - return 1.; - } - - int prevTick = (int) (val / 5); - int prevTickValue = prevTick * 5; - int nextTickValue = (prevTick + 1) * 5; - - int percent = (val - prevTickValue) > (nextTickValue - val) ? nextTickValue : prevTickValue; - return percent / 100.; - } - - /// Creates a compact button that toggles inherited or overridden state. - private static JFXButton createThemeAppearanceOverrideButton() { - var button = new JFXButton(); - button.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS); - button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); - Tooltip tooltip = new Tooltip(); - button.getProperties().put(INHERIT_BUTTON_TOOLTIP_KEY, tooltip); - FXUtils.installFastTooltip(button, tooltip); - updateThemeAppearanceOverrideButton(button, true); - return button; - } - - /// Updates the icon and pseudo class of a theme appearance override button. - private static void updateThemeAppearanceOverrideButton(JFXButton button, boolean inherited) { - button.setGraphic((inherited ? SVG.STYLE : SVG.EDIT).createIcon(INHERIT_BUTTON_ICON_SIZE)); - button.pseudoClassStateChanged(PSEUDO_OVERRIDDEN, !inherited); - - Object tooltip = button.getProperties().get(INHERIT_BUTTON_TOOLTIP_KEY); - if (tooltip instanceof Tooltip inheritTooltip) { - inheritTooltip.setText(i18n(inherited - ? "theme_pack.appearance.follow_theme.tooltip" - : "theme_pack.appearance.custom.tooltip")); - } - } - - /// Adds listeners for state that can change effective inherited appearance values. - private static void addThemeAppearanceRefreshListener(InvalidationListener listener) { - settings().selectedThemeProperty().addListener(listener); - settings().getThemeAppearanceOverrides().addListener(listener); - settings().themeBrightnessModeProperty().addListener(listener); - settings().backgroundTypeProperty().addListener(listener); - if (FXUtils.DARK_MODE != null) { - FXUtils.DARK_MODE.addListener(listener); - } - } - - /// Installs an inheritance button on a line select button. - private static void bindThemeAppearanceLineSelectButton( - LineSelectButton button, - String setting, - Property directProperty, - Supplier effectiveValueSupplier) { - JFXButton inheritButton = createThemeAppearanceOverrideButton(); - button.setTitleTrailing(inheritButton); - - Holder updating = new Holder<>(false); - InvalidationListener refresh = ignored -> { - if (updating.value) { - return; - } - updating.value = true; - try { - boolean overridden = settings().getThemeAppearanceOverrides().contains(setting); - button.setValue(overridden ? directProperty.getValue() : effectiveValueSupplier.get()); - updateThemeAppearanceOverrideButton(inheritButton, !overridden); - } finally { - updating.value = false; - } - }; - - button.valueProperty().addListener((observable, oldValue, newValue) -> { - if (updating.value) { - return; - } - updating.value = true; - try { - directProperty.setValue(newValue); - settings().getThemeAppearanceOverrides().add(setting); - updateThemeAppearanceOverrideButton(inheritButton, false); - } finally { - updating.value = false; - } - }); - directProperty.addListener(refresh); - addThemeAppearanceRefreshListener(refresh); - - inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { - if (!settings().getThemeAppearanceOverrides().contains(setting)) { - directProperty.setValue(effectiveValueSupplier.get()); - settings().getThemeAppearanceOverrides().add(setting); - } else { - settings().getThemeAppearanceOverrides().remove(setting); - } - refresh.invalidated(null); - event.consume(); - }); - refresh.invalidated(null); - } - - /// Binds a switch row to an inheritable theme appearance setting. - private static void bindThemeAppearanceSwitch( - LineSwitch button, - String setting, - BooleanProperty directProperty, - Supplier effectiveValueSupplier) { - JFXButton inheritButton = createThemeAppearanceOverrideButton(); - button.setTitleTrailing(inheritButton); - - Holder updating = new Holder<>(false); - InvalidationListener refresh = ignored -> { - if (updating.value) { - return; - } - updating.value = true; - try { - boolean overridden = settings().getThemeAppearanceOverrides().contains(setting); - button.setSelected(overridden ? directProperty.get() : effectiveValueSupplier.get()); - updateThemeAppearanceOverrideButton(inheritButton, !overridden); - } finally { - updating.value = false; - } - }; - - button.selectedProperty().addListener((observable, oldValue, newValue) -> { - if (updating.value) { - return; - } - updating.value = true; - try { - directProperty.set(Boolean.TRUE.equals(newValue)); - settings().getThemeAppearanceOverrides().add(setting); - updateThemeAppearanceOverrideButton(inheritButton, false); - } finally { - updating.value = false; - } - refresh.invalidated(null); - }); - directProperty.addListener(refresh); - addThemeAppearanceRefreshListener(refresh); - - inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { - if (!settings().getThemeAppearanceOverrides().contains(setting)) { - directProperty.set(button.isSelected()); - settings().getThemeAppearanceOverrides().add(setting); - } else { - settings().getThemeAppearanceOverrides().remove(setting); - } - refresh.invalidated(null); - event.consume(); - }); - refresh.invalidated(null); - } - - /// Creates the launcher appearance settings page. - public PersonalizationPage() { - VBox content = new VBox(10); - content.setPadding(new Insets(10)); - content.setFillWidth(true); - ScrollPane scrollPane = new ScrollPane(content); - FXUtils.smoothScrolling(scrollPane); - scrollPane.setFitToWidth(true); - getChildren().setAll(scrollPane); - - ComponentList themeAppearanceList = new ComponentList(); - ComponentList backgroundLoadingList = new ComponentList(); - - { - LineSelectButton brightnessPane = new LineSelectButton<>(); - brightnessPane.setTitle(i18n("settings.launcher.brightness")); - brightnessPane.setConverter(name -> i18n("settings.launcher.brightness." - + Objects.requireNonNullElse(name, "auto").trim().toLowerCase(Locale.ROOT))); - brightnessPane.setItems(Arrays.asList("auto", "light", "dark")); - bindThemeAppearanceLineSelectButton( - brightnessPane, - LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE, - settings().themeBrightnessModeProperty(), - () -> { - try { - @Nullable Brightness brightness = ThemePackManager.resolveCurrentThemeBrightness( - ThemePackManager.currentResolveContext()); - return brightness == null - ? "auto" - : brightness == Brightness.DARK ? "dark" : "light"; - } catch (IOException | RuntimeException e) { - return "auto"; - } - }); - - themeAppearanceList.getContent().add(brightnessPane); - } - - { - ComponentSublist themeColorSublist = new ComponentSublist(); - themeColorSublist.setTitle(i18n("settings.launcher.theme_color")); - themeColorSublist.setHasSubtitle(true); - Supplier<@Nullable ThemeColorSource> effectiveThemeColorSource = () -> { - try { - return ThemePackManager.resolveCurrentThemeColorSource(ThemePackManager.currentResolveContext()); - } catch (IOException | RuntimeException e) { - return null; - } - }; - Supplier effectiveThemeColorType = () -> { - @Nullable ThemeColorSource source = effectiveThemeColorSource.get(); - if (source instanceof ThemeColorSource.Wallpaper) { - return ThemeColorType.BACKGROUND; - } - if (source instanceof ThemeColorSource.Custom) { - return ThemeColorType.CUSTOM; - } - return ThemeColorType.DEFAULT; - }; - - ColorPicker picker = new JFXColorPicker(); - picker.getCustomColors().setAll(ThemeColor.STANDARD_COLORS.stream().map(ThemeColor::color).toList()); - Platform.runLater(() -> JFXDepthManager.setDepth(picker, 0)); - - var defaultColorChoice = new RadioChoiceList.Choice( - i18n("settings.launcher.theme_color_type.default"), - ThemeColorType.DEFAULT); - - var systemColorChoice = new RadioChoiceList.Choice( - i18n("settings.launcher.theme_color_type.system"), - ThemeColorType.SYSTEM); - - var customColorChoice = new RadioChoiceList.Choice( - i18n("settings.launcher.theme_color_type.custom"), - ThemeColorType.CUSTOM) { - @Override - protected Node createRightNode() { - return picker; - } - }; - customColorChoice.setSubtitle(i18n("settings.launcher.theme_color_type.custom.description")); - - var backgroundColorChoice = new RadioChoiceList.Choice( - i18n("settings.launcher.theme_color_type.background"), - ThemeColorType.BACKGROUND); - - RadioChoiceList themeColorChoiceList = new RadioChoiceList<>(); - themeColorChoiceList.setFallbackValue(ThemeColorType.DEFAULT); - themeColorChoiceList.setChoices(Arrays.asList( - defaultColorChoice, - systemColorChoice, - customColorChoice, - backgroundColorChoice)); - - JFXButton themeColorOverrideButton = createThemeAppearanceOverrideButton(); - themeColorSublist.setTitleRight(themeColorOverrideButton); - Holder updatingThemeColor = new Holder<>(false); - InvalidationListener refreshThemeColor = ignored -> { - if (updatingThemeColor.value) { - return; - } - updatingThemeColor.value = true; - try { - boolean overridden = settings().getThemeAppearanceOverrides().contains( - LauncherSettings.THEME_APPEARANCE_COLOR); - ThemeColorType type = overridden - ? Objects.requireNonNullElse(settings().themeColorTypeProperty().get(), ThemeColorType.DEFAULT) - : effectiveThemeColorType.get(); - ThemeColor color = Objects.requireNonNullElse( - settings().customThemeColorProperty().get(), - ThemeColor.DEFAULT); - if (!overridden) { - @Nullable ThemeColorSource source = effectiveThemeColorSource.get(); - if (source instanceof ThemeColorSource.Custom custom) { - color = custom.color(); - } - } - themeColorChoiceList.selectedValueProperty().set(type); - picker.setValue(color.color()); - updateThemeAppearanceOverrideButton(themeColorOverrideButton, !overridden); - } finally { - updatingThemeColor.value = false; - } - }; - themeColorChoiceList.selectedValueProperty().addListener((observable, oldValue, newValue) -> { - if (updatingThemeColor.value) { - return; - } - updatingThemeColor.value = true; - try { - ThemeColorType type = Objects.requireNonNullElse(newValue, ThemeColorType.DEFAULT); - settings().themeColorTypeProperty().set(type); - if (type == ThemeColorType.CUSTOM) { - @Nullable Color color = picker.getValue(); - settings().customThemeColorProperty().set( - color != null ? ThemeColor.of(color) : ThemeColor.DEFAULT); - } - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_COLOR); - updateThemeAppearanceOverrideButton(themeColorOverrideButton, false); - } finally { - updatingThemeColor.value = false; - } - }); - picker.valueProperty().addListener((observable, oldValue, newValue) -> { - if (updatingThemeColor.value) { - return; - } - updatingThemeColor.value = true; - try { - settings().themeColorTypeProperty().set(ThemeColorType.CUSTOM); - settings().customThemeColorProperty().set( - newValue != null ? ThemeColor.of(newValue) : ThemeColor.DEFAULT); - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_COLOR); - themeColorChoiceList.selectedValueProperty().set(ThemeColorType.CUSTOM); - updateThemeAppearanceOverrideButton(themeColorOverrideButton, false); - } finally { - updatingThemeColor.value = false; - } - }); - settings().themeColorTypeProperty().addListener(refreshThemeColor); - settings().customThemeColorProperty().addListener(refreshThemeColor); - addThemeAppearanceRefreshListener(refreshThemeColor); - themeColorOverrideButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { - updatingThemeColor.value = true; - try { - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_COLOR)) { - ThemeColorType type = Objects.requireNonNullElse( - themeColorChoiceList.getSelectedValue(), - ThemeColorType.DEFAULT); - settings().themeColorTypeProperty().set(type); - if (type == ThemeColorType.CUSTOM) { - @Nullable Color color = picker.getValue(); - settings().customThemeColorProperty().set( - color != null ? ThemeColor.of(color) : ThemeColor.DEFAULT); - } - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_COLOR); - } else { - settings().getThemeAppearanceOverrides().remove(LauncherSettings.THEME_APPEARANCE_COLOR); - } - } finally { - updatingThemeColor.value = false; - } - refreshThemeColor.invalidated(null); - event.consume(); - }); - themeColorSublist.descriptionProperty().bind(Bindings.createStringBinding(() -> { - ThemeColorType type = Objects.requireNonNullElse( - themeColorChoiceList.selectedValueProperty().get(), - ThemeColorType.DEFAULT); - return i18n("settings.launcher.theme_color_type." + type.name().toLowerCase(Locale.ROOT)); - }, - themeColorChoiceList.selectedValueProperty())); - refreshThemeColor.invalidated(null); - - themeColorSublist.getContent().setAll(themeColorChoiceList); - themeAppearanceList.getContent().add(themeColorSublist); - } - - { - LineSelectButton colorStylePane = new LineSelectButton<>(); - colorStylePane.setTitle(i18n("settings.launcher.theme_color_style")); - colorStylePane.setConverter(style -> i18n("settings.launcher.theme_color_style." - + Objects.requireNonNullElse(style, ColorStyle.FIDELITY).name().toLowerCase(Locale.ROOT))); - colorStylePane.setDescriptionConverter(style -> i18n("settings.launcher.theme_color_style." - + Objects.requireNonNullElse(style, ColorStyle.FIDELITY).name().toLowerCase(Locale.ROOT) - + ".desc")); - colorStylePane.setItems(Arrays.asList( - ColorStyle.FIDELITY, - ColorStyle.TONAL_SPOT, - ColorStyle.VIBRANT, - - ColorStyle.NEUTRAL, - - ColorStyle.FRUIT_SALAD, - ColorStyle.RAINBOW - )); - bindThemeAppearanceLineSelectButton( - colorStylePane, - LauncherSettings.THEME_APPEARANCE_COLOR_STYLE, - settings().themeColorStyleProperty(), - () -> { - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentThemeColorStyle(ThemePackManager.currentResolveContext()), - ColorStyle.FIDELITY); - } catch (IOException | RuntimeException e) { - return ColorStyle.FIDELITY; - } - }); - - themeAppearanceList.getContent().add(colorStylePane); - } - - { - RadioChoiceList backgroundItem = new RadioChoiceList<>(); - backgroundItem.setFallbackValue(BackgroundType.DEFAULT); - ComponentSublist backgroundSublist = new ComponentSublist(); - backgroundSublist.setTitle(i18n("launcher.background")); - backgroundSublist.setHasSubtitle(true); - - JFXComboBox builtinBackgroundComboBox = new JFXComboBox<>(); - builtinBackgroundComboBox.getItems().setAll(BuiltinBackground.BUILTIN_BACKGROUND_IDS); - FXUtils.setLimitWidth(builtinBackgroundComboBox, 160); - - RadioChoiceList.Choice builtinBackgroundOption = - new RadioChoiceList.Choice<>(i18n("launcher.background.builtin"), BackgroundType.BUILTIN) { - @Override - protected Node createRightNode() { - return builtinBackgroundComboBox; - } - }; - - RadioChoiceList.FileChoice customBackgroundOption = - new RadioChoiceList.FileChoice<>(i18n("settings.custom"), BackgroundType.CUSTOM) - .setChooserTitle(i18n("launcher.background.choose")) - .addExtensionFilter(FXUtils.getImageExtensionFilter()) - .setSelectionMode(FileSelector.SelectionMode.FILE_OR_DIRECTORY); - RadioChoiceList.TextChoice networkBackgroundOption = - new RadioChoiceList.TextChoice<>(i18n("launcher.background.network"), BackgroundType.NETWORK) - .setValidators(new URLValidator(true)); - PaintChoice paintBackgroundOption = - new PaintChoice(i18n("launcher.background.paint"), BackgroundType.PAINT); - - backgroundItem.setChoices(Arrays.asList( - new RadioChoiceList.Choice<>(i18n("message.default"), BackgroundType.DEFAULT) - .setTooltip(i18n("launcher.background.default.tooltip")), - builtinBackgroundOption, - new RadioChoiceList.Choice<>(i18n("launcher.background.theme_color"), BackgroundType.THEME_COLOR), - customBackgroundOption, - networkBackgroundOption, - paintBackgroundOption - )); - JFXButton backgroundOverrideButton = createThemeAppearanceOverrideButton(); - backgroundSublist.setTitleRight(backgroundOverrideButton); - Holder updatingBackground = new Holder<>(false); - InvalidationListener refreshBackground = ignored -> { - if (updatingBackground.value) { - return; - } - updatingBackground.value = true; - try { - builtinBackgroundComboBox.setValue(Objects.requireNonNullElse( - settings().builtinBackgroundIdProperty().get(), - BuiltinBackground.FALLBACK.id())); - customBackgroundOption.setPath(Objects.toString( - settings().customBackgroundImagePathProperty().get(), - "")); - networkBackgroundOption.setText(Objects.toString( - settings().networkBackgroundImageUrlProperty().get(), - "")); - paintBackgroundOption.setPaint(settings().customBackgroundPaintProperty().get()); - - boolean overridden = settings().getThemeAppearanceOverrides().contains( - LauncherSettings.THEME_APPEARANCE_BACKGROUND); - ThemePackManager.ResolvedBackground background; - try { - background = overridden - ? ThemePackManager.resolveCustomBackground() - : ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()); - } catch (IOException | RuntimeException e) { - background = new ThemePackManager.ResolvedBackground( - BackgroundType.DEFAULT, - null, - null, - null, - null, - 1.0); - } - - if (background.type() == BackgroundType.CUSTOM && background.imageResource() != null) { - backgroundItem.clearSelection(); - } else { - backgroundItem.selectedValueProperty().set(background.type()); - } - switch (background.type()) { - case BUILTIN -> builtinBackgroundComboBox.setValue(Objects.requireNonNullElse( - background.builtinBackgroundId(), - BuiltinBackground.FALLBACK.id())); - case CUSTOM -> { - if (background.imageResource() != null) { - customBackgroundOption.setPath(settings().customBackgroundImagePathProperty().get()); - } else { - customBackgroundOption.setPath(background.imagePath() != null - ? background.imagePath().toString() - : ""); - } - } - case NETWORK -> networkBackgroundOption.setText(Objects.toString( - background.networkImageUrl(), - "")); - case PAINT -> paintBackgroundOption.setPaint(background.paint()); - case DEFAULT, THEME_COLOR -> { - } - } - updateThemeAppearanceOverrideButton(backgroundOverrideButton, !overridden); - } finally { - updatingBackground.value = false; - } - }; - backgroundItem.selectedValueProperty().addListener((observable, oldValue, newValue) -> { - if (updatingBackground.value) { - return; - } - updatingBackground.value = true; - try { - BackgroundType type = Objects.requireNonNullElse(newValue, BackgroundType.DEFAULT); - settings().backgroundTypeProperty().set(type); - switch (type) { - case BUILTIN -> settings().builtinBackgroundIdProperty().set(Objects.requireNonNullElse( - builtinBackgroundComboBox.getValue(), - BuiltinBackground.FALLBACK.id())); - case CUSTOM -> settings().customBackgroundImagePathProperty().set(customBackgroundOption.getPath()); - case NETWORK -> settings().networkBackgroundImageUrlProperty().set(networkBackgroundOption.getText()); - case PAINT -> settings().customBackgroundPaintProperty().set(paintBackgroundOption.getPaint()); - case DEFAULT, THEME_COLOR -> { - } - } - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - updateThemeAppearanceOverrideButton(backgroundOverrideButton, false); - } finally { - updatingBackground.value = false; - } - }); - builtinBackgroundComboBox.valueProperty().addListener((observable, oldValue, newValue) -> { - if (updatingBackground.value) { - return; - } - updatingBackground.value = true; - try { - settings().builtinBackgroundIdProperty().set(Objects.requireNonNullElse( - newValue, - BuiltinBackground.FALLBACK.id())); - settings().backgroundTypeProperty().set(BackgroundType.BUILTIN); - backgroundItem.selectedValueProperty().set(BackgroundType.BUILTIN); - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - updateThemeAppearanceOverrideButton(backgroundOverrideButton, false); - } finally { - updatingBackground.value = false; - } - }); - customBackgroundOption.pathProperty().addListener((observable, oldValue, newValue) -> { - if (updatingBackground.value) { - return; - } - updatingBackground.value = true; - try { - settings().customBackgroundImagePathProperty().set(newValue); - settings().backgroundTypeProperty().set(BackgroundType.CUSTOM); - backgroundItem.selectedValueProperty().set(BackgroundType.CUSTOM); - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - updateThemeAppearanceOverrideButton(backgroundOverrideButton, false); - } finally { - updatingBackground.value = false; - } - }); - networkBackgroundOption.textProperty().addListener((observable, oldValue, newValue) -> { - if (updatingBackground.value) { - return; - } - updatingBackground.value = true; - try { - settings().networkBackgroundImageUrlProperty().set(newValue); - settings().backgroundTypeProperty().set(BackgroundType.NETWORK); - backgroundItem.selectedValueProperty().set(BackgroundType.NETWORK); - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - updateThemeAppearanceOverrideButton(backgroundOverrideButton, false); - } finally { - updatingBackground.value = false; - } - }); - paintBackgroundOption.colorProperty().addListener((observable, oldValue, newValue) -> { - if (updatingBackground.value) { - return; - } - updatingBackground.value = true; - try { - settings().customBackgroundPaintProperty().set(newValue); - settings().backgroundTypeProperty().set(BackgroundType.PAINT); - backgroundItem.selectedValueProperty().set(BackgroundType.PAINT); - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - updateThemeAppearanceOverrideButton(backgroundOverrideButton, false); - } finally { - updatingBackground.value = false; - } - }); - addThemeAppearanceRefreshListener(refreshBackground); - settings().builtinBackgroundIdProperty().addListener(refreshBackground); - settings().customBackgroundImagePathProperty().addListener(refreshBackground); - settings().networkBackgroundImageUrlProperty().addListener(refreshBackground); - settings().customBackgroundPaintProperty().addListener(refreshBackground); - backgroundOverrideButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { - updatingBackground.value = true; - try { - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND)) { - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - } else { - settings().getThemeAppearanceOverrides().remove(LauncherSettings.THEME_APPEARANCE_BACKGROUND); - } - } finally { - updatingBackground.value = false; - } - refreshBackground.invalidated(null); - event.consume(); - }); - refreshBackground.invalidated(null); - - LineSwitch networkBackgroundCacheButton = new LineSwitch(); - networkBackgroundCacheButton.setTitle(i18n("launcher.background.network.cache")); - networkBackgroundCacheButton.setSelected(Objects.requireNonNullElse( - settings().networkBackgroundImageCachePolicyProperty().get(), - NetworkBackgroundImageCachePolicy.ENABLED) == NetworkBackgroundImageCachePolicy.ENABLED); - networkBackgroundCacheButton.selectedProperty().addListener((observable, oldValue, newValue) -> - settings().networkBackgroundImageCachePolicyProperty().set(Boolean.TRUE.equals(newValue) - ? NetworkBackgroundImageCachePolicy.ENABLED - : NetworkBackgroundImageCachePolicy.DISABLED)); - settings().networkBackgroundImageCachePolicyProperty().addListener((observable, oldValue, newValue) -> - networkBackgroundCacheButton.setSelected(Objects.requireNonNullElse( - newValue, - NetworkBackgroundImageCachePolicy.ENABLED) == NetworkBackgroundImageCachePolicy.ENABLED)); - - ComponentSublist backgroundFallbackSublist = new ComponentSublist(); - backgroundFallbackSublist.setTitle(i18n("launcher.background.fallback")); - backgroundFallbackSublist.setHasSubtitle(true); - - RadioChoiceList backgroundFallbackItem = new RadioChoiceList<>(); - backgroundFallbackItem.setFallbackValue(BackgroundType.BUILTIN); - backgroundFallbackItem.setChoices(Arrays.asList( - new RadioChoiceList.Choice<>(i18n("launcher.background.fallback.builtin"), BackgroundType.BUILTIN), - new RadioChoiceList.Choice<>(i18n("launcher.background.fallback.theme_color"), BackgroundType.THEME_COLOR), - new PaintChoice(i18n("launcher.background.fallback.paint"), BackgroundType.PAINT, settings().backgroundFallbackPaintProperty()) - )); - backgroundFallbackItem.selectedValueProperty().bindBidirectional(settings().backgroundFallbackTypeProperty()); - backgroundFallbackSublist.getContent().setAll(backgroundFallbackItem); - backgroundFallbackSublist.descriptionProperty().bind(Bindings.createStringBinding(() -> { - BackgroundType type = Objects.requireNonNullElse( - backgroundFallbackItem.selectedValueProperty().get(), - BackgroundType.BUILTIN); - return switch (type) { - case PAINT -> { - @Nullable Paint backgroundFallbackPaint = settings().backgroundFallbackPaintProperty().get(); - yield backgroundFallbackPaint != null - ? backgroundFallbackPaint.toString() - : i18n("launcher.background.fallback.paint"); - } - case THEME_COLOR -> i18n("launcher.background.fallback.theme_color"); - default -> i18n("launcher.background.fallback.builtin"); - }; - }, - backgroundFallbackItem.selectedValueProperty(), - settings().backgroundFallbackPaintProperty())); - - LineSelectButton backgroundLoadPolicyButton = new LineSelectButton<>(); - backgroundLoadPolicyButton.setTitle(i18n("launcher.background.load_policy")); - backgroundLoadPolicyButton.setConverter(policy -> i18n("launcher.background.load_policy." - + Objects.requireNonNullElse(policy, BackgroundLoadPolicy.WAIT_FOR_BACKGROUND) - .name() - .toLowerCase(Locale.ROOT))); - backgroundLoadPolicyButton.setItems(Arrays.asList( - BackgroundLoadPolicy.WAIT_FOR_BACKGROUND, - BackgroundLoadPolicy.SHOW_FALLBACK_WHILE_LOADING)); - backgroundLoadPolicyButton.valueProperty().bindBidirectional(settings().backgroundLoadPolicyProperty()); - - backgroundSublist.descriptionProperty().bind(Bindings.createStringBinding(() -> { - if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND)) { - BackgroundType type = Objects.requireNonNullElse( - backgroundItem.selectedValueProperty().get(), - BackgroundType.DEFAULT); - return switch (type) { - case DEFAULT -> i18n("message.default"); - case THEME_COLOR -> i18n("launcher.background.theme_color"); - case BUILTIN -> { - String id = settings().builtinBackgroundIdProperty().get(); - yield BuiltinBackground.fromIdOrFallback(id).id(); - } - case CUSTOM -> settings().customBackgroundImagePathProperty().get(); - case NETWORK -> settings().networkBackgroundImageUrlProperty().get(); - case PAINT -> { - @Nullable Paint customBackgroundPaint = settings().customBackgroundPaintProperty().get(); - yield customBackgroundPaint != null ? customBackgroundPaint.toString() : i18n("launcher.background.paint"); - } - }; - } - - try { - ThemePackManager.ResolvedBackground background = - ThemePackManager.resolveCurrentBackground(ThemePackManager.currentResolveContext()); - return switch (background.type()) { - case DEFAULT -> i18n("message.default"); - case BUILTIN -> Objects.requireNonNullElse( - background.builtinBackgroundId(), - BuiltinBackground.FALLBACK.id()); - case CUSTOM -> { - if (background.imageResource() != null) { - yield i18n("launcher.background.theme"); - } - yield background.imagePath() != null - ? background.imagePath().toString() - : i18n("settings.custom"); - } - case NETWORK -> - Objects.toString(background.networkImageUrl(), i18n("launcher.background.network")); - case PAINT -> background.paint() != null - ? background.paint().toString() - : i18n("launcher.background.paint"); - case THEME_COLOR -> i18n("launcher.background.theme_color"); - }; - } catch (IOException | RuntimeException e) { - return i18n("message.default"); - } - }, - backgroundItem.selectedValueProperty(), - settings().getThemeAppearanceOverrides(), - settings().selectedThemeProperty(), - settings().themeBrightnessModeProperty(), - settings().builtinBackgroundIdProperty(), - settings().customBackgroundImagePathProperty(), - settings().networkBackgroundImageUrlProperty(), - settings().customBackgroundPaintProperty())); - - LinePane opacityPane = new LinePane(); - { - opacityPane.setTitle(i18n("settings.launcher.background.settings.opacity")); - Supplier effectiveBackgroundOpacity = () -> { - try { - return ThemePackManager.resolveCurrentBackground( - ThemePackManager.currentResolveContext()).opacity(); - } catch (IOException | RuntimeException e) { - return 1.0; - } - }; - - HBox sliderBox = new HBox(8); - sliderBox.setAlignment(Pos.CENTER); - - JFXSlider slider = new JFXSlider(0, 100, settings().backgroundOpacityProperty().get() * 100); - slider.setPrefWidth(220); - slider.setShowTickMarks(true); - slider.setMajorTickUnit(10); - slider.setMinorTickCount(1); - slider.setBlockIncrement(5); - slider.setSnapToTicks(true); - slider.setPadding(new Insets(9, 0, 0, 0)); - HBox.setHgrow(slider, Priority.ALWAYS); - - Label textOpacity = new Label(); - FXUtils.setLimitWidth(textOpacity, 50); - textOpacity.setAlignment(Pos.CENTER); - - StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty()); - textOpacity.textProperty().bind(valueBinding); - slider.setValueFactory(s -> valueBinding); - sliderBox.getChildren().setAll(slider, textOpacity); - - JFXButton inheritButton = createThemeAppearanceOverrideButton(); - opacityPane.setTitleTrailing(inheritButton); - Holder updatingOpacity = new Holder<>(false); - InvalidationListener refreshOpacity = ignored -> { - if (updatingOpacity.value) { - return; - } - updatingOpacity.value = true; - try { - boolean overridden = settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY); - double opacity = overridden - ? settings().backgroundOpacityProperty().get() - : effectiveBackgroundOpacity.get(); - slider.setValue(Math.round(Math.max(0., Math.min(opacity, 1.)) * 100.)); - updateThemeAppearanceOverrideButton(inheritButton, !overridden); - } finally { - updatingOpacity.value = false; - } - }; - settings().backgroundOpacityProperty().addListener(refreshOpacity); - addThemeAppearanceRefreshListener(refreshOpacity); - slider.valueProperty().addListener((observable, oldValue, newValue) -> { - if (updatingOpacity.value) { - return; - } - double opacity = snapOpacity(newValue.doubleValue()); - updatingOpacity.value = true; - try { - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY); - updateThemeAppearanceOverrideButton(inheritButton, false); - } finally { - updatingOpacity.value = false; - } - if (Double.compare(settings().backgroundOpacityProperty().get(), opacity) != 0) { - settings().backgroundOpacityProperty().set(opacity); - } - }); - inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { - if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY)) { - settings().backgroundOpacityProperty().set(effectiveBackgroundOpacity.get()); - settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY); - } else { - settings().getThemeAppearanceOverrides().remove(LauncherSettings.THEME_APPEARANCE_BACKGROUND_OPACITY); - } - refreshOpacity.invalidated(null); - event.consume(); - }); - - opacityPane.setRight(sliderBox); - refreshOpacity.invalidated(null); - } - - backgroundSublist.getContent().setAll(backgroundItem); - themeAppearanceList.getContent().addAll( - backgroundSublist, - opacityPane); - backgroundLoadingList.getContent().setAll( - networkBackgroundCacheButton, - backgroundFallbackSublist, - backgroundLoadPolicyButton - ); - } - - { - String setting = LauncherSettings.THEME_APPEARANCE_TITLE_BAR_TRANSPARENT; - LineSwitch titleBarTransparentButton = new LineSwitch(); - titleBarTransparentButton.setTitle(i18n("settings.launcher.title_transparent")); - JFXButton inheritButton = createThemeAppearanceOverrideButton(); - titleBarTransparentButton.setTitleTrailing(inheritButton); - - Holder updating = new Holder<>(false); - InvalidationListener refresh = ignored -> { - if (updating.value) { - return; - } - updating.value = true; - try { - boolean overridden = settings().getThemeAppearanceOverrides().contains(setting); - boolean transparent; - if (overridden) { - transparent = settings().titleBarTransparentProperty().get(); - } else { - try { - transparent = Objects.requireNonNullElse( - ThemePackManager.resolveCurrentTitleBarTransparent( - ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - transparent = false; - } - } - titleBarTransparentButton.setSelected(transparent); - updateThemeAppearanceOverrideButton(inheritButton, !overridden); - } finally { - updating.value = false; - } - }; - - titleBarTransparentButton.selectedProperty().addListener((observable, oldValue, newValue) -> { - if (updating.value) { - return; - } - updating.value = true; - try { - settings().titleBarTransparentProperty().set(Boolean.TRUE.equals(newValue)); - settings().getThemeAppearanceOverrides().add(setting); - updateThemeAppearanceOverrideButton(inheritButton, false); - } finally { - updating.value = false; - } - refresh.invalidated(null); - }); - settings().titleBarTransparentProperty().addListener(refresh); - addThemeAppearanceRefreshListener(refresh); - - inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { - if (!settings().getThemeAppearanceOverrides().contains(setting)) { - settings().titleBarTransparentProperty().set(titleBarTransparentButton.isSelected()); - settings().getThemeAppearanceOverrides().add(setting); - } else { - settings().getThemeAppearanceOverrides().remove(setting); - } - refresh.invalidated(null); - event.consume(); - }); - refresh.invalidated(null); - themeAppearanceList.getContent().add(titleBarTransparentButton); - } - - LineSwitch windowTransparentButton = new LineSwitch(); - windowTransparentButton.setTitle(i18n("settings.launcher.window_transparent")); - bindThemeAppearanceSwitch( - windowTransparentButton, - LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT, - settings().windowTransparentProperty(), - () -> { - try { - return Objects.requireNonNullElse( - ThemePackManager.resolveCurrentWindowTransparent( - ThemePackManager.currentResolveContext()), - false); - } catch (IOException | RuntimeException e) { - return false; - } - }); - themeAppearanceList.getContent().add(windowTransparentButton); - - content.getChildren().addAll( - ComponentList.createComponentListTitle(i18n("settings.launcher.appearance")), - themeAppearanceList, - ComponentList.createComponentListTitle(i18n("launcher.background.loading")), - backgroundLoadingList); - - { - ComponentList appearanceList = new ComponentList(); - - LineSwitch animationButton = new LineSwitch(); - appearanceList.getContent().add(animationButton); - animationButton.setSelected(settings().isAnimationDisabled()); - FXUtils.onChange(animationButton.selectedProperty(), value -> settings().animationDisabledProperty().set(value)); - animationButton.setTitle(i18n("settings.launcher.turn_off_animations")); - animationButton.setSubtitle(i18n("settings.take_effect_after_restart")); - - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.animation")), appearanceList); - } - - { - ComponentList fontPane = new ComponentList(); - - { - VBox vbox = new VBox(); - vbox.setSpacing(5); - - { - BorderPane borderPane = new BorderPane(); - vbox.getChildren().add(borderPane); - { - Label left = new Label(i18n("settings.launcher.font")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - borderPane.setLeft(left); - } - - { - HBox hBox = new HBox(); - hBox.setSpacing(8); - - FontComboBox cboFont = new FontComboBox(); - cboFont.setValue(settings().launcherFontFamilyProperty().get()); - FXUtils.onChange(cboFont.valueProperty(), FontManager::setFontFamily); - - JFXButton clearButton = FXUtils.newToggleButton4(SVG.RESTORE); - clearButton.setOnAction(e -> cboFont.setValue(null)); - - FXUtils.installFastTooltip(clearButton, i18n("button.reset")); - - hBox.getChildren().setAll(cboFont, clearButton); - - borderPane.setRight(hBox); - } - } - - vbox.getChildren().add(new Label("Hello Minecraft! Launcher")); - - fontPane.getContent().add(vbox); - } - - { - VBox logFontPane = new VBox(); - logFontPane.setSpacing(5); - - { - BorderPane borderPane = new BorderPane(); - logFontPane.getChildren().add(borderPane); - { - Label left = new Label(i18n("settings.launcher.log.font")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - borderPane.setLeft(left); - } - - { - HBox hBox = new HBox(); - hBox.setSpacing(3); - - FontComboBox cboLogFont = new FontComboBox(); - cboLogFont.valueProperty().bindBidirectional(settings().logFontFamilyProperty()); - - JFXTextField txtLogFontSize = new JFXTextField(); - FXUtils.setLimitWidth(txtLogFontSize, 50); - FXUtils.bind(txtLogFontSize, settings().logFontSizeProperty(), SafeStringConverter.fromFiniteDouble() - .restrict(it -> it > 0) - .fallbackTo(12.0) - .asPredicate(Validator.addTo(txtLogFontSize))); - - JFXButton clearButton = FXUtils.newToggleButton4(SVG.RESTORE); - clearButton.setOnAction(e -> cboLogFont.setValue(null)); - - FXUtils.installFastTooltip(clearButton, i18n("button.reset")); - - hBox.getChildren().setAll(cboLogFont, txtLogFontSize, clearButton); - - borderPane.setRight(hBox); - } - } - - Label lblLogFontDisplay = new Label("[23:33:33] [Client Thread/INFO] [WaterPower]: Loaded mod WaterPower."); - lblLogFontDisplay.fontProperty().bind(Bindings.createObjectBinding( - () -> Font.font(Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT), settings().logFontSizeProperty().get()), - settings().logFontFamilyProperty(), settings().logFontSizeProperty())); - - logFontPane.getChildren().add(lblLogFontDisplay); - - fontPane.getContent().add(logFontPane); - } - - { - var fontAntiAliasingPane = new LineSelectButton>(); - fontAntiAliasingPane.setTitle(i18n("settings.launcher.font.anti_aliasing")); - fontAntiAliasingPane.setSubtitle(i18n("settings.take_effect_after_restart")); - fontAntiAliasingPane.setNullSafeConverter(value -> - value.isPresent() - ? i18n("settings.launcher.font.anti_aliasing." + value.get().name().toLowerCase(Locale.ROOT)) - : i18n("settings.launcher.font.anti_aliasing.auto") - ); - fontAntiAliasingPane.setItems( - Optional.empty(), - Optional.of(FontSmoothingType.LCD), - Optional.of(FontSmoothingType.GRAY) - ); - - @Nullable String fontAntiAliasing = SettingsManager.userSettings().fontAntiAliasingProperty().get(); - if ("lcd".equalsIgnoreCase(fontAntiAliasing)) { - fontAntiAliasingPane.setValue(Optional.of(FontSmoothingType.LCD)); - } else if ("gray".equalsIgnoreCase(fontAntiAliasing)) { - fontAntiAliasingPane.setValue(Optional.of(FontSmoothingType.GRAY)); - } else { - fontAntiAliasingPane.setValue(Optional.empty()); - } - fontAntiAliasingPane.setDisable(SettingsManager.isUserSettingsReadOnly()); - - FXUtils.onChange(fontAntiAliasingPane.valueProperty(), value -> - { - if (SettingsManager.isUserSettingsReadOnly()) { - return; - } - UserSettings userSettings = userSettings(); - userSettings.fontAntiAliasingProperty().set(value.map(it -> it.name().toLowerCase(Locale.ROOT)) - .orElse(null)); - }); - - fontPane.getContent().add(fontAntiAliasingPane); - } - - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.fonts")), fontPane); - } - } - - /// Choice with a right-side paint picker. - private static final class PaintChoice extends RadioChoiceList.Choice { - /// The paint picker owned by this option. - private final ColorPicker colorPicker = new JFXColorPicker(); - - /// Creates a paint choice. - /// - /// @param title the choice title - /// @param value the selected background type - private PaintChoice(String title, BackgroundType value) { - super(title, value); - } - - /// Creates a paint choice bound directly to a setting property. - /// - /// @param title the choice title - /// @param value the selected background type - /// @param property the paint property edited by this choice - private PaintChoice(String title, BackgroundType value, Property property) { - this(title, value); - FXUtils.bindPaint(colorPicker, property); - } - - /// Returns the displayed paint. - private @Nullable Paint getPaint() { - return colorPicker.getValue(); - } - - /// Sets the displayed paint. - private void setPaint(@Nullable Paint paint) { - colorPicker.setValue(paint instanceof Color color ? color : null); - } - - /// Returns the displayed color property. - private Property colorProperty() { - return colorPicker.valueProperty(); - } - - /// Creates the right-side paint picker. - @Override - protected Node createRightNode() { - return colorPicker; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index ff8d6d0fba..22ff961f62 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -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()); - } } diff --git a/HMCL/src/main/resources/assets/css/blue.css b/HMCL/src/main/resources/assets/css/blue.css deleted file mode 100644 index 27f26fa8e3..0000000000 --- a/HMCL/src/main/resources/assets/css/blue.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/css/font.css b/HMCL/src/main/resources/assets/css/font.css index 7ee8fbf036..3d6e1ebe4d 100644 --- a/HMCL/src/main/resources/assets/css/font.css +++ b/HMCL/src/main/resources/assets/css/font.css @@ -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; -} \ No newline at end of file + -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"; +} diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 7afc94b49c..c090d3c119 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -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,181 @@ .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; +} diff --git a/HMCL/src/main/resources/assets/css/warm.css b/HMCL/src/main/resources/assets/css/warm.css new file mode 100644 index 0000000000..68f0506a2f --- /dev/null +++ b/HMCL/src/main/resources/assets/css/warm.css @@ -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; +} diff --git a/HMCL/src/main/resources/assets/fonts/NotoSansSC-Bold.ttf b/HMCL/src/main/resources/assets/fonts/NotoSansSC-Bold.ttf new file mode 100644 index 0000000000..062c426079 Binary files /dev/null and b/HMCL/src/main/resources/assets/fonts/NotoSansSC-Bold.ttf differ diff --git a/HMCL/src/main/resources/assets/fonts/NotoSansSC-Regular.ttf b/HMCL/src/main/resources/assets/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 0000000000..032c13e5b7 Binary files /dev/null and b/HMCL/src/main/resources/assets/fonts/NotoSansSC-Regular.ttf differ diff --git a/HMCL/src/main/resources/assets/fonts/NotoSerifSC-Regular.ttf b/HMCL/src/main/resources/assets/fonts/NotoSerifSC-Regular.ttf new file mode 100644 index 0000000000..db25466f4c Binary files /dev/null and b/HMCL/src/main/resources/assets/fonts/NotoSerifSC-Regular.ttf differ diff --git a/HMCL/src/main/resources/assets/fonts/OFL.txt b/HMCL/src/main/resources/assets/fonts/OFL.txt new file mode 100644 index 0000000000..1c9f43281b --- /dev/null +++ b/HMCL/src/main/resources/assets/fonts/OFL.txt @@ -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. diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 397a047fca..c15f09d905 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -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 diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 6bea50fe42..312af8ad62 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -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=裝戲 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index 746fd278dc..b4744ecd6d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -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=安装游戏 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index 50930c7fd3..a43cad2e00 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -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=安裝遊戲 diff --git a/HMCL/src/test/java/com/jfoenix/controls/JFXSwitchTest.java b/HMCL/src/test/java/com/jfoenix/controls/JFXSwitchTest.java index 7c2e990a7e..d09ea97925 100644 --- a/HMCL/src/test/java/com/jfoenix/controls/JFXSwitchTest.java +++ b/HMCL/src/test/java/com/jfoenix/controls/JFXSwitchTest.java @@ -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()); } diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/game/ServerEndpointTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/game/ServerEndpointTest.java new file mode 100644 index 0000000000..0dc1a21d50 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/game/ServerEndpointTest.java @@ -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"))); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java deleted file mode 100644 index 9e7f3c51a7..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/BuiltinBackgroundTest.java +++ /dev/null @@ -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 checks = Arrays.stream(BuiltinBackground.values()) - .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); - } -} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java deleted file mode 100644 index 89d4a1dc30..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/LauncherBackgroundTest.java +++ /dev/null @@ -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)); - } -} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java deleted file mode 100644 index 2bc428e2f9..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManagerTest.java +++ /dev/null @@ -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(); - } - } -} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java deleted file mode 100644 index ba48551920..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/theme/ThemePackManifestTest.java +++ /dev/null @@ -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()); - } - -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 741f0d5080..29098c375a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -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://"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java index 3fb9629be5..c2c630e89b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java @@ -29,12 +29,26 @@ public class ServerModpackManifest implements ModpackManifest, Validation { private final String fileApi; private final List files; private final List addons; + /// The address players are connected to on launch, or `null` when the manifest does not specify one. + private final @Nullable String serverAddress; + /// Alternative connection routes (for example direct and CDN), or `null` when only [#serverAddress] is used. + private final @Nullable List routes; public ServerModpackManifest() { this("", "", "", "", "", Collections.emptyList(), Collections.emptyList()); } public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List files, List addons) { + this(name, author, version, description, fileApi, files, addons, null); + } + + public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List files, List addons, @Nullable String serverAddress) { + this(name, author, version, description, fileApi, files, addons, serverAddress, null); + } + + public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List files, List addons, @Nullable String serverAddress, @Nullable List routes) { + this.routes = routes; + this.serverAddress = serverAddress; this.name = name; this.author = author; this.version = version; @@ -60,6 +74,20 @@ public class ServerModpackManifest implements ModpackManifest, Validation { return description; } + /// Returns the server address to connect to after launch. + /// + /// @return the `host[:port]` address, or `null` when the manifest does not specify one + public @Nullable String getServerAddress() { + return serverAddress; + } + + /// Returns the connection routes offered to players. + /// + /// @return the routes in display order, or an empty list when the manifest defines none + public List getRoutes() { + return routes == null ? Collections.emptyList() : routes; + } + public String getFileApi() { return fileApi; } @@ -85,6 +113,31 @@ public class ServerModpackManifest implements ModpackManifest, Validation { throw new JsonParseException("ServerModpackManifest.files cannot be null"); } + /// A named way to reach the server, such as a direct address or a CDN WebSocket URL. + public static final class Route { + private final String name; + private final String address; + + public Route() { + this("", ""); + } + + public Route(String name, String address) { + this.name = name; + this.address = address; + } + + /// Returns the display name shown to players. + public String getName() { + return name; + } + + /// Returns the `host[:port]` or `ws(s)://` address to connect to. + public String getAddress() { + return address; + } + } + public static final class Addon { private final String id; private final String version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java new file mode 100644 index 0000000000..4c00d81676 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.util; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +/// Queries a Minecraft server's status with the Server List Ping protocol. +/// +/// Only direct TCP addresses are supported; WebSocket routes cannot be pinged this way. +@NotNullByDefault +public final class ServerStatusPing { + private ServerStatusPing() { + } + + /// The result of a successful ping. + /// + /// @param online the number of players online + /// @param max the player limit + /// @param latencyMillis the round-trip time of the ping packet in milliseconds + public record Status(int online, int max, long latencyMillis) { + } + + /// The protocol number sent in the handshake; Minecraft 1.7.10 uses 5 and servers answer any value. + private static final int PROTOCOL_VERSION = 5; + + /// Pings a server. + /// + /// @param host the server host name + /// @param port the server port + /// @param timeoutMillis the connect and read timeout + /// @return the server status + /// @throws IOException if the server cannot be reached or answers with something unexpected + public static Status ping(String host, int port, int timeoutMillis) throws IOException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), timeoutMillis); + socket.setSoTimeout(timeoutMillis); + DataOutputStream out = new DataOutputStream(socket.getOutputStream()); + DataInputStream in = new DataInputStream(socket.getInputStream()); + + // Handshake with next state "status", then the status request. + ByteArrayOutputStream handshake = new ByteArrayOutputStream(); + DataOutputStream h = new DataOutputStream(handshake); + writeVarInt(h, 0x00); + writeVarInt(h, PROTOCOL_VERSION); + byte[] hostBytes = host.getBytes(StandardCharsets.UTF_8); + writeVarInt(h, hostBytes.length); + h.write(hostBytes); + h.writeShort(port); + writeVarInt(h, 1); + writePacket(out, handshake.toByteArray()); + writePacket(out, new byte[]{0x00}); + + readVarInt(in); // packet length + if (readVarInt(in) != 0x00) { + throw new IOException("Unexpected status response packet"); + } + int jsonLength = readVarInt(in); + if (jsonLength < 0 || jsonLength > 1 << 20) { + throw new IOException("Unreasonable status length: " + jsonLength); + } + byte[] json = new byte[jsonLength]; + in.readFully(json); + int online; + int max; + try { + JsonObject players = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class) + .getAsJsonObject("players"); + online = players.get("online").getAsInt(); + max = players.get("max").getAsInt(); + } catch (JsonParseException | NullPointerException | ClassCastException | IllegalStateException e) { + throw new IOException("Malformed status response", e); + } + + // Ping packet: the server echoes the payload back, which gives the latency. + long start = System.nanoTime(); + ByteArrayOutputStream ping = new ByteArrayOutputStream(); + DataOutputStream p = new DataOutputStream(ping); + writeVarInt(p, 0x01); + p.writeLong(start); + writePacket(out, ping.toByteArray()); + readVarInt(in); // packet length + if (readVarInt(in) != 0x01) { + throw new IOException("Unexpected pong packet"); + } + in.readLong(); + return new Status(online, max, (System.nanoTime() - start) / 1_000_000); + } + } + + private static void writePacket(DataOutputStream out, byte[] body) throws IOException { + writeVarInt(out, body.length); + out.write(body); + out.flush(); + } + + private static void writeVarInt(DataOutputStream out, int value) throws IOException { + while ((value & ~0x7F) != 0) { + out.writeByte((value & 0x7F) | 0x80); + value >>>= 7; + } + out.writeByte(value); + } + + private static int readVarInt(DataInputStream in) throws IOException { + int result = 0; + for (int shift = 0; shift < 35; shift += 7) { + int b = in.readUnsignedByte(); + result |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return result; + } + } + throw new IOException("VarInt is too long"); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java new file mode 100644 index 0000000000..3cf1e6b793 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.util; + +import org.junit.jupiter.api.Test; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests [ServerStatusPing] against a minimal in-process status server. +public final class ServerStatusPingTest { + @Test + void readsPlayersAndMeasuresLatency() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + Thread thread = new Thread(() -> serve(server), "fake-status-server"); + thread.setDaemon(true); + thread.start(); + + var status = ServerStatusPing.ping("127.0.0.1", server.getLocalPort(), 3000); + assertEquals(12, status.online()); + assertEquals(50, status.max()); + assertTrue(status.latencyMillis() >= 0); + } + } + + @Test + void failsWhenNothingListens() throws Exception { + int port; + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + assertThrows(IOException.class, () -> ServerStatusPing.ping("127.0.0.1", port, 500)); + } + + private static void serve(ServerSocket server) { + try (Socket socket = server.accept()) { + DataInputStream in = new DataInputStream(socket.getInputStream()); + DataOutputStream out = new DataOutputStream(socket.getOutputStream()); + skipPacket(in); // handshake + skipPacket(in); // status request + + byte[] json = "{\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8); + byte[] body = concat(new byte[]{0x00}, varInt(json.length), json); + out.write(varInt(body.length)); + out.write(body); + + int length = readVarInt(in); // ping packet + byte[] ping = new byte[length]; + in.readFully(ping); + out.write(varInt(length)); + out.write(ping); + out.flush(); + } catch (IOException ignored) { + } + } + + private static void skipPacket(DataInputStream in) throws IOException { + in.readFully(new byte[readVarInt(in)]); + } + + private static int readVarInt(DataInputStream in) throws IOException { + int result = 0; + for (int shift = 0; ; shift += 7) { + int b = in.readUnsignedByte(); + result |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) return result; + } + } + + private static byte[] varInt(int value) { + var out = new java.io.ByteArrayOutputStream(); + while ((value & ~0x7F) != 0) { + out.write((value & 0x7F) | 0x80); + value >>>= 7; + } + out.write(value); + return out.toByteArray(); + } + + private static byte[] concat(byte[]... parts) { + var out = new java.io.ByteArrayOutputStream(); + for (byte[] part : parts) out.writeBytes(part); + return out.toByteArray(); + } +}