diff --git a/HMCL/image/april_fools.png b/HMCL/image/april_fools.png deleted file mode 100644 index b248712fa8..0000000000 Binary files a/HMCL/image/april_fools.png and /dev/null differ 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 daa1fc90fc..fe8d7446ea 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -143,7 +143,7 @@ public Response serve(IHTTPSession 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/setting/StyleSheets.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java index f9d2d54902..642fddfc36 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java @@ -25,8 +25,7 @@ import org.glavo.monetfx.Brightness; import org.glavo.monetfx.ColorRole; import org.glavo.monetfx.ColorScheme; -import org.jackhuang.hmcl.theme.Theme; -import org.jackhuang.hmcl.theme.ThemeColor; +import org.jackhuang.hmcl.theme.ColorTheme; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; @@ -144,24 +143,25 @@ private static String getBrightnessStyleSheet() { private static void addColor(StringBuilder builder, String name, Color color) { builder.append(" ").append(name) - .append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n"); + .append(": ").append(FXUtils.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"); + .append(": ").append(FXUtils.getColorDisplayNameWithOpacity(color, opacity)).append(";\n"); } private static void addColor(StringBuilder builder, ColorScheme scheme, ColorRole role, double opacity) { + Color c = scheme.getColor(role); builder.append(" ").append(role.getVariableName()).append("-transparent-%02d".formatted((int) (100 * opacity))) - .append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(scheme.getColor(role), opacity)) + .append(": ").append(FXUtils.getColorDisplayNameWithOpacity(c, opacity)) .append(";\n"); } private static String getThemeStyleSheet() { final String blueCss = "/assets/css/blue.css"; - if (Theme.DEFAULT.equals(Themes.getTheme())) + if (ColorTheme.DEFAULT.equals(Themes.getColorTheme())) return blueCss; ColorScheme scheme = Themes.getColorScheme(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ColorTheme.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ColorTheme.java new file mode 100644 index 0000000000..12295bd19d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ColorTheme.java @@ -0,0 +1,41 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.theme; + +import javafx.scene.paint.Color; +import org.glavo.monetfx.*; + +/// @author Glavo +public record ColorTheme( + Color primaryColorSeed, + Brightness brightness, + ColorStyle colorStyle, + Contrast contrast) { + + public static final ColorTheme DEFAULT = new ColorTheme(ThemeColor.DEFAULT.color(), Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT); + + public ColorScheme toColorScheme() { + return ColorScheme.newBuilder() + .setPrimaryColorSeed(primaryColorSeed) + .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 index e8d3371166..6bc8578d70 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2025 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,78 +17,319 @@ */ package org.jackhuang.hmcl.theme; -import org.glavo.monetfx.*; +import com.google.gson.*; +import org.glavo.monetfx.Brightness; +import org.glavo.monetfx.ColorStyle; +import org.glavo.monetfx.Contrast; +import org.jackhuang.hmcl.game.CompatibilityRule; +import org.jackhuang.hmcl.util.MathUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import java.util.*; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Represents a resolved or unresolved theme configuration for HMCL. +/// +/// A theme describes visual settings such as brightness, accent color, color style, +/// background image and opacity, and contrast level. It may also carry a list of +/// conditional [overrides] that are merged on top of the base values when +/// [resolve(Map)] is called. +/// +/// Themes are typically loaded from a JSON object via [fromJson(JsonObject)]. +/// After loading, call [resolve(Map)] with the current feature map to obtain +/// a fully resolved [Theme] whose [overrides] list is empty. +/// +/// @param version The schema version string declared in the JSON source, or `null` if absent. +/// @param brightness The preferred brightness (light or dark), or `null` to use the system default. +/// @param color The accent color of the theme, or `null` to use the default color. +/// @param colorStyle The color generation style, or `null` to use the default style. +/// @param background The background configuration, or `null` if no custom background is set. +/// @param backgroundOpacity The opacity of the background image in the range `[0.0, 1.0]`, or `null` if not specified. +/// @param contrast The contrast level preference, or `null` to use the system default. +/// @param rules The compatibility rules that guard this theme entry. An empty list means the theme always applies. +/// @param overrides The overrides that are merged on top of the base values when [resolve(Map)] is called. /// @author Glavo -public final class Theme { - - public static final Theme DEFAULT = new Theme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT); - - private final ThemeColor primaryColorSeed; - private final Brightness brightness; - private final ColorStyle colorStyle; - private final Contrast contrast; - - public Theme(ThemeColor primaryColorSeed, - Brightness brightness, - ColorStyle colorStyle, - Contrast contrast - ) { - this.primaryColorSeed = primaryColorSeed; - this.brightness = brightness; - this.colorStyle = colorStyle; - this.contrast = contrast; - } +public record Theme( + @Nullable String version, + @Nullable Brightness brightness, + @Nullable ThemeColor color, + @Nullable ColorStyle colorStyle, + @Nullable ThemeBackground background, + @Nullable Double backgroundOpacity, + @Nullable Contrast contrast, + @NotNull List rules, + @NotNull List overrides +) { - public ColorScheme toColorScheme() { - return ColorScheme.newBuilder() - .setPrimaryColorSeed(primaryColorSeed.color()) - .setColorStyle(colorStyle) - .setBrightness(brightness) - .setSpecVersion(ColorSpecVersion.SPEC_2025) - .setContrast(contrast) - .build(); - } + public static final int CURRENT_VERSION_MAJOR = 1; + public static final int CURRENT_VERSION_MINOR = 0; - public ThemeColor primaryColorSeed() { - return primaryColorSeed; - } + /// The current default (and maximum supported) theme schema version. + public static final String DEFAULT_VERSION = CURRENT_VERSION_MAJOR + "." + CURRENT_VERSION_MINOR; - public Brightness brightness() { - return brightness; + /// Returns `true` if this theme has no pending overrides and does not need + /// to be resolved further. + public boolean isResolved() { + return overrides.isEmpty(); } - public ColorStyle colorStyle() { - return colorStyle; - } + /// Resolves this theme against the given feature map by iterating over + /// [overrides] in order and merging each override whose compatibility rules + /// are satisfied by `features` into the base values. + /// + /// If this theme [isResolved()] already, `this` is returned unchanged. + /// If no override ends up being applicable, `this` is also returned unchanged. + /// Otherwise a new [Theme] record is returned containing the merged field values + /// while keeping the original [rules] and [overrides] references. + /// + /// @param features a map of named feature flags used to evaluate [CompatibilityRule]s + /// @return a [Theme] with all applicable overrides merged in + public Theme resolve(Map features) { + if (isResolved()) + return this; - public Contrast contrast() { - return contrast; - } + String version = this.version; + Brightness brightness = this.brightness; + ThemeColor color = this.color; + ColorStyle colorStyle = this.colorStyle; + ThemeBackground background = this.background; + Double backgroundOpacity = this.backgroundOpacity; + Contrast contrast = this.contrast; - @Override - public boolean equals(Object obj) { - return obj == this || obj instanceof Theme that - && this.primaryColorSeed.color().equals(that.primaryColorSeed.color()) - && this.brightness.equals(that.brightness) - && this.colorStyle.equals(that.colorStyle) - && this.contrast.equals(that.contrast); - } + boolean hasOverride = false; + for (Theme override : overrides) { + if (!override.rules().isEmpty()) { + if (!CompatibilityRule.appliesToCurrentEnvironment(override.rules(), features)) { + continue; + } + } + + hasOverride = true; + + if (override.brightness != null) + brightness = override.brightness; + if (override.color != null) + color = override.color; + if (override.colorStyle != null) + colorStyle = override.colorStyle; + if (override.background != null) + background = override.background; + if (override.backgroundOpacity != null) + backgroundOpacity = override.backgroundOpacity; + if (override.contrast != null) + contrast = override.contrast; + } - @Override - public int hashCode() { - return Objects.hash(primaryColorSeed, brightness, colorStyle, contrast); + return hasOverride ? new Theme( + version, + brightness, + color, + colorStyle, + background, + backgroundOpacity, + contrast, + rules, + overrides + ) : this; } - @Override - public String toString() { - return "Theme[" + - "primaryColorSeed=" + primaryColorSeed + ", " + - "brightness=" + brightness + ", " + - "colorStyle=" + colorStyle + ", " + - "contrast=" + contrast + ']'; + /// Parses a [Theme] from the given JSON object. + /// + /// Unrecognised or malformed field values are silently ignored (a warning is + /// logged) and the corresponding record component is set to `null`. + /// The returned theme always has empty [rules] and [overrides] lists; callers + /// that need conditional overrides must assemble the full theme graph themselves. + /// + /// @param json the JSON object to parse + /// @return the parsed [Theme] + /// @throws JsonParseException if the declared schema version is newer than + /// [DEFAULT_VERSION] + public static Theme fromJson(JsonObject json) throws JsonParseException { + String versionString; + if (json.get("version") instanceof JsonPrimitive version) { + versionString = version.getAsString(); + Pattern versionPattern = Pattern.compile("(?[0-9]+)\\.(?[0-9]+)"); + + Matcher matcher = versionPattern.matcher(versionString); + if (!matcher.matches()) { + throw new JsonParseException("Invalid theme version format: " + versionString); + } + + int major; + int minor; + try { + major = Integer.parseInt(matcher.group("major")); + minor = Integer.parseInt(matcher.group("minor")); + } catch (NumberFormatException e) { + throw new JsonParseException("Invalid theme version format: " + versionString, e); + } + + if (major > CURRENT_VERSION_MAJOR) + throw new JsonParseException("Unsupported theme version: " + version.getAsString()); + + if (major == CURRENT_VERSION_MAJOR && minor > CURRENT_VERSION_MINOR) + LOG.warning("Unsupported theme version: " + versionString); + + + } else { + versionString = null; + } + + Brightness brightness; + JsonElement brightnessJson = json.get("brightness"); + if (brightnessJson != null) { + if (brightnessJson instanceof JsonPrimitive primitive) + brightness = switch (primitive.getAsString().toLowerCase(Locale.ROOT)) { + case "light" -> Brightness.LIGHT; + case "dark" -> Brightness.DARK; + default -> null; + }; + else + brightness = null; + + if (brightness == null) + LOG.warning("Invalid brightness: " + brightnessJson); + } else { + brightness = null; + } + + ThemeColor color; + JsonElement colorJson = json.get("color"); + if (colorJson != null) { + try { + color = ThemeColor.fromJson(colorJson); + } catch (Exception e) { + LOG.warning("Invalid color JSON format: " + colorJson); + color = null; + } + } else { + color = null; + } + + ColorStyle colorStyle; + JsonElement colorStyleJson = json.get("colorStyle"); + if (colorStyleJson != null) { + try { + colorStyle = ColorStyle.valueOf(colorStyleJson.getAsString().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + LOG.warning("Invalid color style: " + colorStyleJson); + colorStyle = null; + } + } else { + colorStyle = null; + } + + ThemeBackground background; + JsonElement backgroundJson = json.get("background"); + if (backgroundJson != null) { + try { + background = ThemeBackground.fromJson(backgroundJson); + } catch (Exception e) { + LOG.warning("Invalid theme background: " + backgroundJson, e); + background = null; + } + } else { + background = null; + } + + Double backgroundOpacity; + JsonElement backgroundOpacityJson = json.get("backgroundOpacity"); + if (backgroundOpacityJson != null) { + if (backgroundOpacityJson instanceof JsonPrimitive primitive) { + double value = primitive.getAsDouble(); + backgroundOpacity = value >= 0 && value <= 1 ? value : null; + } else + backgroundOpacity = null; + if (backgroundOpacity == null) + LOG.warning("Invalid background opacity: " + backgroundOpacityJson); + } else { + backgroundOpacity = null; + } + + Contrast contrast; + JsonElement contrastJson = json.get("contrast"); + if (contrastJson != null) { + if (contrastJson instanceof JsonPrimitive primitive) + if (primitive.isNumber()) { + double contrastValue = primitive.getAsDouble(); + contrast = Double.isNaN(contrastValue) + ? null + : Contrast.of(MathUtils.clamp(contrastValue, -1.0, 1.0)); + } else { + contrast = switch (primitive.getAsString().toLowerCase(Locale.ROOT)) { + case "high" -> Contrast.HIGH; + case "low" -> Contrast.LOW; + default -> null; + }; + } + else + contrast = null; + + if (contrast == null) + LOG.warning("Invalid contrast: " + contrastJson); + } else { + contrast = null; + } + + return new Theme( + versionString, + brightness, + color, + colorStyle, + background, + backgroundOpacity, + contrast, + List.of(), + List.of() + ); } + /// Serializes this theme to a [JsonObject]. + /// + /// Only the base scalar fields are written; [rules] and [overrides] are not + /// included because [fromJson(JsonObject)] does not parse them. + /// Fields whose value is `null` are omitted from the output. + /// + /// @return a [JsonObject] representing this theme's fields + public JsonObject toJson() { + JsonObject jsonObject = new JsonObject(); + + if (version != null) + jsonObject.addProperty("version", version); + + if (brightness != null) + jsonObject.addProperty("brightness", brightness == Brightness.LIGHT ? "light" : "dark"); + + if (color != null) + jsonObject.addProperty("color", color.name()); + + if (colorStyle != null) + jsonObject.addProperty("colorStyle", colorStyle.name().toLowerCase(Locale.ROOT)); + + if (background != null) { + jsonObject.add("background", background.toJson()); + } + + if (backgroundOpacity != null) + jsonObject.addProperty("backgroundOpacity", backgroundOpacity); + + if (contrast != null) + jsonObject.addProperty("contrast", contrast == Contrast.HIGH ? "high" : "low"); + + if (!overrides.isEmpty()) { + JsonArray overridesArray = new JsonArray(); + for (Theme override : overrides) { + overridesArray.add(override.toJson()); + } + jsonObject.add("overrides", overridesArray); + } + + return jsonObject; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java new file mode 100644 index 0000000000..5fec3d1dc4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeBackground.java @@ -0,0 +1,193 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +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 javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.Locale; + +/// Represents a background for the HMCL theme. +/// +/// A background can be one of the following variants: +/// - {@link BuiltIn}: a built-in background identified by name. +/// - {@link Local}: a background loaded from a local file path. +/// - {@link Remote}: a background loaded from a remote URL. +/// - {@link Fill}: a background filled with a solid or gradient {@link Paint}. +/// +/// Instances can be serialized to and deserialized from JSON via {@link #toJson()} and +/// {@link #fromJson(JsonElement)}. +/// +/// @author Glavo +@NotNullByDefault +public sealed interface ThemeBackground { + + /// Deserializes a {@link ThemeBackground} from a JSON element. + /// + /// Accepted forms: + /// - A JSON string matching a {@link BuiltIn} name (case-insensitive) → {@link BuiltIn}. + /// - Any other JSON string → {@link Local} with that string as the path. + /// - A JSON object with {@code "type": "local"} and a {@code "path"} string → {@link Local}. + /// - A JSON object with {@code "type": "remote"} and a {@code "url"} string → {@link Remote}. + /// - A JSON object with {@code "type": "fill"} and either a {@code "paint"} or {@code "color"} + /// string parseable by JavaFX → {@link Fill}. + /// + /// @param json the JSON element to deserialize + /// @return the deserialized {@link ThemeBackground} + /// @throws JsonParseException if the JSON element is not a valid background representation + static ThemeBackground fromJson(JsonElement json) throws JsonParseException { + if (json instanceof JsonPrimitive primitive) { + String value = primitive.getAsString(); + + try { + return BuiltIn.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return new Local(value); + } + } else if (json instanceof JsonObject object) { + String type; + if (object.get("type") instanceof JsonPrimitive elementType) + type = elementType.getAsString(); + else + throw new JsonParseException("Invalid theme background: " + json); + + switch (type) { + case "local" -> { + if (object.get("path") instanceof JsonPrimitive path) + return new Local(path.getAsString()); + else + throw new JsonParseException("Invalid theme background: " + json); + } + case "remote" -> { + if (object.get("url") instanceof JsonPrimitive url) + return new Remote(url.getAsString()); + else + throw new JsonParseException("Invalid theme background: " + json); + } + case "fill" -> { + Paint paint; + + if (object.get("paint") instanceof JsonPrimitive paintJson) { + try { + paint = Paint.valueOf(paintJson.getAsString()); + } catch (IllegalArgumentException ignored) { + paint = null; + } + } else if (object.get("color") instanceof JsonPrimitive colorJson) { + try { + paint = Color.web(colorJson.getAsString()); + } catch (IllegalArgumentException ignored) { + paint = null; + } + } else { + paint = null; + } + + if (paint != null) + return new Fill(paint); + else + throw new JsonParseException("Invalid theme background: " + json); + } + default -> throw new JsonParseException("Invalid theme background: " + json); + } + } else { + throw new JsonParseException("Invalid theme background: " + json); + } + } + + /// Serializes this background to a JSON element. + /// + /// The returned element can be passed back to {@link #fromJson(JsonElement)} to reconstruct + /// an equivalent instance. + /// + /// @return a JSON representation of this background + JsonElement toJson(); + + /// Built-in backgrounds provided by HMCL. + /// + /// Each constant is serialized as a plain JSON string equal to its {@link #name()}. + enum BuiltIn implements ThemeBackground { + /// The default built-in background. + DEFAULT, + /// The classic built-in background. + CLASSIC; + + /// {@inheritDoc} + /// + /// Returns a {@link JsonPrimitive} containing the name of this constant. + @Override + public JsonPrimitive toJson() { + return new JsonPrimitive(name()); + } + } + + /// A background that is loaded from a local file path. + /// + /// @param path the local file-system path to the background image + record Local(String path) implements ThemeBackground { + /// {@inheritDoc} + /// + /// Returns a JSON object of the form {@code {"type": "local", "path": ""}}. + @Override + public JsonObject toJson() { + var object = new JsonObject(); + object.addProperty("type", "local"); + object.addProperty("path", path); + return object; + } + } + + /// A background that is loaded from a remote URL. + /// + /// @param url the URL of the background image + record Remote(String url) implements ThemeBackground { + /// {@inheritDoc} + /// + /// Returns a JSON object of the form {@code {"type": "remote", "url": ""}}. + @Override + public JsonObject toJson() { + var object = new JsonObject(); + object.addProperty("type", "remote"); + object.addProperty("url", url); + return object; + } + } + + /// A background that fills the entire area with a {@link Paint}. + /// + /// @param paint the {@link Paint} used to fill the background + record Fill(Paint paint) implements ThemeBackground { + /// {@inheritDoc} + /// + /// Returns a JSON object of the form {@code {"type": "fill", "paint": ""}}, + /// where {@code } is the string representation of the {@link Paint} as returned by + /// {@link Paint#toString()}. + @Override + public JsonObject toJson() { + var object = new JsonObject(); + object.addProperty("type", "fill"); + object.addProperty("paint", paint.toString()); + return object; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java index 1ce2264b44..a75e05b348 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2025 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,177 +17,98 @@ */ package org.jackhuang.hmcl.theme; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; -import javafx.beans.WeakListener; -import javafx.beans.property.Property; -import javafx.scene.control.ColorPicker; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; import javafx.scene.paint.Color; -import org.jackhuang.hmcl.util.gson.JsonSerializable; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import java.io.IOException; -import java.lang.ref.WeakReference; import java.util.List; -import java.util.Objects; /// @author Glavo -@JsonAdapter(ThemeColor.TypeAdapter.class) -@JsonSerializable -public record ThemeColor(@NotNull String name, @NotNull Color color) { +@NotNullByDefault +public sealed interface ThemeColor { - public static final ThemeColor DEFAULT = new ThemeColor("blue", Color.web("#5C6BC0")); + Preset DEFAULT = new Preset("default", Color.web("#5C6BC0")); - public static final List STANDARD_COLORS = List.of( + List PRESETS = List.of( DEFAULT, - new ThemeColor("darker_blue", Color.web("#283593")), - new ThemeColor("green", Color.web("#43A047")), - new ThemeColor("orange", Color.web("#E67E22")), - new ThemeColor("purple", Color.web("#9C27B0")), - new ThemeColor("red", Color.web("#B71C1C")) + new Preset("blue", Color.web("#5C6BC0")), + new Preset("darker_blue", Color.web("#283593")), + new Preset("green", Color.web("#43A047")), + new Preset("orange", Color.web("#E67E22")), + new Preset("purple", Color.web("#9C27B0")), + new Preset("red", Color.web("#B71C1C")) ); - public static String getColorDisplayName(Color c) { - return c != null ? String.format("#%02X%02X%02X", - Math.round(c.getRed() * 255.0D), - Math.round(c.getGreen() * 255.0D), - Math.round(c.getBlue() * 255.0D)) - : null; - } - - public static String getColorDisplayNameWithOpacity(Color c, double opacity) { - return c != null ? String.format("#%02X%02X%02X%02X", - Math.round(c.getRed() * 255.0D), - Math.round(c.getGreen() * 255.0D), - Math.round(c.getBlue() * 255.0D), - Math.round(opacity * 255.0)) - : null; - } + List BUILTIN = Lang.merge(PRESETS, List.of( + FollowSystem.INSTANCE, + FollowBackground.INSTANCE + )); - public static @Nullable ThemeColor of(String name) { + static @Nullable ThemeColor of(@Nullable String name) { if (name == null) return null; - if (!name.startsWith("#")) { - for (ThemeColor color : STANDARD_COLORS) { - if (name.equalsIgnoreCase(color.name())) - return color; + for (ThemeColor builtin : BUILTIN) { + if (name.equalsIgnoreCase(builtin.name())) + return builtin; } } try { - return new ThemeColor(name, Color.web(name)); + return new Custom(Color.web(name)); } catch (IllegalArgumentException e) { return null; } } - @Contract("null -> null; !null -> !null") - public static ThemeColor of(Color color) { - return color != null ? new ThemeColor(getColorDisplayName(color), color) : null; - } - - private static final class BidirectionalBinding implements InvalidationListener, WeakListener { - private final WeakReference colorPickerRef; - private final WeakReference> propertyRef; - private final int hashCode; + static ThemeColor fromJson(JsonElement json) throws JsonParseException { + if (json instanceof JsonPrimitive primitive) { + //noinspection DataFlowIssue + return ThemeColor.of(primitive.getAsString()); + } - private boolean updating = false; + throw new JsonParseException("Invalid JSON element for ThemeColor: " + json); + } - private BidirectionalBinding(ColorPicker colorPicker, Property property) { - this.colorPickerRef = new WeakReference<>(colorPicker); - this.propertyRef = new WeakReference<>(property); - this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property); - } + String name(); - @Override - public void invalidated(Observable sourceProperty) { - if (!updating) { - final ColorPicker colorPicker = colorPickerRef.get(); - final Property property = propertyRef.get(); - - if (colorPicker == null || property == null) { - if (colorPicker != null) { - colorPicker.valueProperty().removeListener(this); - } - - if (property != null) { - property.removeListener(this); - } - } else { - updating = true; - try { - if (property == sourceProperty) { - ThemeColor newValue = property.getValue(); - colorPicker.setValue(newValue != null ? newValue.color() : null); - } else { - Color newValue = colorPicker.getValue(); - property.setValue(newValue != null ? ThemeColor.of(newValue) : null); - } - } finally { - updating = false; - } - } - } - } + record Preset(String name, Color color) implements ThemeColor { + } - @Override - public boolean wasGarbageCollected() { - return colorPickerRef.get() == null || propertyRef.get() == null; - } + final class FollowSystem implements ThemeColor { + public static FollowSystem INSTANCE = new FollowSystem(); - @Override - public int hashCode() { - return hashCode; + private FollowSystem() { } @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (!(o instanceof BidirectionalBinding that)) - return false; - - final ColorPicker colorPicker = this.colorPickerRef.get(); - final Property property = this.propertyRef.get(); - - final ColorPicker thatColorPicker = that.colorPickerRef.get(); - final Property thatProperty = that.propertyRef.get(); - - if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null) - return false; - - return colorPicker == thatColorPicker && property == thatProperty; + public String name() { + return "follow_system"; } } - public static void bindBidirectional(ColorPicker colorPicker, Property property) { - var binding = new BidirectionalBinding(colorPicker, property); + final class FollowBackground implements ThemeColor { + public static FollowBackground INSTANCE = new FollowBackground(); - colorPicker.valueProperty().removeListener(binding); - property.removeListener(binding); - - ThemeColor themeColor = property.getValue(); - colorPicker.setValue(themeColor != null ? themeColor.color() : null); - - colorPicker.valueProperty().addListener(binding); - property.addListener(binding); - } + private FollowBackground() { + } - static final class TypeAdapter extends com.google.gson.TypeAdapter { @Override - public void write(JsonWriter out, ThemeColor value) throws IOException { - out.value(value.name()); + public String name() { + return "follow_background"; } + } + record Custom(Color color) implements ThemeColor { @Override - public ThemeColor read(JsonReader in) throws IOException { - return Objects.requireNonNullElse(of(in.nextString()), ThemeColor.DEFAULT); + public String name() { + return FXUtils.getColorDisplayName(color); } } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePack.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePack.java new file mode 100644 index 0000000000..b8ba1d7e9c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemePack.java @@ -0,0 +1,39 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.theme; + +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.i18n.LocalizedText; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.nio.file.Path; +import java.util.List; + +/// @author Glavo +@JsonSerializable +@NotNullByDefault +public record ThemePack( + LocalizedText name, + @Nullable LocalizedText author, + @Nullable LocalizedText description, + @Unmodifiable List themes, + Path packFile +) { +} 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 a26eeb9d9d..b2a5664ce8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java @@ -25,6 +25,8 @@ import javafx.beans.binding.ObjectExpression; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.event.EventHandler; import javafx.scene.paint.Color; import javafx.stage.Stage; @@ -52,13 +54,14 @@ import java.time.Duration; import java.util.*; +import static javafx.collections.FXCollections.*; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author Glavo public final class Themes { - private static final ObjectExpression theme = new ObjectBinding<>() { + private static final ObjectExpression colorTheme = new ObjectBinding<>() { { List observables = new ArrayList<>(); @@ -90,10 +93,28 @@ private Brightness getBrightness() { } @Override - protected Theme computeValue() { + protected ColorTheme computeValue() { ThemeColor themeColor = Objects.requireNonNullElse(config().getThemeColor(), ThemeColor.DEFAULT); - return new Theme(themeColor, getBrightness(), Theme.DEFAULT.colorStyle(), Contrast.DEFAULT); + Color primaryColorSeed; + if (themeColor instanceof ThemeColor.Preset preset) { + primaryColorSeed = preset.color(); + } else if (themeColor instanceof ThemeColor.Custom custom) { + primaryColorSeed = custom.color(); + } else if (themeColor instanceof ThemeColor.FollowSystem) { + primaryColorSeed = Color.RED; // TODO + } else if (themeColor instanceof ThemeColor.FollowBackground) { + primaryColorSeed = Color.RED; // TODO + } else { + LOG.warning("Unknown theme color type: " + themeColor.getClass().getName()); + primaryColorSeed = ThemeColor.DEFAULT.color(); + } + + return new ColorTheme( + primaryColorSeed, + getBrightness(), + ColorTheme.DEFAULT.colorStyle(), + Contrast.DEFAULT); } }; private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); @@ -102,14 +123,20 @@ protected Theme computeValue() { colorScheme ); + private static final ObservableList themes = observableArrayList(); + static { - ChangeListener listener = (observable, oldValue, newValue) -> { + ChangeListener listener = (observable, oldValue, newValue) -> { if (!Objects.equals(oldValue, newValue)) { - colorScheme.set(newValue != null ? newValue.toColorScheme() : Theme.DEFAULT.toColorScheme()); + colorScheme.set(newValue != null ? newValue.toColorScheme() : ColorTheme.DEFAULT.toColorScheme()); } }; - listener.changed(theme, null, theme.get()); - theme.addListener(listener); + listener.changed(colorTheme, null, colorTheme.get()); + colorTheme.addListener(listener); + } + + public static ObservableList getThemes() { + return themes; } private static Brightness defaultBrightness; @@ -172,12 +199,12 @@ private static Brightness getDefaultBrightness() { return defaultBrightness = brightness; } - public static ObjectExpression themeProperty() { - return theme; + public static ObjectExpression colorThemeProperty() { + return colorTheme; } - public static Theme getTheme() { - return themeProperty().get(); + public static ColorTheme getColorTheme() { + return colorThemeProperty().get(); } public static ReadOnlyColorSchemeProperty colorSchemeProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index fa65e748c4..c2ebd4527d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -1132,6 +1132,23 @@ public void invalidated(Observable observable) { } } + public static String getColorDisplayName(Color c) { + return c != null ? String.format("#%02X%02X%02X", + Math.round(c.getRed() * 255.0D), + Math.round(c.getGreen() * 255.0D), + Math.round(c.getBlue() * 255.0D)) + : null; + } + + public static String getColorDisplayNameWithOpacity(Color c, double opacity) { + return c != null ? String.format("#%02X%02X%02X%02X", + Math.round(c.getRed() * 255.0D), + Math.round(c.getGreen() * 255.0D), + Math.round(c.getBlue() * 255.0D), + Math.round(opacity * 255.0)) + : null; + } + public static void setIcon(Stage stage) { String icon; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java index fb2630b3c0..4825d2eee3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.ui.construct; -import com.jfoenix.controls.JFXColorPicker; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; @@ -25,21 +24,16 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.ColorPicker; import javafx.scene.control.Label; import javafx.scene.control.Toggle; import javafx.scene.control.ToggleGroup; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.StringUtils; import java.util.Collection; -import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; @@ -306,44 +300,4 @@ protected Node createItem(ToggleGroup group) { } } - public static final class PaintOption extends Option { - private final ColorPicker colorPicker = new JFXColorPicker(); - - public PaintOption(String title, T data) { - super(title, data); - } - - public PaintOption setCustomColors(List colors) { - colorPicker.getCustomColors().setAll(colors); - return this; - } - - public PaintOption bindBidirectional(Property property) { - FXUtils.bindPaint(colorPicker, property); - return this; - } - - public PaintOption bindThemeColorBidirectional(Property property) { - ThemeColor.bindBidirectional(colorPicker, property); - return this; - } - - @Override - protected Node createItem(ToggleGroup group) { - BorderPane pane = new BorderPane(); - pane.setPadding(new Insets(3)); - FXUtils.setLimitHeight(pane, 30); - - left.setText(title); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - left.setToggleGroup(group); - left.setUserData(data); - pane.setLeft(left); - - colorPicker.disableProperty().bind(left.selectedProperty().not()); - BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT); - pane.setRight(colorPicker); - return pane; - } - } } 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 index 40f241121d..7c744360ef 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java @@ -18,31 +18,23 @@ package org.jackhuang.hmcl.ui.main; import com.jfoenix.controls.*; -import com.jfoenix.effects.JFXDepthManager; -import javafx.application.Platform; import javafx.beans.binding.Bindings; -import javafx.beans.binding.StringBinding; -import javafx.beans.binding.When; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.ColorPicker; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.*; import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; -import org.jackhuang.hmcl.setting.EnumBackgroundImage; import org.jackhuang.hmcl.setting.FontManager; -import org.jackhuang.hmcl.theme.ThemeColor; +import org.jackhuang.hmcl.theme.Theme; +import org.jackhuang.hmcl.theme.Themes; 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.javafx.SafeStringConverter; -import java.util.Arrays; import java.util.Locale; import java.util.Optional; @@ -84,25 +76,28 @@ public PersonalizationPage() { brightnessPane.valueProperty().bindBidirectional(config().themeBrightnessProperty()); themeList.getContent().add(brightnessPane); - } + var lightThemeButton = new LineSelectButton(); + lightThemeButton.setTitle("浅色主题"); + lightThemeButton.managedProperty().bindBidirectional(lightThemeButton.visibleProperty()); + themeList.getContent().add(lightThemeButton); - { - BorderPane themePane = new BorderPane(); - themeList.getContent().add(themePane); - - Label left = new Label(i18n("settings.launcher.theme")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - themePane.setLeft(left); - - StackPane themeColorPickerContainer = new StackPane(); - themeColorPickerContainer.setMinHeight(30); - themePane.setRight(themeColorPickerContainer); - - ColorPicker picker = new JFXColorPicker(); - picker.getCustomColors().setAll(ThemeColor.STANDARD_COLORS.stream().map(ThemeColor::color).toList()); - ThemeColor.bindBidirectional(picker, config().themeColorProperty()); - themeColorPickerContainer.getChildren().setAll(picker); - Platform.runLater(() -> JFXDepthManager.setDepth(picker, 0)); + var editLightThemeButton = LineButton.createNavigationButton(); + editLightThemeButton.setTitle("修改浅色主题"); + editLightThemeButton.managedProperty().bindBidirectional(editLightThemeButton.visibleProperty()); + + themeList.getContent().add(editLightThemeButton); + + var darkThemeButton = new LineSelectButton(); + darkThemeButton.setTitle("深色主题"); + darkThemeButton.managedProperty().bindBidirectional(darkThemeButton.visibleProperty()); + + themeList.getContent().add(darkThemeButton); + + var editDarkThemeButton = LineButton.createNavigationButton(); + editDarkThemeButton.setTitle("修改深色主题"); + editDarkThemeButton.managedProperty().bindBidirectional(editDarkThemeButton.visibleProperty()); + + themeList.getContent().add(editDarkThemeButton); } { LineToggleButton titleTransparentButton = new LineToggleButton(); @@ -117,85 +112,8 @@ public PersonalizationPage() { animationButton.setTitle(i18n("settings.launcher.turn_off_animations")); animationButton.setSubtitle(i18n("settings.take_effect_after_restart")); } - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.appearance")), themeList); - { - ComponentList componentList = new ComponentList(); - - MultiFileItem backgroundItem = new MultiFileItem<>(); - ComponentSublist backgroundSublist = new ComponentSublist(); - backgroundSublist.getContent().add(backgroundItem); - backgroundSublist.setTitle(i18n("launcher.background")); - backgroundSublist.setHasSubtitle(true); - - backgroundItem.loadChildren(Arrays.asList( - new MultiFileItem.Option<>(i18n("launcher.background.default"), EnumBackgroundImage.DEFAULT) - .setTooltip(i18n("launcher.background.default.tooltip")), - new MultiFileItem.Option<>(i18n("launcher.background.classic"), EnumBackgroundImage.CLASSIC), - new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumBackgroundImage.CUSTOM) - .setChooserTitle(i18n("launcher.background.choose")) - .addExtensionFilter(FXUtils.getImageExtensionFilter()) - .setSelectionMode(FileSelector.SelectionMode.FILE_OR_DIRECTORY) - .bindBidirectional(config().backgroundImageProperty()), - new MultiFileItem.StringOption<>(i18n("launcher.background.network"), EnumBackgroundImage.NETWORK) - .setValidators(new URLValidator(true)) - .bindBidirectional(config().backgroundImageUrlProperty()), - new MultiFileItem.PaintOption<>(i18n("launcher.background.paint"), EnumBackgroundImage.PAINT) - .bindBidirectional(config().backgroundPaintProperty()) - )); - backgroundItem.selectedDataProperty().bindBidirectional(config().backgroundImageTypeProperty()); - backgroundSublist.subtitleProperty().bind( - new When(backgroundItem.selectedDataProperty().isEqualTo(EnumBackgroundImage.DEFAULT)) - .then(i18n("launcher.background.default")) - .otherwise(config().backgroundImageProperty())); - - HBox opacityItem = new HBox(8); - { - opacityItem.setAlignment(Pos.CENTER); - - Label label = new Label(i18n("settings.launcher.background.settings.opacity")); - - JFXSlider slider = new JFXSlider(0, 100, - config().getBackgroundImageType() != EnumBackgroundImage.TRANSLUCENT - ? config().getBackgroundImageOpacity() : 50); - 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); - - if (config().getBackgroundImageType() == EnumBackgroundImage.TRANSLUCENT) { - slider.setDisable(true); - config().backgroundImageTypeProperty().addListener(new ChangeListener<>() { - @Override - public void changed(ObservableValue observable, EnumBackgroundImage oldValue, EnumBackgroundImage newValue) { - if (newValue != EnumBackgroundImage.TRANSLUCENT) { - config().backgroundImageTypeProperty().removeListener(this); - slider.setDisable(false); - slider.setValue(100); - } - } - }); - } - - Label textOpacity = new Label(); - FXUtils.setLimitWidth(textOpacity, 50); - - StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty()); - textOpacity.textProperty().bind(valueBinding); - slider.setValueFactory(s -> valueBinding); - - slider.valueProperty().addListener((observable, oldValue, newValue) -> - config().setBackgroundImageOpacity(snapOpacity(newValue.doubleValue()))); - - opacityItem.getChildren().setAll(label, slider, textOpacity); - } - - componentList.getContent().setAll(backgroundItem, opacityItem); - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("launcher.background")), componentList); - } + content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.appearance")), themeList); { ComponentList logPane = new ComponentList(); diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java deleted file mode 100644 index dfb2ce3092..0000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2025 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import javafx.scene.paint.Color; -import org.jackhuang.hmcl.theme.ThemeColor; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -/// @author Glavo -public final class ThemeColorTest { - - @Test - public void testOf() { - assertEquals(new ThemeColor("#AABBCC", Color.web("#AABBCC")), ThemeColor.of("#AABBCC")); - assertEquals(new ThemeColor("blue", Color.web("#5C6BC0")), ThemeColor.of("blue")); - assertEquals(new ThemeColor("darker_blue", Color.web("#283593")), ThemeColor.of("darker_blue")); - assertEquals(new ThemeColor("green", Color.web("#43A047")), ThemeColor.of("green")); - assertEquals(new ThemeColor("orange", Color.web("#E67E22")), ThemeColor.of("orange")); - assertEquals(new ThemeColor("purple", Color.web("#9C27B0")), ThemeColor.of("purple")); - assertEquals(new ThemeColor("red", Color.web("#B71C1C")), ThemeColor.of("red")); - - assertNull(ThemeColor.of((String) null)); - assertNull(ThemeColor.of("")); - assertNull(ThemeColor.of("unknown")); - } -}