Merge pull request 'feat: 服务器专用启动器(一键进服、新界面、精简)' (#3) from feat/server-launcher-redesign into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Canceled after 7m7s
Java CI / build (push) Failing after 3h0m4s

This commit was merged in pull request #3.
This commit is contained in:
layue13 committed 2026-11-08 07:31:54 +00:00
commit 61caf8a925
95 files changed
+1428 -14853

No files matched your search

@@ -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();
}
}
@@ -131,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Resolves the routes that the one-click join button can connect through.
@NotNullByDefault
public final class ServerEndpoint {
private ServerEndpoint() {
}
/// One selectable connection route.
///
/// @param name the display name
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
public record Route(String name, String address) {
}
/// Lists the routes configured for an instance.
///
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
/// used, each as a single route.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the routes in display order; empty when nothing is configured
public static @Unmodifiable List<Route> routes(Path configurationFile) {
List<Route> result = new ArrayList<>();
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
if (manifest != null) {
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
if (StringUtils.isNotBlank(route.getAddress())) {
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
}
}
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
result.add(new Route("default", manifest.getServerAddress().trim()));
}
}
if (result.isEmpty()) {
String fallback = Metadata.getDefaultServerAddress();
if (!fallback.isEmpty()) {
result.add(new Route("default", fallback));
}
}
return List.copyOf(result);
}
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the route to connect through, or `null` when nothing is configured
public static @Nullable Route selected(Path configurationFile) {
List<Route> routes = routes(configurationFile);
if (routes.isEmpty()) {
return null;
}
@Nullable String saved = readSelectedName(configurationFile);
for (Route route : routes) {
if (route.name().equals(saved)) {
return route;
}
}
return routes.get(0);
}
/// Remembers the player's route choice next to the instance's modpack configuration.
///
/// @param configurationFile the instance's `modpack.json` path
/// @param route the chosen route
public static void select(Path configurationFile, Route route) {
try {
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
} catch (IOException e) {
LOG.warning("Unable to save the selected route", e);
}
}
private static Path selectionFile(Path configurationFile) {
return configurationFile.resolveSibling("server-route.txt");
}
private static @Nullable String readSelectedName(Path configurationFile) {
try {
Path file = selectionFile(configurationFile);
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
} catch (IOException e) {
return null;
}
}
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
if (!Files.isRegularFile(configurationFile)) {
return null;
}
try {
ModpackConfiguration<ServerModpackManifest> configuration =
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
return configuration != null ? configuration.getManifest() : null;
} catch (Exception e) {
LOG.warning("Unable to read server routes from " + configurationFile, e);
return null;
}
}
}
@@ -82,7 +82,28 @@ public final class FontManager {
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
/// Bundled OFL fonts: Noto Sans SC for headings and controls, Noto Serif SC for body text.
private static final String[] BUNDLED_FONTS = {
"/assets/fonts/NotoSansSC-Regular.ttf",
"/assets/fonts/NotoSansSC-Bold.ttf",
"/assets/fonts/NotoSerifSC-Regular.ttf"
};
/// Registers the bundled fonts with JavaFX so style sheets can refer to them by family name.
private static void loadBundledFonts() {
for (String resource : BUNDLED_FONTS) {
try (var in = FontManager.class.getResourceAsStream(resource)) {
if (in == null || Font.loadFont(in, DEFAULT_FONT_SIZE) == null) {
LOG.warning("Failed to load bundled font " + resource);
}
} catch (java.io.IOException e) {
LOG.warning("Failed to load bundled font " + resource, e);
}
}
}
static {
loadBundledFonts();
updateFont();
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
}
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.ThemeReference;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.*;
@@ -59,7 +58,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
/// Default launcher theme used when no stored theme reference is available.
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
/// Theme appearance override key for theme brightness mode.
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
@@ -225,23 +223,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
// UI
// Theme selection
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
@SerializedName("selectedTheme")
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
/// Returns the selected installed theme property.
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
return selectedTheme;
}
/// Returns the selected installed theme, falling back to the built-in default theme.
public ThemeReference getSelectedThemeOrDefault() {
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
}
// Theme appearance overrides
/// Theme appearance setting keys overridden by the launcher.
@@ -6,12 +6,6 @@ import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.ResolvedTheme;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.Themes;
import java.nio.charset.StandardCharsets;
@@ -100,55 +94,14 @@ public final class StyleSheets {
}
private static String getBrightnessStyleSheet() {
return Themes.getColorScheme().getBrightness() == Brightness.LIGHT
? "/assets/css/brightness-light.css"
: "/assets/css/brightness-dark.css";
}
private static void addColor(StringBuilder builder, String name, Color color) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n");
}
private static void addColor(StringBuilder builder, String name, Color color, double opacity) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(color, opacity)).append(";\n");
}
private static void addColor(StringBuilder builder, ColorScheme scheme, ColorRole role, double opacity) {
builder.append(" ").append(role.getVariableName()).append("-transparent-%02d".formatted((int) (100 * opacity)))
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(scheme.getColor(role), opacity))
.append(";\n");
return "/assets/css/brightness-light.css";
}
/// Returns the launcher color style sheet.
///
/// The launcher ships a single warm palette; user-selected themes are no longer applied.
private static String getThemeStyleSheet() {
final String blueCss = "/assets/css/blue.css";
if (ResolvedTheme.DEFAULT.equals(Themes.getTheme()))
return blueCss;
ColorScheme scheme = Themes.getColorScheme();
StringBuilder builder = new StringBuilder();
builder.append("* {\n");
for (ColorRole colorRole : ColorRole.ALL) {
addColor(builder, colorRole.getVariableName(), scheme.getColor(colorRole));
}
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
addColor(builder, scheme, ColorRole.PRIMARY, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
addColor(builder, scheme, ColorRole.ON_SURFACE_VARIANT, 0.38);
addColor(builder, scheme, ColorRole.SURFACE_CONTAINER_LOW, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.8);
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString());
return "/assets/css/warm.css";
}
public static void init(Scene scene) {
@@ -1,24 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.layout.Background;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// A launcher background and the opacity applied by its rendering node.
///
/// @param background the JavaFX background rendered by the launcher
/// @param opacity the node opacity in the range `[0, 1]`
@NotNullByDefault
public record LauncherBackground(Background background, double opacity) {
/// Creates a launcher background value.
public LauncherBackground {
Objects.requireNonNull(background);
if (!Double.isFinite(opacity) || opacity < 0.0 || opacity > 1.0) {
throw new IllegalArgumentException("Launcher background opacity must be between 0 and 1: " + opacity);
}
}
}
@@ -1,38 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.*;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.*;
/// Concrete launcher theme values resolved for MonetFX.
///
/// @param primaryColorSeed Color seed used to generate the MonetFX color scheme.
/// @param brightness Brightness used by the generated color scheme.
/// @param colorStyle MonetFX color style used by the generated color scheme.
/// @param contrast MonetFX contrast level used by the generated color scheme.
@NotNullByDefault
public record ResolvedTheme(ThemeColor primaryColorSeed,
Brightness brightness,
ColorStyle colorStyle,
Contrast contrast) {
/// Default launcher theme used when no custom theme values are configured.
public static final ResolvedTheme DEFAULT = new ResolvedTheme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
/// Converts these resolved values to a MonetFX color scheme.
///
/// @return the generated color scheme
public ColorScheme toColorScheme() {
return ColorScheme.newBuilder()
.setPrimaryColorSeed(primaryColorSeed.color())
.setColorStyle(colorStyle)
.setBrightness(brightness)
.setSpecVersion(ColorSpecVersion.SPEC_2025)
.setContrast(contrast)
.build();
}
}
@@ -1,229 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// One selectable theme inside a theme pack.
///
/// The theme object's top-level appearance fields provide default values. Each
/// matching override is applied in array order to produce the resolved appearance.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
@NotNullByDefault
public record Theme(
@Nullable String id,
@Nullable LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable LocalizedText description,
@Nullable String icon,
ThemeAppearance appearance,
@Unmodifiable List<ThemeOverride> overrides) {
/// Creates a theme.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
public Theme {
if (id != null) {
id = ThemePackManifest.requireThemeId(id);
}
if (name != null && name.mayBeEmpty()) {
throw new IllegalArgumentException("Theme name is empty: " + name);
}
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
authors = List.copyOf(authors);
Objects.requireNonNull(appearance);
overrides = List.copyOf(overrides);
}
/// Parses a theme from JSON.
///
/// @param object the theme JSON object
/// @param requireIdentity whether the theme must declare an explicit ID and name
/// @return the parsed theme
/// @throws JsonParseException if required identity fields are missing or malformed
static Theme fromJson(JsonObject object, boolean requireIdentity) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String id = JsonUtils.getString(object, "id");
if (id != null) {
try {
id = ThemePackManifest.requireThemeId(id);
} catch (IllegalArgumentException e) {
if (requireIdentity) {
throw new JsonParseException(e);
}
LOG.warning("Ignored invalid theme id: " + id, e);
id = null;
}
}
if (id == null && requireIdentity) {
throw new JsonParseException("Theme is missing the id");
}
@Nullable LocalizedText name;
try {
name = LocalizedText.fromJson(object.get("name"));
if (name != null && name.mayBeEmpty()) {
throw new JsonParseException("Theme name is empty: " + name);
}
} catch (JsonParseException | IllegalArgumentException e) {
if (requireIdentity) {
throw e;
}
LOG.warning("Ignored invalid theme name", e);
name = null;
}
if (name == null && requireIdentity) {
throw new JsonParseException("Theme is missing required localized text field: " + "name");
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (Exception e) {
LOG.warning("Failed to parse authors", e);
authors = List.of();
}
@Nullable LocalizedText description;
try {
description = LocalizedText.fromJson(object.get("description"));
if (description != null) {
description = ThemePackManifest.requireLocalizedText(description, "description");
}
} catch (Exception e) {
LOG.warning("Failed to parse description", e);
description = null;
}
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
JsonElement overridesJson = object.get("overrides");
List<ThemeOverride> overrides;
if (overridesJson == null || overridesJson.isJsonNull()) {
overrides = List.of();
} else if (overridesJson instanceof JsonArray array) {
overrides = new ArrayList<>(array.size());
for (JsonElement overrideJson : array) {
try {
ThemeOverride override = ThemeOverride.fromJson(overrideJson);
if (override != null)
overrides.add(override);
} catch (Exception e) {
LOG.warning("Invalid theme override", e);
}
}
} else {
LOG.warning("Invalid theme overrides: expected an array, got " + overridesJson);
overrides = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme icon: " + icon, e);
icon = null;
}
}
return new Theme(id, name, authors, description,
icon,
appearance, overrides);
}
/// Returns the theme display name in the current locale.
///
/// @return the localized display name, or `null` when this theme has no matching display name
public @Nullable String displayName() {
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns the theme description in the current locale.
///
/// @return the localized description, or `null` when this theme has no matching description
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Resolves this theme against a context by applying matching overrides.
///
/// @param context the resolution context
/// @return the resolved appearance
public ThemeAppearance resolve(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeAppearance resolved = appearance;
for (ThemeOverride override : overrides) {
if (override.matches(context)) {
resolved = resolved.merge(override.appearance());
}
}
return resolved;
}
/// Converts this theme to its JSON representation.
///
/// @return the JSON object representing this theme
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (id != null) {
object.addProperty("id", id);
}
if (name != null) {
object.add("name", JsonUtils.GSON.toJsonTree(name, LocalizedText.class));
}
object.add("authors", ThemePackAuthor.toJson(authors));
if (description != null) {
object.add("description", JsonUtils.GSON.toJsonTree(description, LocalizedText.class));
}
if (icon != null) {
object.addProperty("icon", icon);
}
appearance.addToJsonObject(object);
if (!overrides.isEmpty()) {
JsonArray array = new JsonArray();
for (ThemeOverride override : overrides) {
array.add(override.toJsonObject());
}
object.add("overrides", array);
}
return object;
}
}
@@ -1,374 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Appearance values contributed by a theme or override.
///
/// All fields are optional so the same type can represent both a default appearance
/// and a conditional patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeAppearance(
@Nullable ThemeColorSource color,
@Nullable Brightness brightness,
@Nullable ColorStyle colorStyle,
@Nullable Contrast contrast,
@Nullable ThemeBackgroundSettings background,
@Nullable ThemeTitleBar titleBar,
@Nullable Boolean windowTransparent) {
/// JSON member name for the color seed.
static final String FIELD_COLOR = "color";
/// JSON member name for the controlled launcher brightness.
static final String FIELD_BRIGHTNESS = "brightness";
/// JSON member name for the MonetFX color style.
static final String FIELD_COLOR_STYLE = "colorStyle";
/// JSON member name for the MonetFX contrast level.
static final String FIELD_CONTRAST = "contrast";
/// JSON member name for background settings.
static final String FIELD_BACKGROUND = "background";
/// JSON member name for title-bar settings.
static final String FIELD_TITLE_BAR = "titleBar";
/// JSON member name for window transparency.
static final String FIELD_WINDOW_TRANSPARENT = "windowTransparent";
/// Creates an appearance patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
public ThemeAppearance {
if (background != null && background.isEmpty()) {
background = null;
}
if (titleBar != null && titleBar.isEmpty()) {
titleBar = null;
}
}
/// Parses known appearance fields from a JSON object.
///
/// @param object the JSON object containing appearance fields
/// @return the parsed appearance patch
static ThemeAppearance fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeAppearance(
readColor(object),
readBrightness(object),
readColorStyle(object),
readContrast(object),
readBackground(object),
readTitleBar(object),
readWindowTransparent(object));
}
/// Returns whether this appearance contains no concrete fields.
///
/// @return `true` when every appearance field is inherited
public boolean isEmpty() {
return color == null
&& brightness == null
&& colorStyle == null
&& contrast == null
&& background == null
&& titleBar == null
&& windowTransparent == null;
}
/// Adds this appearance patch's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object) {
Objects.requireNonNull(object);
if (color != null) {
object.add(FIELD_COLOR, color.toJsonElement());
}
if (brightness != null) {
object.addProperty(FIELD_BRIGHTNESS, toJsonBrightness(brightness));
}
if (colorStyle != null) {
object.addProperty(FIELD_COLOR_STYLE, colorStyle.name().toLowerCase(Locale.ROOT));
}
if (contrast != null && !contrast.equals(Contrast.DEFAULT)) {
addContrast(object, contrast);
}
if (background != null) {
object.add(FIELD_BACKGROUND, background.toJsonObject());
}
if (titleBar != null) {
object.add(FIELD_TITLE_BAR, titleBar.toJsonObject());
}
if (windowTransparent != null) {
object.addProperty(FIELD_WINDOW_TRANSPARENT, windowTransparent);
}
}
/// Converts this appearance patch to its JSON representation.
///
/// @return the JSON object representing this appearance patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Applies the given patch over this appearance.
///
/// @param patch the patch to apply
/// @return the merged appearance
public ThemeAppearance merge(ThemeAppearance patch) {
Objects.requireNonNull(patch);
return new ThemeAppearance(
patch.color != null ? patch.color : color,
patch.brightness != null ? patch.brightness : brightness,
patch.colorStyle != null ? patch.colorStyle : colorStyle,
patch.contrast != null ? patch.contrast : contrast,
background != null && patch.background != null
? background.merge(patch.background)
: patch.background != null ? patch.background : background,
titleBar != null && patch.titleBar != null
? titleBar.merge(patch.titleBar)
: patch.titleBar != null ? patch.titleBar : titleBar,
patch.windowTransparent != null ? patch.windowTransparent : windowTransparent);
}
/// Converts this appearance to concrete launcher theme values.
///
/// @param context the resolution context that provides fallback brightness when this appearance does not control it
/// @return the concrete theme used by MonetFX
public ResolvedTheme toResolvedTheme(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeColor resolvedColor = color != null ? color.resolveFallback() : ResolvedTheme.DEFAULT.primaryColorSeed();
Brightness resolvedBrightness = brightness != null ? brightness : context.brightness();
ColorStyle resolvedColorStyle = colorStyle != null ? colorStyle : ResolvedTheme.DEFAULT.colorStyle();
Contrast resolvedContrast = contrast != null ? contrast : Contrast.DEFAULT;
return new ResolvedTheme(resolvedColor, resolvedBrightness, resolvedColorStyle, resolvedContrast);
}
/// Reads the optional color field.
private static @Nullable ThemeColorSource readColor(JsonObject object) {
JsonElement element = object.get(FIELD_COLOR);
if (element == null) {
return null;
}
try {
return ThemeColorSource.fromJson(element);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme color: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional controlled brightness field.
private static @Nullable Brightness readBrightness(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_BRIGHTNESS);
if (value == null) {
return null;
}
return parseBrightness(value);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme brightness: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional color style field.
private static @Nullable ColorStyle readColorStyle(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_COLOR_STYLE);
if (value == null) {
return null;
}
String normalized = value.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return ColorStyle.DEFAULT;
}
return ColorStyle.valueOf(normalized);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme colorStyle: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional contrast field.
private static @Nullable Contrast readContrast(JsonObject object) {
JsonElement element = object.get(FIELD_CONTRAST);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive)) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
try {
if (primitive.isNumber()) {
return readContrastValue(primitive.getAsDouble());
}
if (!primitive.isString()) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
String value = primitive.getAsString();
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "low" -> Contrast.LOW;
case "standard" -> Contrast.DEFAULT;
case "medium" -> Contrast.MEDIUM;
case "high" -> Contrast.HIGH;
default -> {
try {
yield readContrastValue(Double.parseDouble(normalized));
} catch (NumberFormatException e) {
throw new JsonParseException("Invalid theme contrast: " + value, e);
}
}
};
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme contrast: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional background object.
private static @Nullable ThemeBackgroundSettings readBackground(JsonObject object) {
JsonElement element = object.get(FIELD_BACKGROUND);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject background)) {
LOG.warning("Ignored invalid theme background: expected an object, got " + element);
return null;
}
try {
return ThemeBackgroundSettings.fromJson(background);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme background: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional title-bar object.
private static @Nullable ThemeTitleBar readTitleBar(JsonObject object) {
JsonElement element = object.get(FIELD_TITLE_BAR);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject titleBar)) {
LOG.warning("Ignored invalid theme titleBar: expected an object, got " + element);
return null;
}
try {
return ThemeTitleBar.fromJson(titleBar);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme titleBar: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional window transparency field.
private static @Nullable Boolean readWindowTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_WINDOW_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme windowTransparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a validated contrast value.
private static Contrast readContrastValue(double value) {
try {
return Contrast.of(value);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Theme contrast value must be between -1 and 1: " + value, e);
}
}
/// Adds a canonical contrast value to a JSON object.
private static void addContrast(JsonObject object, Contrast contrast) {
if (contrast.equals(Contrast.LOW)) {
object.addProperty(FIELD_CONTRAST, "low");
} else if (contrast.equals(Contrast.DEFAULT)) {
object.addProperty(FIELD_CONTRAST, "standard");
} else if (contrast.equals(Contrast.MEDIUM)) {
object.addProperty(FIELD_CONTRAST, "medium");
} else if (contrast.equals(Contrast.HIGH)) {
object.addProperty(FIELD_CONTRAST, "high");
} else {
object.addProperty(FIELD_CONTRAST, contrast.getValue());
}
}
/// Parses a serialized theme brightness value.
private static Brightness parseBrightness(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "light" -> Brightness.LIGHT;
case "dark" -> Brightness.DARK;
default -> throw new IllegalArgumentException("Unsupported theme brightness: " + value);
};
}
/// Converts a controlled brightness value to its canonical JSON string.
private static String toJsonBrightness(Brightness brightness) {
return switch (brightness) {
case LIGHT -> "light";
case DARK -> "dark";
};
}
}
@@ -1,215 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Background source contributed by a theme-pack appearance.
///
/// The sealed implementations describe where the wallpaper comes from. Rendering
/// parameters such as opacity are stored by [ThemeBackgroundSettings].
@NotNullByDefault
public sealed interface ThemeBackground
permits ThemeBackground.Default, ThemeBackground.Builtin, ThemeBackground.Image,
ThemeBackground.Paint, ThemeBackground.ThemeColor {
/// JSON member name for the background type.
String FIELD_TYPE = "type";
/// JSON member name for the built-in wallpaper ID.
String FIELD_ID = "id";
/// JSON member name for the local theme-pack image path.
String FIELD_PATH = "path";
/// JSON member name for a serialized paint value.
String FIELD_PAINT = "paint";
/// Adds this background source's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object);
/// Converts this background source to its JSON representation.
///
/// @return the JSON object representing this background source
default JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Parses a background source from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background source, or `null` when no source field is present
/// @throws JsonParseException if a known field is malformed
static @Nullable ThemeBackground fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String type = readString(object, FIELD_TYPE);
@Nullable String id = readString(object, FIELD_ID);
@Nullable String path = readString(object, FIELD_PATH);
@Nullable String paint = readString(object, FIELD_PAINT);
if (type == null) {
int sourceFields = 0;
if (id != null) {
sourceFields++;
}
if (path != null) {
sourceFields++;
}
if (paint != null) {
sourceFields++;
}
if (sourceFields > 1) {
throw new JsonParseException("Theme background without type must contain only one source field");
}
if (id != null) {
return new Builtin(id);
}
if (path != null) {
return new Image(path);
}
if (paint != null) {
return new Paint(paint);
}
return null;
}
return switch (type.trim().replace('-', '_').toUpperCase(Locale.ROOT)) {
case "DEFAULT" -> new Default();
case "BUILTIN" -> new Builtin(id);
case "IMAGE" -> new Image(path);
case "PAINT" -> new Paint(paint);
case "THEME_COLOR" -> new ThemeColor();
default -> throw new JsonParseException("Unsupported theme background type: " + type);
};
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme background field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a required non-blank string value.
private static String requireNonBlank(@Nullable String value, String field) {
if (value == null) {
throw new JsonParseException("Theme background field is missing: " + field);
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Theme background field is blank: " + field);
}
return trimmed;
}
/// A source that delegates to HMCL's launcher default background resolution.
@NotNullByDefault
record Default() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "default");
}
}
/// A source that uses a launcher built-in wallpaper.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
@NotNullByDefault
record Builtin(@Nullable String id) implements ThemeBackground {
/// Creates a source that uses the fallback built-in wallpaper.
public Builtin() {
this(null);
}
/// Creates a built-in wallpaper source.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
public Builtin {
if (id != null) {
id = requireNonBlank(id, FIELD_ID);
}
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "builtin");
if (id != null) {
object.addProperty(FIELD_ID, id);
}
}
}
/// A source that uses an image stored inside the theme pack.
///
/// @param path the theme-pack relative image path
@NotNullByDefault
record Image(String path) implements ThemeBackground {
/// Creates an image background source.
///
/// @param path the theme-pack relative image path
public Image {
path = requireNonBlank(path, FIELD_PATH);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "image");
object.addProperty(FIELD_PATH, path);
}
}
/// A source that uses a JavaFX paint string.
///
/// @param paint the serialized paint value
@NotNullByDefault
record Paint(String paint) implements ThemeBackground {
/// Creates a paint background source.
///
/// @param paint the serialized paint value
public Paint {
paint = requireNonBlank(paint, FIELD_PAINT);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "paint");
object.addProperty(FIELD_PAINT, paint);
}
}
/// A source that follows the current launcher theme color.
@NotNullByDefault
record ThemeColor() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "theme_color");
}
}
}
@@ -1,103 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Background settings contributed by a theme-pack appearance.
///
/// @param source the background source, or `null` when inherited
/// @param opacity the background opacity override, or `null` when inherited
@NotNullByDefault
public record ThemeBackgroundSettings(
@Nullable ThemeBackground source,
@Nullable Double opacity) {
/// JSON member name for the background opacity.
private static final String FIELD_OPACITY = "opacity";
/// Creates background settings.
///
/// @param source the background source, or `null` when inherited
/// @param opacity the background opacity override, or `null` when inherited
public ThemeBackgroundSettings {
if (opacity != null && (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity))) {
throw new IllegalArgumentException("Theme background opacity must be between 0 and 1: " + opacity);
}
}
/// Parses background settings from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background settings
static ThemeBackgroundSettings fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeBackgroundSettings(
ThemeBackground.fromJson(object),
readOpacity(object));
}
/// Converts these settings to their JSON representation.
///
/// @return the JSON object representing these background settings
public JsonObject toJsonObject() {
JsonObject object = source != null ? source.toJsonObject() : new JsonObject();
if (opacity != null) {
object.addProperty(FIELD_OPACITY, opacity);
}
return object;
}
/// Returns whether these settings contain no concrete fields.
///
/// @return `true` when every background setting is inherited
public boolean isEmpty() {
return source == null && opacity == null;
}
/// Returns settings that apply the given patch over these settings.
///
/// @param patch the patch to apply
/// @return the merged settings
public ThemeBackgroundSettings merge(ThemeBackgroundSettings patch) {
Objects.requireNonNull(patch);
@Nullable ThemeBackground mergedSource = patch.source != null ? patch.source : source;
@Nullable Double mergedOpacity = patch.opacity != null ? patch.opacity : opacity;
return new ThemeBackgroundSettings(mergedSource, mergedOpacity);
}
/// Reads the optional opacity field.
private static @Nullable Double readOpacity(JsonObject object) {
JsonElement element = object.get(FIELD_OPACITY);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isNumber()) {
LOG.warning("Ignored invalid theme background opacity: expected a number, got " + element);
return null;
}
try {
double opacity = primitive.getAsDouble();
if (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity)) {
LOG.warning("Ignored invalid theme background opacity: expected a number between 0 and 1, got "
+ opacity);
return null;
}
return opacity;
} catch (NumberFormatException e) {
LOG.warning("Ignored invalid theme background opacity: " + e.getMessage(), e);
return null;
}
}
}
@@ -1,157 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Describes how a theme-pack appearance chooses its Monet seed color.
@NotNullByDefault
public sealed interface ThemeColorSource permits ThemeColorSource.Default, ThemeColorSource.Custom, ThemeColorSource.Wallpaper {
/// JSON member name for the source type.
String FIELD_SOURCE = "source";
/// The launcher default color source.
ThemeColorSource DEFAULT = new Default();
/// Creates a custom color source.
///
/// @param color the custom color
/// @return the custom color source
static ThemeColorSource custom(ThemeColor color) {
return new Custom(color);
}
/// Creates a wallpaper color source.
///
/// @return the wallpaper color source
static ThemeColorSource wallpaper() {
return new Wallpaper();
}
/// Parses a color source from a manifest JSON value.
///
/// @param element the JSON value
/// @return the parsed color source
/// @throws JsonParseException if the color source is malformed
static ThemeColorSource fromJson(JsonElement element) throws JsonParseException {
Objects.requireNonNull(element);
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
String value = primitive.getAsString();
if ("default".equals(value.trim().replace('-', '_').toLowerCase(Locale.ROOT))) {
return DEFAULT;
}
@Nullable ThemeColor color = ThemeColor.of(value);
if (color == null) {
throw new JsonParseException("Invalid theme color: " + value);
}
return custom(color);
}
if (!(element instanceof JsonObject object)) {
throw new JsonParseException("Theme color must be a string or object");
}
JsonElement sourceElement = object.get(FIELD_SOURCE);
if (sourceElement == null) {
throw new JsonParseException("Theme color source is missing required field: " + FIELD_SOURCE);
}
if (!(sourceElement instanceof JsonPrimitive sourcePrimitive) || !sourcePrimitive.isString()) {
throw new JsonParseException("Theme color source field must be a string: " + FIELD_SOURCE);
}
String source = sourcePrimitive.getAsString();
String normalized = source.trim().replace('-', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return DEFAULT;
}
if ("WALLPAPER".equals(normalized)) {
return wallpaper();
}
throw new JsonParseException("Unsupported theme color source: " + source);
}
/// Converts this color source to its JSON representation.
///
/// @return the color source JSON value
JsonElement toJsonElement();
/// Returns the best available color without accessing wallpaper pixels.
///
/// @return the custom color or launcher default color
ThemeColor resolveFallback();
/// The launcher default color seed.
@NotNullByDefault
record Default() implements ThemeColorSource {
/// Creates a default color source.
public Default {
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
JsonObject object = new JsonObject();
object.addProperty(FIELD_SOURCE, "default");
return object;
}
/// Returns the launcher default seed color.
@Override
public ThemeColor resolveFallback() {
return ThemeColor.DEFAULT;
}
}
/// A fixed color seed supplied by the manifest.
///
/// @param color the custom seed color
@NotNullByDefault
record Custom(ThemeColor color) implements ThemeColorSource {
/// Creates a custom color source.
///
/// @param color the custom seed color
public Custom {
Objects.requireNonNull(color);
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
return new JsonPrimitive(color.name());
}
/// Returns the custom seed color.
@Override
public ThemeColor resolveFallback() {
return color;
}
}
/// A dynamic seed color extracted from the effective wallpaper.
@NotNullByDefault
record Wallpaper() implements ThemeColorSource {
/// Creates a wallpaper color source.
public Wallpaper {
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
JsonObject object = new JsonObject();
object.addProperty(FIELD_SOURCE, "wallpaper");
return object;
}
/// Returns the launcher default color used before wallpaper pixels are available.
@Override
public ThemeColor resolveFallback() {
return ThemeColor.DEFAULT;
}
}
}
@@ -1,189 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/// A simple JSON-object condition used by theme-pack overrides.
///
/// A condition is an AND of all members. A string value requires equality, and
/// an array value requires the context value to match any listed value. Unknown
/// condition keys are accepted for forward compatibility, but they do not match
/// contexts produced by this implementation.
///
/// @param requirements normalized accepted values keyed by condition name
@NotNullByDefault
public record ThemeCondition(@Unmodifiable Map<String, @Unmodifiable Set<String>> requirements) {
/// Condition key for the effective light or dark mode.
static final String KEY_BRIGHTNESS = "brightness";
/// Condition key for the current operating system.
static final String KEY_OS = "os";
/// Condition key for the current UI language.
static final String KEY_LANGUAGE = "language";
/// Supported operating system condition values.
private static final Set<String> SUPPORTED_OS_VALUES = Set.of("windows", "macos", "linux", "freebsd", "unknown");
/// Creates a condition from normalized accepted values.
///
/// @param requirements normalized accepted values keyed by condition name
public ThemeCondition {
Objects.requireNonNull(requirements);
LinkedHashMap<String, Set<String>> copy = new LinkedHashMap<>();
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
String key = normalizeKey(entry.getKey());
Set<String> values = entry.getValue();
if (values.isEmpty()) {
throw new IllegalArgumentException("Theme condition field has no accepted values: " + key);
}
LinkedHashSet<String> valueCopy = new LinkedHashSet<>();
for (String value : values) {
valueCopy.add(normalizeValue(key, value));
}
copy.put(key, Collections.unmodifiableSet(valueCopy));
}
requirements = Collections.unmodifiableMap(copy);
}
/// Parses a theme condition from a JSON object.
///
/// @param object the condition object
/// @return the parsed condition
/// @throws JsonParseException if the condition contains malformed values
public static ThemeCondition fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
LinkedHashMap<String, Set<String>> requirements = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
String key = normalizeKey(entry.getKey());
requirements.put(key, readAcceptedValues(key, entry.getValue()));
}
return new ThemeCondition(requirements);
}
/// Returns whether this condition matches the given resolution context.
///
/// @param context the context to test
/// @return `true` if every condition member matches the context
public boolean matches(ThemeResolveContext context) {
Objects.requireNonNull(context);
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
String value = context.conditionValue(entry.getKey());
if (value == null || !entry.getValue().contains(value)) {
return false;
}
}
return true;
}
/// Converts this condition to its JSON representation.
///
/// @return the JSON object representing this condition
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
if (entry.getValue().size() == 1) {
object.addProperty(entry.getKey(), entry.getValue().iterator().next());
} else {
JsonArray array = new JsonArray();
for (String value : entry.getValue()) {
array.add(value);
}
object.add(entry.getKey(), array);
}
}
return object;
}
/// Reads one condition field value.
private static Set<String> readAcceptedValues(String key, JsonElement element) throws JsonParseException {
LinkedHashSet<String> values = new LinkedHashSet<>();
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
values.add(normalizeValue(key, primitive.getAsString()));
} else if (element instanceof JsonArray array) {
if (array.isEmpty()) {
throw new JsonParseException("Theme condition array is empty: " + key);
}
for (JsonElement item : array) {
if (!(item instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme condition array must contain strings: " + key);
}
values.add(normalizeValue(key, primitive.getAsString()));
}
} else {
throw new JsonParseException("Theme condition value must be a string or string array: " + key);
}
return values;
}
/// Normalizes and validates a condition key.
private static String normalizeKey(String key) {
Objects.requireNonNull(key);
String normalized = key.trim();
if (normalized.isEmpty()) {
throw new JsonParseException("Theme condition key is blank");
}
return normalized;
}
/// Normalizes and validates one condition value.
private static String normalizeValue(String key, String value) {
Objects.requireNonNull(key);
Objects.requireNonNull(value);
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Empty theme condition value for " + key);
}
String normalized = trimmed.toLowerCase(Locale.ROOT);
return switch (key) {
case KEY_BRIGHTNESS -> switch (normalized) {
case "light", "dark" -> normalized;
default -> throw new JsonParseException("Unsupported brightness condition value: " + value);
};
case KEY_OS -> normalizeOperatingSystemValue(normalized, value);
case KEY_LANGUAGE -> normalized;
default -> trimmed;
};
}
/// Normalizes an operating system condition value.
private static String normalizeOperatingSystemValue(String normalized, String original) {
String value = switch (normalized) {
case "win", "windows" -> "windows";
case "mac", "macos", "osx" -> "macos";
case "linux" -> "linux";
case "freebsd" -> "freebsd";
case "unknown", "universal" -> "unknown";
default -> normalized;
};
if (!SUPPORTED_OS_VALUES.contains(value)) {
throw new JsonParseException("Unsupported os condition value: " + original);
}
return value;
}
}
@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// A conditional appearance patch in a theme.
///
/// @param condition the condition required for this override to apply
/// @param appearance the appearance fields applied when the condition matches
@NotNullByDefault
public record ThemeOverride(ThemeCondition condition, ThemeAppearance appearance) {
/// JSON member name for an override condition.
private static final String FIELD_CONDITION = "condition";
/// Creates a conditional theme override.
///
/// @param condition the condition required for this override to apply
/// @param appearance the appearance fields applied when the condition matches
public ThemeOverride {
Objects.requireNonNull(condition);
Objects.requireNonNull(appearance);
if (appearance.isEmpty()) {
throw new IllegalArgumentException("Theme override does not define any appearance fields");
}
}
/// Parses a theme override from JSON.
///
/// @param element the override object
/// @return the parsed override
/// @throws JsonParseException if the override is malformed
static @Nullable ThemeOverride fromJson(@Nullable JsonElement element) throws JsonParseException {
if (element == null || element.isJsonNull())
return null;
if (!(element instanceof JsonObject object)) {
throw new JsonParseException("Invalid theme override");
}
JsonElement conditionElement = object.get(FIELD_CONDITION);
if (!(conditionElement instanceof JsonObject conditionObject)) {
throw new JsonParseException("Theme override must define an object condition");
}
ThemeCondition condition = ThemeCondition.fromJson(conditionObject);
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
if (appearance.isEmpty()) {
throw new JsonParseException("Theme override does not define any appearance fields");
}
return new ThemeOverride(condition, appearance);
}
/// Returns whether this override matches the given resolution context.
///
/// @param context the context to test
/// @return `true` when the override should be applied
public boolean matches(ThemeResolveContext context) {
return condition.matches(context);
}
/// Converts this override to its JSON representation.
///
/// @return the JSON object representing this override
public JsonObject toJsonObject() {
JsonObject object = appearance.toJsonObject();
object.add(FIELD_CONDITION, condition.toJsonObject());
return object;
}
}
@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
import java.util.Objects;
/// One resource copied into an exported theme-pack zip.
///
/// @param source the resource to copy
/// @param entryName the normalized zip entry name under `assets/`
@NotNullByDefault
public record ThemePackAsset(ThemePackResource source, String entryName) {
/// Required prefix for theme-pack asset entries.
private static final String ASSETS_PREFIX = "assets/";
/// Creates a theme-pack asset from a local file.
///
/// @param source the local file to copy
/// @param entryName the normalized zip entry name under `assets/`
public ThemePackAsset(Path source, String entryName) {
this(new ThemePackResource.File(source), entryName);
}
/// Creates a theme-pack asset entry.
///
/// @param source the resource to copy
/// @param entryName the normalized zip entry name under `assets/`
public ThemePackAsset {
Objects.requireNonNull(source);
entryName = normalizeEntryName(entryName);
}
/// Normalizes and validates a zip entry name.
///
/// @param entryName the entry name to validate
/// @return the normalized entry name
/// @throws IllegalArgumentException if the entry name is unsafe or outside `assets/`
static String normalizeEntryName(String entryName) {
Objects.requireNonNull(entryName);
String normalized = entryName.trim().replace('\\', '/');
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Theme-pack asset entry is empty");
}
if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
throw new IllegalArgumentException("Theme-pack asset entry must be relative: " + entryName);
}
if (!normalized.startsWith(ASSETS_PREFIX)) {
throw new IllegalArgumentException("Theme-pack asset entry must be under assets/: " + entryName);
}
if (normalized.endsWith("/")) {
throw new IllegalArgumentException("Theme-pack asset entry must be a file: " + entryName);
}
for (String segment : normalized.split("/")) {
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
throw new IllegalArgumentException("Theme-pack asset entry contains an unsafe segment: " + entryName);
}
for (int i = 0; i < segment.length(); i++) {
char ch = segment.charAt(i);
if (Character.isISOControl(ch) || ch == '\0') {
throw new IllegalArgumentException("Theme-pack asset entry contains a control character: " + entryName);
}
}
}
return normalized;
}
}
@@ -1,138 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Author metadata declared by a theme pack.
///
/// @param name the localized author display name
@NotNullByDefault
@JsonSerializable
@JsonAdapter(ThemePackAuthor.Adapter.class)
public record ThemePackAuthor(LocalizedText name) {
/// Parses author metadata from a JSON array.
static @Unmodifiable List<ThemePackAuthor> parseAuthors(@Nullable JsonElement element) throws JsonParseException {
if (element == null || element.isJsonNull()) {
return List.of();
}
if (!(element instanceof JsonArray jsonArray)) {
throw new JsonParseException("Theme-pack authors must be an array");
}
ArrayList<ThemePackAuthor> authors = new ArrayList<>(jsonArray.size());
int index = 0;
for (JsonElement authorJson : jsonArray) {
try {
ThemePackAuthor author = fromJson(authorJson);
if (author != null) {
authors.add(author);
}
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack author at authors[" + index + "]: " + authorJson, e);
}
index++;
}
return List.copyOf(authors);
}
/// Converts author metadata to a JSON array.
static JsonArray toJson(List<ThemePackAuthor> authors) {
JsonArray array = new JsonArray();
for (ThemePackAuthor author : authors) {
array.add(author.toJsonObject());
}
return array;
}
/// Parses author metadata from a JSON object or a plain string.
///
/// @param json the author metadata JSON
/// @return the parsed author metadata, or `null` when `json` is `null`
/// @throws JsonParseException if `json` is neither an object nor a string author name
public static @Nullable ThemePackAuthor fromJson(@Nullable JsonElement json) throws JsonParseException {
if (json == null || json instanceof JsonNull)
return null;
if (json instanceof JsonPrimitive primitive && primitive.isString()) {
try {
return new ThemePackAuthor(LocalizedText.plain(primitive.getAsString()));
} catch (IllegalArgumentException e) {
throw new JsonParseException(e);
}
}
if (!(json instanceof JsonObject jsonObject)) {
throw new JsonParseException("Theme-pack author must be an object or a string");
}
JsonElement nameJson = jsonObject.get("name");
if (nameJson == null) {
throw new JsonParseException("Missing author name: " + json);
}
LocalizedText name = LocalizedText.fromJson(nameJson);
if (name == null) {
throw new JsonParseException("The author name is null");
}
try {
return new ThemePackAuthor(name);
} catch (IllegalArgumentException e) {
throw new JsonParseException(e);
}
}
/// Creates theme-pack author metadata.
///
/// @param name the localized author display name
public ThemePackAuthor {
if (name.mayBeEmpty()) {
throw new IllegalArgumentException("The author name cannot be empty");
}
}
/// Returns the author display name in the current locale.
///
/// @return the localized author display name
public String displayName() {
return name.getText(I18n.getLocale().getCandidateLocales());
}
/// Converts this author to its JSON representation.
///
/// @return the JSON object representing this author
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
object.add("name", name.toJsonElement());
return object;
}
static final class Adapter implements JsonSerializer<@Nullable ThemePackAuthor>, JsonDeserializer<@Nullable ThemePackAuthor> {
@Override
public @Nullable ThemePackAuthor deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return ThemePackAuthor.fromJson(json);
}
@Override
public JsonElement serialize(@Nullable ThemePackAuthor src, Type typeOfSrc, JsonSerializationContext context) {
return src != null ? src.toJsonObject() : JsonNull.INSTANCE;
}
}
}
@@ -1,100 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/// Writes theme-pack manifests and assets into zip-compatible files.
@NotNullByDefault
public final class ThemePackExporter {
/// Recommended file extension for theme-pack files.
public static final String FILE_EXTENSION = ".hmcl-theme";
/// Manifest entry name used by every exported theme pack.
public static final String MANIFEST_ENTRY = "manifest.json";
/// Prevents instantiation.
private ThemePackExporter() {
}
/// Exports a theme pack to a zip-compatible file.
///
/// @param manifest the manifest to write
/// @param assets asset files to copy into the zip
/// @param outputFile the target theme-pack file
/// @throws IOException if a source file cannot be read or the zip cannot be written
/// @throws IllegalArgumentException if an asset entry is duplicated or unsafe
public static void export(
ThemePackManifest manifest,
@Unmodifiable List<ThemePackAsset> assets,
Path outputFile) throws IOException {
Objects.requireNonNull(manifest);
Objects.requireNonNull(assets);
Objects.requireNonNull(outputFile);
validateAssets(assets);
Path output = outputFile.toAbsolutePath().normalize();
Path parent = output.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path temporaryFile = FileUtils.tmpSaveFile(output);
try {
Files.deleteIfExists(temporaryFile);
try (Zipper zipper = new Zipper(temporaryFile)) {
zipper.putTextFile(JsonUtils.GSON.toJson(manifest), MANIFEST_ENTRY);
for (ThemePackAsset asset : assets) {
try (InputStream input = asset.source().openStream()) {
zipper.putStream(input, asset.entryName());
}
}
}
Files.move(temporaryFile, output, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(temporaryFile);
} catch (IOException suppressed) {
e.addSuppressed(suppressed);
}
throw e;
}
}
/// Validates all asset entries and source files before the zip is written.
private static void validateAssets(List<ThemePackAsset> assets) throws IOException {
Set<String> entries = new HashSet<>();
entries.add(MANIFEST_ENTRY);
for (ThemePackAsset asset : assets) {
Objects.requireNonNull(asset);
@Nullable Path sourceFile = asset.source().file();
if (sourceFile != null && !Files.isRegularFile(sourceFile)) {
throw new IOException("Theme-pack asset source is not a regular file: " + sourceFile);
}
try (InputStream ignored = asset.source().openStream()) {
// Validate readability before writing the target zip.
}
if (!entries.add(asset.entryName())) {
throw new IllegalArgumentException("Duplicate theme-pack zip entry: " + asset.entryName());
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,345 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Parsed metadata and themes from a theme-pack manifest.
///
/// @param id the stable package identifier
/// @param version the package version string
/// @param name the localized display name
/// @param authors the package authors
/// @param icon the optional theme-pack relative icon path
/// @param description the optional localized package description
/// @param themes selectable themes declared by the package
@NotNullByDefault
@JsonSerializable
@JsonAdapter(ThemePackManifest.Adapter.class)
public record ThemePackManifest(
String id,
String version,
LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable String icon,
@Nullable LocalizedText description,
@Unmodifiable List<Theme> themes) {
/// JSON schema for the current manifest format.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("theme-pack", new JsonSchema.Version(1, 0, 0));
/// Package and theme ID format used by theme-pack manifests.
private static final Pattern PACKAGE_ID_PATTERN = Pattern.compile(
"[A-Za-z0-9][A-Za-z0-9_-]*(\\.[A-Za-z0-9][A-Za-z0-9_-]*)*"
);
/// Creates a theme-pack manifest.
///
/// @param id the stable package identifier
/// @param version the package version string
/// @param name the localized display name
/// @param authors the package authors
/// @param icon the optional theme-pack relative icon path
/// @param description the optional localized package description
/// @param themes selectable themes declared by the package
public ThemePackManifest {
id = requirePackageId(id);
version = requireNonBlank(version, "version");
name = requireLocalizedText(name, "name");
authors = List.copyOf(authors);
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
if (description != null) {
description = requireLocalizedText(description, "description");
}
themes = List.copyOf(themes);
if (themes.isEmpty()) {
throw new IllegalArgumentException("Theme pack must declare at least one theme");
}
checkThemeIdentities(themes);
}
public boolean isSimpleThemePack() {
if (themes.size() == 1) {
Theme theme = themes.get(0);
return theme.id() == null && theme.name() == null;
} else {
return false;
}
}
/// Returns the package display name in the current locale.
///
/// @return the localized display name, or the package ID when no localized text matches
public String displayName() {
return Objects.requireNonNullElse(name.getText(I18n.getLocale().getCandidateLocales()), id);
}
/// Returns the package description in the current locale.
///
/// @return the localized description, or `null` when no localized text matches
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns the first theme with the given ID.
///
/// A `null` theme ID matches only a single-theme manifest.
///
/// @param themeId the theme ID to find, or `null` for an unnamed single-theme manifest
/// @return the matching theme, or `null` when no theme matches
public @Nullable Theme findTheme(@Nullable String themeId) {
if (themeId == null) {
return themes.size() == 1 ? themes.get(0) : null;
}
for (Theme theme : themes) {
if (themeId.equals(theme.id())) {
return theme;
}
}
return null;
}
/// Reads the required theme declaration.
private static List<Theme> readThemes(JsonObject object) {
boolean hasSingleTheme = object.has("theme");
boolean hasMultipleThemes = object.has("themes");
if (hasSingleTheme == hasMultipleThemes) {
throw new JsonParseException("Theme-pack manifest must declare exactly one of theme or themes");
}
if (hasSingleTheme) {
JsonElement element = object.get("theme");
if (!(element instanceof JsonObject themeObject)) {
throw new JsonParseException("Theme-pack theme must be an object");
}
return List.of(Theme.fromJson(themeObject, false));
}
JsonElement element = object.get("themes");
if (!(element instanceof JsonArray array)) {
throw new JsonParseException("Theme-pack manifest is missing themes array");
}
if (array.isEmpty()) {
throw new JsonParseException("Theme-pack themes array must declare at least one theme");
}
ArrayList<Theme> themes = new ArrayList<>(array.size());
for (JsonElement item : array) {
if (!(item instanceof JsonObject themeObject)) {
throw new JsonParseException("Theme-pack theme must be an object");
}
themes.add(Theme.fromJson(themeObject, true));
}
return themes;
}
/// Checks that theme IDs and names are present whenever the manifest needs them for disambiguation.
private static void checkThemeIdentities(List<Theme> themes) {
if (themes.size() <= 1) {
return;
}
for (Theme theme : themes) {
if (theme.id() == null) {
throw new IllegalArgumentException("Theme ID is required when a theme pack declares multiple themes");
}
if (theme.name() == null) {
throw new IllegalArgumentException("Theme name is required when a theme pack declares multiple themes");
}
}
}
/// Reads a required string member.
private static String requireMemberString(JsonObject object, String fieldName) {
JsonElement element = object.get(fieldName);
if (element == null) {
throw new JsonParseException("Theme-pack manifest is missing " + fieldName);
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme-pack manifest field must be a string: " + fieldName);
}
return requireNonBlank(primitive.getAsString(), fieldName);
}
/// Parses a localized text value.
static LocalizedText parseLocalizedText(JsonElement element, String field) {
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
return LocalizedText.plain(requireNonBlank(primitive.getAsString(), field));
}
if (element instanceof JsonObject localizedObject) {
if (localizedObject.isEmpty()) {
throw new JsonParseException("Localized text field is empty: " + field);
}
LinkedHashMap<String, String> localizedValues = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : localizedObject.entrySet()) {
JsonElement value = entry.getValue();
if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Localized text values must be strings: " + field);
}
localizedValues.put(
requireNonBlank(entry.getKey(), field),
requireNonBlank(primitive.getAsString(), field));
}
return new LocalizedText(localizedValues);
}
throw new JsonParseException("Theme-pack localized text must be a string or object: " + field);
}
/// Returns a validated localized text value.
static LocalizedText requireLocalizedText(LocalizedText value, String field) {
Objects.requireNonNull(value);
JsonElement element = JsonUtils.GSON.toJsonTree(value, LocalizedText.class);
return parseLocalizedText(element, field);
}
/// Returns a non-blank string value.
private static String requireNonBlank(String value, String field) {
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("Theme-pack manifest field is blank: " + field);
}
return trimmed;
}
/// Returns a package ID matching the theme-pack ID format.
static String requirePackageId(String value) {
return requireId(value, "id", "Theme-pack manifest ID must follow package ID format: ");
}
/// Returns a theme ID matching the theme-pack ID format.
static String requireThemeId(String value) {
return requireId(value, "theme id", "Theme ID must follow package ID format: ");
}
/// Returns an ID matching the theme-pack ID format.
private static String requireId(String value, String field, String messagePrefix) {
String id = requireNonBlank(value, field);
if (!PACKAGE_ID_PATTERN.matcher(id).matches()) {
throw new IllegalArgumentException(messagePrefix + value);
}
return id;
}
static final class Adapter implements JsonSerializer<@Nullable ThemePackManifest>,
JsonDeserializer<@Nullable ThemePackManifest> {
@Override
public @Nullable ThemePackManifest deserialize(
@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json instanceof JsonNull) {
return null;
}
if (!(json instanceof JsonObject object)) {
throw new JsonParseException("Theme-pack manifest is not a JsonObject");
}
if (!JsonSchema.check(object, CURRENT_SCHEMA).readable()) {
throw new JsonParseException("Unsupported theme-pack schema: " + object.get(JsonSchema.PROPERTY_SCHEMA));
}
String id = requireMemberString(object, "id");
String version = requireMemberString(object, "version");
LocalizedText name = context.deserialize(object.get("name"), LocalizedText.class);
if (name == null) {
throw new JsonParseException("Theme-pack manifest is missing name");
}
@Nullable LocalizedText description;
try {
description = context.deserialize(object.get("description"), LocalizedText.class);
if (description != null) {
description = requireLocalizedText(description, "description");
}
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack description", e);
description = null;
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack authors", e);
authors = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack icon: " + icon, e);
icon = null;
}
}
return new ThemePackManifest(
id,
version,
name,
authors,
icon,
description,
readThemes(object));
}
@Override
public JsonElement serialize(@Nullable ThemePackManifest src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null) {
return JsonNull.INSTANCE;
}
JsonObject object = new JsonObject();
object.addProperty(JsonSchema.PROPERTY_SCHEMA, CURRENT_SCHEMA.url());
object.addProperty("id", src.id);
object.addProperty("version", src.version);
object.add("name", src.name.toJsonElement());
object.add("authors", ThemePackAuthor.toJson(src.authors));
if (src.description != null) {
object.add("description", src.description.toJsonElement());
}
if (src.icon != null) {
object.addProperty("icon", src.icon);
}
if (src.isSimpleThemePack()) {
object.add("theme", src.themes.get(0).toJsonObject());
} else {
JsonArray themeArray = new JsonArray();
for (Theme theme : src.themes) {
themeArray.add(theme.toJsonObject());
}
object.add("themes", themeArray);
}
return object;
}
}
}
@@ -1,200 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import kala.compress.archivers.zip.ZipArchiveEntry;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
/// A readable resource contributed by a theme pack.
@NotNullByDefault
public sealed interface ThemePackResource
permits ThemePackResource.File, ThemePackResource.Zip, ThemePackResource.Builtin {
/// Returns a stable resource name used for display and extension detection.
///
/// @return the resource name
String name();
/// Opens a fresh input stream for this resource.
///
/// @return a new stream for reading the resource content
/// @throws IOException if the resource cannot be opened
InputStream openStream() throws IOException;
/// Returns the backing file when this resource is a regular filesystem file.
///
/// @return the backing file, or `null` when the resource is not a direct file
default @Nullable Path file() {
return null;
}
/// A resource stored as a regular filesystem file.
///
/// @param path the resource file path
/// @param name the stable resource name
@NotNullByDefault
record File(Path path, String name) implements ThemePackResource {
/// Creates a file-backed theme-pack resource named after its file name.
///
/// @param path the resource file path
public File(Path path) {
this(path, Objects.requireNonNull(path).getFileName().toString());
}
/// Creates a file-backed theme-pack resource.
///
/// @param path the resource file path
/// @param name the stable resource name
public File {
path = Objects.requireNonNull(path).toAbsolutePath().normalize();
name = Objects.requireNonNull(name).trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("Theme-pack resource name is blank");
}
}
/// Opens the file.
@Override
public InputStream openStream() throws IOException {
return Files.newInputStream(path);
}
/// Returns the backing file.
@Override
public Path file() {
return path;
}
}
/// A resource stored as an entry inside a zip theme-pack file.
///
/// @param zipFile the zip theme-pack file
/// @param entryName the normalized zip entry name
@NotNullByDefault
record Zip(Path zipFile, String entryName) implements ThemePackResource {
/// Creates a zip-entry theme-pack resource.
///
/// @param zipFile the zip theme-pack file
/// @param entryName the normalized zip entry name
public Zip {
zipFile = Objects.requireNonNull(zipFile).toAbsolutePath().normalize();
entryName = ThemePackAsset.normalizeEntryName(entryName);
}
/// Returns the zip entry name.
@Override
public String name() {
return entryName;
}
/// Opens the zip entry and closes the zip file when the returned stream is closed.
@Override
public InputStream openStream() throws IOException {
ZipArchiveReader zip = new ZipArchiveReader(zipFile, StandardCharsets.UTF_8);
boolean success = false;
try {
ZipArchiveEntry entry = zip.getEntry(entryName);
if (entry == null || entry.isDirectory()) {
throw new IOException("Theme-pack asset is missing: " + entryName);
}
InputStream input = zip.getInputStream(entry);
success = true;
return new ZipEntryInputStream(input, zip);
} finally {
if (!success) {
zip.close();
}
}
}
}
/// A resource stored in launcher-bundled classpath resources.
///
/// @param resourcePath the classpath resource path
/// @param entryName the theme-pack entry name
@NotNullByDefault
record Builtin(String resourcePath, String entryName) implements ThemePackResource {
/// Creates a classpath-backed theme-pack resource.
///
/// @param resourcePath the classpath resource path
/// @param entryName the theme-pack entry name
public Builtin {
resourcePath = Objects.requireNonNull(resourcePath);
entryName = ThemePackAsset.normalizeEntryName(entryName);
}
/// Returns the theme-pack entry name.
@Override
public String name() {
return entryName;
}
/// Opens the classpath resource.
@Override
public InputStream openStream() throws IOException {
@Nullable InputStream input = ThemePackResource.class.getResourceAsStream(resourcePath);
if (input == null) {
throw new IOException("Built-in theme-pack asset is missing: " + entryName);
}
return input;
}
}
/// An input stream that owns a zip file handle.
@NotNullByDefault
final class ZipEntryInputStream extends FilterInputStream {
/// The zip file that must stay open while this stream is read.
private final ZipArchiveReader zipFile;
/// Whether this stream has already been closed.
private boolean closed;
/// Creates a zip-entry stream.
///
/// @param input the zip entry input stream
/// @param zipFile the owning zip file
private ZipEntryInputStream(InputStream input, ZipArchiveReader zipFile) {
super(input);
this.zipFile = Objects.requireNonNull(zipFile);
}
/// Closes both the entry stream and the owning zip file.
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
@Nullable IOException exception = null;
try {
super.close();
} catch (IOException e) {
exception = e;
}
try {
zipFile.close();
} catch (IOException e) {
if (exception != null) {
exception.addSuppressed(e);
} else {
exception = e;
}
}
if (exception != null) {
throw exception;
}
}
}
}
@@ -1,30 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Identifies one theme declared by an installed theme pack.
///
/// @param packId the theme-pack identifier
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
@NotNullByDefault
@JsonSerializable
public record ThemeReference(
@SerializedName("packId") String packId,
@SerializedName("themeId") @Nullable String themeId) {
/// Creates a theme reference.
///
/// @param packId the theme-pack identifier
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
public ThemeReference {
packId = ThemePackManifest.requirePackageId(packId);
if (themeId != null) {
themeId = ThemePackManifest.requireThemeId(themeId);
}
}
}
@@ -1,79 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.Brightness;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Environment values used to resolve conditional theme-pack overrides.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @param os the normalized operating system name used by theme conditions
/// @param language the normalized UI language subtag used by theme conditions
@NotNullByDefault
public record ThemeResolveContext(
Brightness brightness,
String os,
String language) {
/// Creates a theme resolution context.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @param os the normalized operating system name used by theme conditions
/// @param language the normalized UI language subtag used by theme conditions
public ThemeResolveContext {
Objects.requireNonNull(brightness);
os = normalizeToken(os, "os");
language = normalizeToken(language, "language");
}
/// Creates a context for the current process platform.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @return a context containing the current OS and default language
public static ThemeResolveContext current(Brightness brightness) {
return new ThemeResolveContext(
brightness,
normalizeOperatingSystem(OperatingSystem.CURRENT_OS),
Locale.getDefault().getLanguage());
}
/// Returns the normalized value for one supported condition key.
///
/// @param key the condition key
/// @return the normalized context value, or `null` when the key is unsupported
@Nullable String conditionValue(String key) {
return switch (key) {
case ThemeCondition.KEY_BRIGHTNESS -> brightness.name().toLowerCase(Locale.ROOT);
case ThemeCondition.KEY_OS -> os;
case ThemeCondition.KEY_LANGUAGE -> language;
default -> null;
};
}
/// Returns the normalized name for an operating system.
///
/// @param operatingSystem the operating system to normalize
/// @return the normalized operating system name
static String normalizeOperatingSystem(OperatingSystem operatingSystem) {
Objects.requireNonNull(operatingSystem);
return operatingSystem == OperatingSystem.UNKNOWN ? "unknown" : operatingSystem.getCheckedName();
}
/// Normalizes one condition context token.
private static String normalizeToken(String value, String name) {
Objects.requireNonNull(value, name);
String normalized = value.trim().toLowerCase(Locale.ROOT);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Empty theme condition context value: " + name);
}
return normalized;
}
}
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Title-bar settings contributed by a theme-pack appearance.
///
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeTitleBar(@Nullable Boolean transparent) {
/// JSON member name for title-bar transparency.
private static final String FIELD_TRANSPARENT = "transparent";
/// Creates a title-bar patch.
///
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
public ThemeTitleBar {
}
/// Parses title-bar settings from a JSON object.
///
/// @param object the title-bar JSON object
/// @return the parsed title-bar patch
static ThemeTitleBar fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeTitleBar(readTransparent(object));
}
/// Converts this title-bar patch to its JSON representation.
///
/// @return the JSON object representing this title-bar patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (transparent != null) {
object.addProperty(FIELD_TRANSPARENT, transparent);
}
return object;
}
/// Returns whether this title-bar object contains no concrete fields.
///
/// @return `true` when every field is inherited
public boolean isEmpty() {
return transparent == null;
}
/// Returns a title-bar patch that applies the given patch over this title-bar patch.
///
/// @param patch the patch to apply
/// @return the merged title-bar patch
public ThemeTitleBar merge(ThemeTitleBar patch) {
Objects.requireNonNull(patch);
return new ThemeTitleBar(patch.transparent != null ? patch.transparent : transparent);
}
/// Reads the optional transparent field.
private static @Nullable Boolean readTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme titleBar.transparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
}
File diff suppressed because it is too large. Load diff
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
/// Extracts a MonetFX seed color from wallpaper images.
@NotNullByDefault
public final class WallpaperColorExtractor {
/// Prevents instantiation.
private WallpaperColorExtractor() {
}
/// Extracts a theme color from an image file.
///
/// @param imageFile the image file
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
/// @throws IOException if the image file cannot be read
public static ThemeColor extract(Path imageFile, ThemeColor fallback) throws IOException {
Objects.requireNonNull(imageFile);
Objects.requireNonNull(fallback);
Image image;
try {
image = FXUtils.loadImage(imageFile);
} catch (Exception e) {
if (e instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Failed to load wallpaper image: " + imageFile, e);
}
return extract(image, fallback);
}
/// Extracts a theme color from a theme-pack resource.
///
/// @param resource the theme-pack resource
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
/// @throws IOException if the resource cannot be read
static ThemeColor extract(ThemePackResource resource, ThemeColor fallback) throws IOException {
Objects.requireNonNull(resource);
Objects.requireNonNull(fallback);
Image image;
try {
image = FXUtils.loadImage(resource.openStream(), resource.name());
} catch (Exception e) {
if (e instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Failed to load wallpaper image: " + resource.name(), e);
}
return extract(image, fallback);
}
/// Extracts a theme color from a loaded image.
///
/// @param image the loaded image
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
public static ThemeColor extract(Image image, ThemeColor fallback) {
Objects.requireNonNull(image);
Objects.requireNonNull(fallback);
Color extracted = ColorScheme.extractColor(image, fallback.color());
return ThemeColor.of(extracted);
}
}
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.NotNullByDefault;
/// The fixed warm cream palette used by window chrome that is painted from Java.
///
/// Values match `/assets/css/warm.css`, which supplies the same colors to style sheets.
@NotNullByDefault
public final class WarmPalette {
private WarmPalette() {
}
/// The window and title-bar background.
public static final Color WINDOW = Color.web("#F3EEE6");
/// The title-bar text and icon color.
public static final Color INK = Color.web("#2A2622");
}
@@ -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;
@@ -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));
};
@@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
@@ -340,15 +339,6 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
int rowIndex = 0;
if (!IntegrityChecker.isOfficial() && !(factory instanceof OfflineAccountFactory)) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("unofficial.hint"));
GridPane.setColumnSpan(hintPane, 2);
add(hintPane, 0, rowIndex);
rowIndex++;
}
if (factory instanceof BoundAuthlibInjectorAccountFactory) {
this.server = ((BoundAuthlibInjectorAccountFactory) factory).getServer();
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// A small dialog shown when the player has no account yet.
///
/// The player either types a game name, which creates an offline account, or chooses to sign in
/// with a Microsoft account instead. Authentication itself is left to the server.
@NotNullByDefault
public final class FirstRunAccountDialog extends JFXDialogLayout implements DialogAware {
/// Names accepted by the game: letters, digits and underscores, at most 16 characters.
private static final Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,16}$");
private final JFXTextField nameField = new JFXTextField();
private final Label hint = new Label();
private final JFXButton acceptButton = new JFXButton(i18n("first_run.accept"));
/// Creates the dialog with its name field and the two actions.
public FirstRunAccountDialog() {
Label heading = new Label(i18n("first_run.title"));
setHeading(heading);
Label description = new Label(i18n("first_run.description"));
description.setWrapText(true);
nameField.setPromptText(i18n("first_run.name"));
hint.setStyle("-fx-text-fill: -monet-error;");
hint.setWrapText(true);
VBox body = new VBox(13, description, nameField, hint);
body.setPrefWidth(377);
setBody(body);
JFXButton microsoftButton = new JFXButton(i18n("first_run.microsoft"));
microsoftButton.getStyleClass().add("dialog-cancel");
microsoftButton.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT));
});
acceptButton.getStyleClass().add("dialog-accept");
acceptButton.setOnAction(e -> createAccount());
nameField.setOnAction(e -> acceptButton.fire());
setActions(microsoftButton, acceptButton);
}
/// Validates the typed name and creates and selects the offline account.
private void createAccount() {
String name = nameField.getText() == null ? "" : nameField.getText().trim();
if (!NAME_PATTERN.matcher(name).matches()) {
hint.setText(i18n("first_run.invalid"));
return;
}
hint.setText("");
acceptButton.setDisable(true);
Task.supplyAsync(() -> Accounts.FACTORY_OFFLINE.create(
new CreateAccountPane.DialogCharacterSelector(), name, null, null,
new OfflineAccountFactory.AdditionalData(null, null)))
.whenComplete(Schedulers.javafx(), account -> {
if (!Accounts.getAccounts().contains(account)) {
Accounts.getAccounts().add(account);
}
Accounts.setSelectedAccount(account);
fireEvent(new DialogCloseEvent());
}, exception -> {
acceptButton.setDisable(false);
hint.setText(Accounts.localizeErrorMessage(exception));
}).start();
}
@Override
public void onDialogShown() {
nameField.requestFocus();
}
}
@@ -121,7 +121,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// Animates forward navigation with temporary incoming-page caching.
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
@@ -152,7 +152,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// Animates backward navigation with temporary incoming-page caching.
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
@@ -23,10 +23,10 @@ import org.jetbrains.annotations.Nullable;
public class TransitionPane extends StackPane {
/// The default easing curve for page transitions.
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE;
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE_OUT_CUBIC;
/// The default duration for page transitions.
public static final Duration DEFAULT_DURATION = Motion.SHORT4;
public static final Duration DEFAULT_DURATION = Duration.millis(320);
/// Half the default duration for transitions with two phases.
public static final Duration DEFAULT_DURATION_HALF = DEFAULT_DURATION.divide(2);
@@ -1,42 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
public class DoubleValidator extends ValidatorBase {
private final boolean nullable;
public DoubleValidator() {
this(false);
}
public DoubleValidator(@NamedArg("nullable") boolean nullable) {
this.nullable = nullable;
}
public DoubleValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
super(message);
this.nullable = nullable;
}
@Override
protected void eval() {
if (srcControl.get() instanceof TextInputControl) {
evalTextInputField();
}
}
private void evalTextInputField() {
TextInputControl textField = ((TextInputControl) srcControl.get());
if (StringUtils.isBlank(textField.getText()))
hasErrors.set(!nullable);
else
hasErrors.set(Lang.toDoubleOrNull(textField.getText()) == null);
}
}
@@ -1,50 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import static javafx.collections.FXCollections.emptyObservableList;
import static javafx.collections.FXCollections.observableList;
import static javafx.collections.FXCollections.singletonObservableList;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListCell;
import javafx.beans.binding.Bindings;
import javafx.scene.text.Font;
public final class FontComboBox extends JFXComboBox<String> {
private boolean loaded = false;
public FontComboBox() {
setMinWidth(260);
styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\""));
setCellFactory(listView -> new JFXListCell<>() {
@Override
public void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (!empty) {
setText(item);
setGraphic(null);
setStyle("-fx-font-family: \"" + item + "\"");
}
}
});
itemsProperty().bind(BindingMapping.of(valueProperty())
.map(value -> value == null ? emptyObservableList() : singletonObservableList(value)));
FXUtils.onClicked(this, () -> {
if (loaded)
return;
itemsProperty().unbind();
setItems(observableList(Font.getFamilies()));
loaded = true;
});
}
}
@@ -1,53 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.util.Callback;
/// @author Glavo
public final class JFXCheckBoxTableCell<S, T> extends TableCell<S, T> {
public static <S> Callback<TableColumn<S, Boolean>, TableCell<S, Boolean>> forTableColumn(
final TableColumn<S, Boolean> column) {
return list -> new JFXCheckBoxTableCell<>();
}
private final JFXCheckBox checkBox = new JFXCheckBox();
private BooleanProperty booleanProperty;
public JFXCheckBoxTableCell() {
this.getStyleClass().add("jfx-checkbox-table-cell");
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setGraphic(null);
checkBox.disableProperty().unbind();
} else {
setGraphic(checkBox);
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) {
booleanProperty = obsValue;
checkBox.selectedProperty().bindBidirectional(booleanProperty);
}
checkBox.disableProperty().bind(Bindings.not(
getTableView().editableProperty().and(
getTableColumn().editableProperty()).and(
editableProperty())
));
}
}
}
@@ -1,227 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Window;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class LineFileChooserButton extends LineButton {
private static final String DEFAULT_STYLE_CLASS = "line-file-select-button";
public LineFileChooserButton() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setTrailingIcon(SVG.EDIT);
}
/// Converts the given path to absolute/relative(if possible) path according to [#convertToRelativePathProperty()].
private String processPath(Path path) {
if (isConvertToRelativePath() && path.isAbsolute()) {
try {
return Metadata.CURRENT_DIRECTORY.relativize(path).normalize().toString();
} catch (IllegalArgumentException e) {
// the given path can't be relativized against current path
}
}
return path.normalize().toString();
}
@Override
public void fire() {
super.fire();
Scene scene = this.getScene();
Window owner = scene != null ? scene.getWindow() : null; // TODO: Allow user to set owner stage
String windowTitle = getFileChooserTitle();
Path initialDirectory = null;
if (getLocation() != null) {
Path file;
try {
file = FileUtils.toAbsolute(Path.of(getLocation()));
if (Files.exists(file)) {
if (Files.isRegularFile(file))
initialDirectory = file.getParent();
else if (Files.isDirectory(file))
initialDirectory = file;
}
} catch (IllegalArgumentException e) {
LOG.warning("Failed to resolve path: " + getLocation());
}
}
Path path;
Type type = getType();
if (type == Type.OPEN_DIRECTORY) {
var directoryChooser = new DirectoryChooser();
if (windowTitle != null)
directoryChooser.setTitle(windowTitle);
if (initialDirectory != null)
directoryChooser.setInitialDirectory(initialDirectory.toFile());
path = FileUtils.toPath(directoryChooser.showDialog(owner));
} else {
var fileChooser = new FileChooser();
if (windowTitle != null)
fileChooser.setTitle(windowTitle);
if (initialDirectory != null)
fileChooser.setInitialDirectory(initialDirectory.toFile());
if (extensionFilters != null)
fileChooser.getExtensionFilters().setAll(extensionFilters);
fileChooser.setInitialFileName(getInitialFileName());
path = FileUtils.toPath(switch (type) {
case OPEN_FILE -> fileChooser.showOpenDialog(owner);
case SAVE_FILE -> fileChooser.showSaveDialog(owner);
default -> throw new AssertionError("Unknown Type: " + type);
});
}
if (path != null) {
setLocation(processPath(path));
}
}
private final StringProperty location = new StringPropertyBase() {
@Override
public Object getBean() {
return LineFileChooserButton.this;
}
@Override
public String getName() {
return "location";
}
@Override
protected void invalidated() {
setTrailingText(get());
}
};
public StringProperty locationProperty() {
return location;
}
public String getLocation() {
return locationProperty().get();
}
public void setLocation(String location) {
locationProperty().set(location);
}
private final StringProperty fileChooserTitle = new SimpleStringProperty(this, "fileChooserTitle");
public StringProperty fileChooserTitleProperty() {
return fileChooserTitle;
}
public String getFileChooserTitle() {
return fileChooserTitleProperty().get();
}
public void setFileChooserTitle(String fileChooserTitle) {
fileChooserTitleProperty().set(fileChooserTitle);
}
private ObjectProperty<Type> type;
public ObjectProperty<Type> typeProperty() {
if (type == null) {
type = new SimpleObjectProperty<>(this, "type", Type.OPEN_FILE);
}
return type;
}
public Type getType() {
return type != null ? type.get() : Type.OPEN_FILE;
}
public void setType(Type type) {
typeProperty().set(type);
}
private ObjectProperty<String> initialFileName;
public final ObjectProperty<String> initialFileNameProperty() {
if (initialFileName == null)
initialFileName = new SimpleObjectProperty<>(this, "initialFileName");
return initialFileName;
}
public final String getInitialFileName() {
return initialFileName != null ? initialFileName.get() : null;
}
public final void setInitialFileName(String value) {
initialFileNameProperty().set(value);
}
private ObservableList<FileChooser.ExtensionFilter> extensionFilters;
public ObservableList<FileChooser.ExtensionFilter> getExtensionFilters() {
if (extensionFilters == null)
extensionFilters = FXCollections.observableArrayList();
return extensionFilters;
}
private BooleanProperty convertToRelativePath;
public BooleanProperty convertToRelativePathProperty() {
if (convertToRelativePath == null)
convertToRelativePath = new BooleanPropertyBase(false) {
@Override
public Object getBean() {
return LineFileChooserButton.this;
}
@Override
public String getName() {
return "convertToRelativePath";
}
@Override
protected void invalidated() {
String location = getLocation();
if (location == null)
return;
try {
setLocation(processPath(FileUtils.toAbsolute(Path.of(getLocation()))));
} catch (IllegalArgumentException ignored) {
}
}
};
return convertToRelativePath;
}
public boolean isConvertToRelativePath() {
return convertToRelativePath != null && convertToRelativePath.get();
}
public void setConvertToRelativePath(boolean convertToRelativePath) {
convertToRelativePathProperty().set(convertToRelativePath);
}
public enum Type {
OPEN_FILE,
OPEN_DIRECTORY,
SAVE_FILE
}
}
@@ -48,6 +48,9 @@ public final class LineInheritableSwitch extends LineButtonBase {
this.inheritButton = new JFXButton();
inheritButton.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS);
inheritButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
// Hidden: the launcher has a single settings page, so there is no inherited/overridden choice to make.
inheritButton.setVisible(false);
inheritButton.setManaged(false);
inheritButton.setGraphic(SVG.PUBLIC.createIcon(INHERIT_BUTTON_ICON_SIZE));
this.inheritTooltip = new Tooltip();
FXUtils.installFastTooltip(inheritButton, inheritTooltip);
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.MultipleSelectionModel;
public class NoneMultipleSelectionModel<T> extends MultipleSelectionModel<T> {
public NoneMultipleSelectionModel() {
}
@Override
public ObservableList<Integer> getSelectedIndices() {
return FXCollections.emptyObservableList();
}
@Override
public ObservableList<T> getSelectedItems() {
return FXCollections.emptyObservableList();
}
@Override
public void selectIndices(int index, int... indices) {
}
@Override
public void selectAll() {
}
@Override
public void clearAndSelect(int index) {
}
@Override
public void select(int index) {
}
@Override
public void select(T obj) {
}
@Override
public void clearSelection(int index) {
}
@Override
public void clearSelection() {
}
@Override
public boolean isSelected(int index) {
return false;
}
@Override
public boolean isEmpty() {
return true;
}
@Override
public void selectPrevious() {
}
@Override
public void selectNext() {
}
@Override
public void selectFirst() {
}
@Override
public void selectLast() {
}
}
@@ -81,10 +81,10 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
@NotNullByDefault
public final class Decorator {
/// The minimum width, in pixels, of the visible window content.
private static final double MIN_CONTENT_WIDTH = 800.0;
private static final double MIN_CONTENT_WIDTH = 987.0;
/// The minimum height, in pixels, including the 450-pixel background and 40-pixel title bar.
private static final double MIN_CONTENT_HEIGHT = 450.0 + 40.0;
private static final double MIN_CONTENT_HEIGHT = 570.0 + 40.0;
/// The minimum visible overlap, in pixels, used to accept a persisted window position.
private static final double MIN_VISIBLE_WINDOW_AREA = 20.0;
@@ -21,12 +21,11 @@ import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.theme.WarmPalette;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
@@ -81,7 +80,7 @@ final class MainWindowPane extends StackPane {
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
WarmPalette.WINDOW,
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
@@ -101,7 +100,11 @@ final class MainWindowPane extends StackPane {
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(windowButtons);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
titleBar.setLeft(windowButtons);
} else {
titleBar.setRight(windowButtons);
}
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
@@ -111,7 +114,6 @@ final class MainWindowPane extends StackPane {
updateTitleBarBackground();
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
InvalidationListener navBarListener = observable -> updateNavBar();
decorator.stateProperty().addListener(holder.weak(navBarListener));
@@ -153,16 +155,7 @@ final class MainWindowPane extends StackPane {
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
holder.onWeakChangeAndOperate(Themes.backgroundProperty(), background -> {
double opacity = background.opacity();
if (opacity >= 0.001) {
backgroundNode.setBackground(background.background());
backgroundNode.setOpacity(opacity);
} else {
backgroundNode.setBackground(null);
backgroundNode.setOpacity(0);
}
});
// The launcher paints a flat cream window; wallpapers are no longer used.
return backgroundNode;
}
@@ -171,21 +164,39 @@ final class MainWindowPane extends StackPane {
/// @return a size-limited top-right button row
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
decorator.capableDraggingWindow(buttons);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
// macOS style: round close/minimize buttons on the left. Zoom is shown but disabled.
buttons.setAlignment(Pos.CENTER_LEFT);
buttons.setSpacing(8);
buttons.setPadding(new Insets(0, 0, 0, 20));
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton closeButton = macButton("mac-close", decorator::closeWindow);
JFXButton minimizeButton = macButton("mac-minimize", decorator::minimizeWindow);
JFXButton zoomButton = macButton("mac-zoom", () -> {
});
zoomButton.setDisable(true);
buttons.getChildren().setAll(closeButton, minimizeButton, zoomButton);
return buttons;
}
// Windows and Linux style: flat rectangular buttons on the right.
buttons.setAlignment(Pos.TOP_RIGHT);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton minimizeButton = new JFXButton();
minimizeButton.setFocusTraversable(false);
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
minimizeButton.getStyleClass().add("jfx-decorator-button");
minimizeButton.getStyleClass().add("window-button");
minimizeButton.setOnAction(event -> decorator.minimizeWindow());
decorator.forbidDraggingWindow(minimizeButton);
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.getStyleClass().addAll("window-button", "window-close-button");
closeButton.setOnAction(event -> decorator.closeWindow());
decorator.forbidDraggingWindow(closeButton);
@@ -193,23 +204,25 @@ final class MainWindowPane extends StackPane {
return buttons;
}
/// Creates one round macOS-style window button.
///
/// @param styleClass the class that selects the button color
/// @param action the action to run when the button is pressed
private JFXButton macButton(String styleClass, Runnable action) {
JFXButton button = new JFXButton();
button.setFocusTraversable(false);
button.getStyleClass().addAll("mac-window-button", styleClass);
button.setOnAction(event -> action.run());
decorator.forbidDraggingWindow(button);
return button;
}
/// Recomputes the title-bar background for the current theme and transparency preference.
private void updateTitleBarBackground() {
@Nullable ColorScheme colorScheme = Themes.getColorScheme();
if (colorScheme == null) {
titleBar.setBackground(null);
} else if (Themes.titleBarTransparentProperty().get()) {
Color surface = colorScheme.getSurface();
titleBar.setBackground(new Background(new BackgroundFill(
new Color(surface.getRed(), surface.getGreen(), surface.getBlue(), 0.5),
CornerRadii.EMPTY,
Insets.EMPTY)));
} else {
titleBar.setBackground(new Background(new BackgroundFill(
colorScheme.getPrimaryContainer(),
CornerRadii.EMPTY,
Insets.EMPTY)));
}
titleBar.setBackground(new Background(new BackgroundFill(
WarmPalette.WINDOW,
CornerRadii.EMPTY,
Insets.EMPTY)));
}
/// Rebuilds the navigation bar from the current page state.
@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Helpers for downloading a remote addon file into an instance subdirectory.
///
/// The former free-install / CurseForge-Modrinth browse hub was removed for the
/// server-client launcher; only install-into-folder callbacks remain for update flows.
public final class DownloadPage {
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
private DownloadPage() {
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
@Nullable HMCLGameInstance instance = instanceId != null
? repository.findInstance(instanceId)
: repository.getSelectedInstance();
Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory();
var targetPath = runDirectory.resolve(subdirectoryName);
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(targetPath, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + targetPath, e);
existingPaths = new FileNameSet(false);
}
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
Path dest = targetPath.resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
task.setName(file.name());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
if (!(exception instanceof CancellationException)) {
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
}
} else {
Controllers.showToast(i18n("install.success"));
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
handler.resolve();
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
}
}
@@ -1,139 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InstallersPage extends AbstractInstallersPage {
private boolean isNameModifiedByUser = false;
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
txtName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty()));
txtName.textProperty().addListener((obs, oldText, newText) -> isNameModifiedByUser = true);
}
@Override
public String getTitle() {
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
}
private String getVersion(String id) {
return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id));
}
protected void reload() {
for (InstallerItem component : group.getComponents()) {
String libraryId = component.getComponentType().getPatchId();
if (controller.getSettings().containsKey(libraryId)) {
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
} else {
component.versionProperty().set(null);
}
}
if (!isNameModifiedByUser) {
setTxtNameWithLoaders();
}
}
@Override
public void cleanup(SettingsMap settings) {
}
private static boolean checkName(String name) {
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (!(c >= '0' && c <= '9')
&& !(c >= 'a' && c <= 'z')
&& !(c >= 'A' && c <= 'Z')
&& c != '-' && c != '_' && c != '.'
)
return false;
}
return true;
}
protected void onInstall() {
GameInstanceID instanceId = new GameInstanceID(txtName.getText());
if (!checkName(instanceId.id())) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}, () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}
}
private void setTxtNameWithLoaders() {
StringBuilder nameBuilder = new StringBuilder(getTitle());
for (InstallerItem component : group.getComponents()) {
if (component.getComponentType() == GameComponentType.GAME
|| !controller.getSettings().containsKey(component.getComponentType().getPatchId()))
continue;
String loaderName = switch (component.getComponentType()) {
case FORGE -> "Forge";
case NEO_FORGE -> "NeoForge";
case CLEANROOM -> "Cleanroom";
case LEGACY_FABRIC -> "LegacyFabric";
case FABRIC -> "Fabric";
case LITELOADER -> "LiteLoader";
case QUILT -> "Quilt";
case OPTIFINE -> "OptiFine";
default -> null;
};
if (loaderName != null)
nameBuilder.append('-').append(loaderName);
}
txtName.setText(nameBuilder.toString());
isNameModifiedByUser = false;
}
@Override
protected boolean showExtendPane() {
return true;
}
@Override
protected void resetDefaultName() {
isNameModifiedByUser = false;
setTxtNameWithLoaders();
}
}
@@ -1348,6 +1348,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
var button = new JFXButton();
button.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS);
button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
// The launcher has a single settings page, so players never choose between inheriting and overriding.
button.setVisible(false);
button.setManaged(false);
Tooltip tooltip = new Tooltip();
button.getProperties().put(INHERIT_BUTTON_TOOLTIP_KEY, tooltip);
FXUtils.installFastTooltip(button, tooltip);
@@ -1,515 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.addon.update.AddonUpdate;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.addon.update.AddonUpdateTask;
import org.jackhuang.hmcl.addon.update.AddonUpdates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CSVTable;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import org.jackhuang.hmcl.util.javafx.Subscription;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update")));
private final DownloadProvider downloadProvider;
private final Path localAddonDirectory;
private final ObservableList<AddonUpdateObject> objects;
private final JFXListView<AddonUpdateObject> listView;
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
this.downloadProvider = downloadProvider;
this.localAddonDirectory = localAddonDirectory;
getStyleClass().add("gray-background");
var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList());
var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList());
objects = FXCollections.observableArrayList();
{
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("addon.check_update.only_preview"));
placeholderContainer.getChildren().add(placeholderLabel);
listView = new JFXListView<>();
listView.setItems(objects);
listView.getStyleClass().add("jfx-list-view-float");
listView.getStyleClass().add("no-horizontal-scrollbar");
listView.setPlaceholder(placeholderContainer);
setMargin(listView, new Insets(0, 10, 0, 10));
VBox.setVgrow(listView, Priority.ALWAYS);
listView.setCellFactory(AddonUpdateListCell::new);
ComponentList.setVgrow(listView, Priority.ALWAYS);
centrePane.getContent().setAll(listView);
centerWrapper.getChildren().setAll(centrePane);
setCenter(centerWrapper);
}
{
HBox actions = new HBox(8);
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXCheckBox allEnabledBox = new JFXCheckBox();
allEnabledBox.setText(i18n("button.select_all"));
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
{
List<Subscription> subscriptions = new ArrayList<>();
FXUtils.onChangeAndOperate(showPreviewCheck.selectedProperty(), preview -> {
if (preview) objects.setAll(common.get());
else objects.setAll(release.get());
subscriptions.forEach(Subscription::unsubscribe);
subscriptions.clear();
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
});
}
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
exportListButton.setOnAction(e -> exportList());
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
nextButton.setOnAction(e -> updateFiles());
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
onEscPressed(listView, cancelButton::fire);
actions.getChildren().setAll(allEnabledBox, showPreviewCheck, spacer, exportListButton, nextButton, cancelButton);
setBottom(actions);
}
}
private void updateFiles() {
AddonUpdateTask task = new AddonUpdateTask(
downloadProvider,
localAddonDirectory,
objects.stream()
.filter(AddonUpdateObject::isEnabled)
.map(AddonUpdateObject::getData)
.toList()
);
Controllers.taskDialog(
task.whenComplete(Schedulers.javafx(), exception -> {
fireEvent(new PageCloseEvent());
if (!task.getFailedAddons().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.failed_download") + "\n" +
task.getFailedAddons().stream().map(LocalAddonFile::getFileName).collect(Collectors.joining("\n")),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
if (exception == null) {
Controllers.dialog(i18n("install.success"));
}
}),
i18n("addon.check_update"),
TaskCancellationAction.NORMAL);
}
private void exportList() {
Path path = Paths.get("hmcl-mod-update-list-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".csv").toAbsolutePath();
Controllers.taskDialog(Task.runAsync(() -> {
CSVTable csvTable = new CSVTable();
csvTable.set(0, 0, "Source File Name");
csvTable.set(1, 0, "Current Version");
csvTable.set(2, 0, "Target Version");
csvTable.set(3, 0, "Update Source");
for (int i = 0; i < objects.size(); i++) {
csvTable.set(0, i + 1, objects.get(i).getFileName());
csvTable.set(1, i + 1, objects.get(i).getCurrentVersion());
csvTable.set(2, i + 1, objects.get(i).getTargetVersion());
csvTable.set(3, i + 1, objects.get(i).getSource());
}
csvTable.write(path);
FXUtils.showFileInExplorer(path);
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception == null) {
Controllers.dialog(path.toString(), i18n("message.success"));
} else {
Controllers.dialog("", i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}), i18n("button.export"), TaskCancellationAction.NORMAL);
}
@Override
public ReadOnlyObjectWrapper<State> stateProperty() {
return state;
}
private static final class AddonUpdateObject {
final AddonUpdate data;
final String targetVersionType;
final String source;
String changelog = null;
final BooleanProperty enabled = new SimpleBooleanProperty();
final ItemPropertyAsyncCache<Image, AddonUpdateObject> iconCache;
public AddonUpdateObject(AddonUpdate data) {
this.data = data;
this.targetVersionType = switch (data.targetVersion().versionType()) {
case Release -> i18n("addon.channel.release");
case Beta -> i18n("addon.channel.beta");
case Alpha -> i18n("addon.channel.alpha");
};
this.source = switch (data.currentVersion().source()) {
case CURSEFORGE -> i18n("addon.curseforge");
case MODRINTH -> i18n("addon.modrinth");
};
this.enabled.set(!data.localAddonFile().isDisabled());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public AddonUpdate getData() {
return data;
}
public boolean isEnabled() {
return enabled.get();
}
public BooleanProperty enabledProperty() {
return enabled;
}
public LocalAddonFile<?> getLocalAddonFile() {
return data.localAddonFile();
}
public String getFileName() {
return data.localAddonFile().getFileName();
}
public String getCurrentVersion() {
return data.currentVersion().version();
}
public String getTargetVersion() {
return data.targetVersion().version();
}
public String getTargetVersionType() {
return targetVersionType;
}
public String getSource() {
return source;
}
private Image getDefaultIcon() {
if (data.localAddonFile() instanceof LocalModFile localModFile) {
return GameInstanceIconType
.getIconType(localModFile.getModLoaderType())
.getIcon();
}
return null;
}
private Image loadIcon() {
if (data.localAddonFile() instanceof LocalModFile localModFile) {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(localModFile.getLogoPath())) {
iconPaths.add(localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(
iconPath,
80,
80,
true,
true
);
if (!image.isError()
&& image.getWidth() > 0
&& image.getHeight() > 0
&& Math.abs(image.getWidth() - image.getHeight()) < 1
) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load addon icons", e);
}
}
if (data.localAddonFile() instanceof ResourcePackFile resourcePackFile) {
Image image = resourcePackFile.loadIcon();
if (image != null
&& !image.isError()
&& image.getWidth() > 0
&& image.getHeight() > 0
&& Math.abs(image.getWidth() - image.getHeight()) < 1
) {
return image;
}
}
return getDefaultIcon();
}
}
private static class AddonUpdateListCell extends ListCell<AddonUpdateObject> {
private final StackPane pane = new StackPane();
private final RipplerContainer ripplerContainer;
private final JFXCheckBox enabledBox = new JFXCheckBox();
private final ImageContainer imageContainer = new ImageContainer(40);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton changelogButton = FXUtils.newToggleButton4(SVG.INFO);
AddonUpdateListCell(ListView<AddonUpdatesPage.AddonUpdateObject> listView) {
HBox hbox = new HBox(16);
HBox.setHgrow(content, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
hbox.getChildren().setAll(enabledBox, imageContainer, content, changelogButton);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
ripplerContainer = new RipplerContainer(hbox);
pane.getChildren().setAll(ripplerContainer);
enabledBox.setMouseTransparent(false);
imageContainer.setMouseTransparent(true);
FXUtils.installFastTooltip(changelogButton, i18n("addon.changelog"));
changelogButton.setOnAction(event -> {
AddonUpdateObject item = getItem();
if (item != null) {
Controllers.dialog(new AddonChangelog(item));
}
});
FXUtils.onClicked(this, () -> {
AddonUpdateObject item = getItem();
if (item != null) {
item.enabled.set(!item.isEnabled());
}
});
FXUtils.limitCellWidth(listView, this);
}
/// Updates addon version information and replaces tags together, reusing their labels.
@Override
public void updateItem(@Nullable AddonUpdateObject item, boolean empty) {
@Nullable AddonUpdateObject oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) {
return;
}
ripplerContainer.resetFeedback();
if (oldItem != null) {
enabledBox.selectedProperty().unbindBidirectional(oldItem.enabledProperty());
}
if (empty || item == null) {
setGraphic(null);
return;
}
enabledBox.selectedProperty().bindBidirectional(item.enabledProperty());
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
LocalAddonFile<?> addonFile = item.getLocalAddonFile();
var tags = new ArrayList<Tag>(3);
if (addonFile instanceof LocalModFile modFile)
tags.add(new Tag(modFile.getId()));
tags.add(new Tag(item.getSource()));
tags.add(new Tag(item.getTargetVersionType()));
content.getTags().setAll(tags);
if (addonFile instanceof LocalModFile modFile) {
content.setTitle(modFile.getName());
} else if (addonFile instanceof ResourcePackFile resourcePackFile) {
content.setTitle(resourcePackFile.getFileName());
}
content.setSubtitle(item.getCurrentVersion() + " → " + item.getTargetVersion());
setGraphic(pane);
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(AddonUpdateObject object) {
RemoteAddon.Version targetVersion = object.data.targetVersion();
this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(object, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(object, versionPageBtn);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(versionPageBtn, closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.targetVersion().source().getRepository();
Task.supplyAsync(() -> {
if (object.changelog != null) return object.changelog;
RemoteAddon.Version version = object.data.targetVersion();
return StringUtils.convertToHtml(
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
"238222".equals(object.data.targetVersion().projectId())
);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() ->
object.data.targetVersion().source().getRepository().getVersionPageUrl(object.data.targetVersion())
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
}).start();
}
}
}
@@ -1,121 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.BooleanProperty;
import javafx.collections.ObservableList;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.DataPackInfoObject> implements WorldManagePage.WorldRefreshable {
private final World world;
private final DataPack dataPack;
final BooleanProperty readOnly;
public DataPackListPage(WorldManagePage worldManagePage) {
world = worldManagePage.getWorld();
dataPack = new DataPack(world.getFile().resolve("datapacks"));
setItems(MappedObservableList.create(dataPack.getPacks(), DataPackListPageSkin.DataPackInfoObject::new));
readOnly = worldManagePage.readOnlyProperty();
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
this::installMultiDataPack, this::refresh);
refresh();
}
private void installMultiDataPack(List<Path> dataPackPath) {
dataPackPath.forEach(this::installSingleDataPack);
if (readOnly.get()) {
Controllers.showToast(i18n("datapack.reload.toast"));
}
}
private void installSingleDataPack(Path dataPack) {
try {
this.dataPack.installPack(dataPack, world.getGameVersion());
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse datapack file " + dataPack, e);
}
}
@Override
protected Skin<?> createDefaultSkin() {
return new DataPackListPageSkin(this);
}
public void refresh() {
setLoading(true);
Task.runAsync(dataPack::loadFromDir)
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
.start();
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("datapack.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.datapack"), "*.zip"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res != null) {
installMultiDataPack(res);
}
dataPack.loadFromDir();
}
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
dataPack.deletePack(pack);
} catch (IOException e) {
failed.add(pack.getPath().toString());
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("datapack.delete.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void setSelectedEnabled(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems, boolean enabled) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
pack.setActive(enabled);
} catch (IOException e) {
failed.add(pack.getPath().toString());
LOG.warning("Failed to enable or disable datapack \"" + pack.getId() + "\"", e);
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("datapack.toggle.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void openDataPackFolder() {
FXUtils.openFolder(dataPack.getPath());
}
}
@@ -1,370 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSwitch;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ListChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Predicate;
import java.util.regex.PatternSyntaxException;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox normalToolbar;
private final HBox selectingToolbar;
private final JFXListView<DataPackInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
DataPackListPageSkin(DataPackListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAllButton = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> selectAllButton.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
normalToolbar = new HBox();
selectingToolbar = new HBox();
normalToolbar.getChildren().addAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openDataPackFolder),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () ->
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () ->
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()), null));
JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true));
JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false));
removeButton.disableProperty().bind(getSkinnable().readOnly);
enableButton.disableProperty().bind(getSkinnable().readOnly);
disableButton.disableProperty().bind(getSkinnable().readOnly);
listView.getSelectionModel().getSelectedItems().addListener(listener);
selectingToolbar.getChildren().addAll(
removeButton,
enableButton,
disableButton,
selectAllButton,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(normalToolbar);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
});
FXUtils.onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().addAll(searchField, closeSearchBar);
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : normalToolbar);
else
changeToolbar(selectingToolbar);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("datapack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("datapack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super DataPackInfoObject>) change -> {
if (isSearching.get()) {
search();
}
});
listView.getItems().addListener(listener);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
/// Filters pack IDs and descriptions using the shared query syntax; invalid regular expressions leave the list empty.
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (PatternSyntaxException e) {
LOG.warning("Illegal regular expression", e);
return;
}
for (DataPackInfoObject item : getSkinnable().getItems()) {
if (predicate.test(item.getTitle()) || predicate.test(item.getSubtitle())) {
listView.getItems().add(item);
}
}
}
}
private void changeToolbar(HBox newToolbar) {
@Nullable Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
// search button click will get focus while searchField request focus, this cause conflict.
// Defer focus request to next pulse avoids this conflict.
Platform.runLater(searchField::requestFocus);
}
}
}
static class DataPackInfoObject {
private final DataPack.Pack packInfo;
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
DataPackInfoObject(DataPack.Pack packInfo) {
this.packInfo = packInfo;
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
String getTitle() {
return packInfo.getId();
}
String getSubtitle() {
return packInfo.getDescription().toString();
}
DataPack.Pack getPackInfo() {
return packInfo;
}
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image image = null;
Path imagePath;
if (this.getPackInfo().isDirectory()) {
imagePath = getPackInfo().getPath().resolve("pack.png");
try {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
}
} else {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) {
imagePath = fs.getPath("/pack.png");
if (Files.exists(imagePath)) {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
}
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
}
}
if (image != null && !image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
} else {
return getDefaultIcon();
}
}
}
private static final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
private final JFXSwitch activeSwitch = new JFXSwitch();
ImageContainer imageContainer = new ImageContainer(32);
final TwoLineListItem content = new TwoLineListItem();
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getPackInfo().isActive());
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
/// Creates a selectable data pack row whose switch follows the read-only state.
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
super(listView);
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
container.setPadding(new Insets(0, 8, 0, 8));
HBox.setHgrow(content, Priority.ALWAYS);
setSelectable();
activeSwitch.disableProperty().bind(isReadOnlyProperty);
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
StackPane.setMargin(container, new Insets(8));
container.getChildren().setAll(imageContainer, content, activeSwitch);
getContainer().getChildren().setAll(container);
}
@Override
protected void updateControl(@Nullable DataPackInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
if (empty || dataItem == null) return;
content.setTitle(dataItem.getTitle());
content.setSubtitle(dataItem.getSubtitle());
DataPack.Pack packInfo = dataItem.getPackInfo();
(observedActiveProperty = packInfo.activeProperty()).addListener(weakActiveListener);
activeSwitch.setDisableAnimation(true);
activeSwitch.setSelected(packInfo.isActive());
activeSwitch.setOnAction(event -> {
try {
packInfo.setActive(activeSwitch.isSelected());
} catch (IOException e) {
LOG.warning("Unable to change state of datapack " + packInfo.getPath(), e);
Controllers.showToast(i18n("datapack.toggle.failed"));
} finally {
activeSwitch.setSelected(packInfo.isActive());
}
});
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
}
}
}
@@ -1,637 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
public class DownloadListPage extends Control implements DecoratorPage {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final boolean instanceSelection;
private final ObjectProperty<HMCLGameInstance.Optional> instanceReference = new SimpleObjectProperty<>();
private final IntegerProperty pageOffset = new SimpleIntegerProperty(0);
private final IntegerProperty pageCount = new SimpleIntegerProperty(-1);
private final ListProperty<RemoteAddon> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
private final ObservableList<GameInstanceID> instances = FXCollections.observableArrayList();
private final ObjectProperty<GameInstanceID> selectedInstance = new SimpleObjectProperty<>();
private final DownloadPage.DownloadCallback callback;
private boolean searchInitialized = false;
protected final BooleanProperty supportChinese = new SimpleBooleanProperty();
private final ObservableList<Node> actions = FXCollections.observableArrayList();
protected final ListProperty<String> downloadSources = new SimpleListProperty<>(this, "downloadSources", FXCollections.observableArrayList());
protected final StringProperty downloadSource = new SimpleStringProperty();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private int searchID = 0;
protected final RemoteAddonRepository repository;
protected final @NotNull RemoteAddon.Type type;
private final DownloadProvider downloadProvider;
private Runnable retrySearch;
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
this(type, repository, null, false);
}
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
this.type = Objects.requireNonNull(type);
this.repository = repository;
this.callback = callback;
this.instanceSelection = instanceSelection;
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
public DownloadProvider getDownloadProvider() {
return downloadProvider;
}
public ObservableList<Node> getActions() {
return actions;
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.instanceReference.set(instance);
setLoading(false);
setFailed(false);
if (!searchInitialized) {
searchInitialized = true;
search("", null, 0, "", RemoteAddonRepository.SortType.RELEVANCY);
}
if (instanceSelection) {
HMCLGameRepository repository = instance.repository();
instances.setAll(repository.getDisplayInstances()
.map(DefaultGameInstance::getId)
.toList());
@Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance();
selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null);
}
}
public boolean isFailed() {
return failed.get();
}
public BooleanProperty failedProperty() {
return failed;
}
public void setFailed(boolean failed) {
this.failed.set(failed);
}
public boolean isLoading() {
return loading.get();
}
public BooleanProperty loadingProperty() {
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
}
public void selectInstance(GameInstanceID instanceId) {
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
public DownloadPage.DownloadCallback getCallback() {
return callback;
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
retrySearch = null;
setLoading(true);
setFailed(false);
int currentSearchID = searchID = searchID + 1;
Task.supplyAsync(() -> {
HMCLGameInstance.Optional instanceReference = this.instanceReference.get();
@Nullable HMCLGameInstance instance = instanceReference.instance();
if (instance == null) {
return userGameVersion;
} else {
GameVersionNumber version = instance.getVersion();
return version != GameVersionNumber.unknown() ? version.toString() : "";
}
}).thenApplyAsync(
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (searchID != currentSearchID) {
return;
}
setLoading(false);
if (exception == null) {
items.setAll(result.results().collect(Collectors.toList()));
pageCount.set(result.totalPages());
failed.set(false);
} else {
failed.set(true);
pageCount.set(-1);
retrySearch = () -> search(userGameVersion, category, pageOffset, searchFilter, sort);
}
}).executor(true);
}
protected String getLocalizedCategory(String category, Object self) {
return repository instanceof ModrinthRemoteAddonRepository
? i18n("modrinth.category." + category)
: i18n("curse.category." + category);
}
protected boolean shouldDisplayCategory(String category) {
return !"minecraft".equals(category);
}
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
return StringUtils.repeats(' ', category.indent * 4) +
(category.category() == null
? i18n("curse.category.0")
: getLocalizedCategory(category.category().id(), category.category().self()));
}
protected String getLocalizedOfficialPage() {
if (repository instanceof ModrinthRemoteAddonRepository) {
return i18n("addon.modrinth");
} else {
return i18n("addon.curseforge");
}
}
protected HMCLGameInstance.Optional getInstanceOptional() {
if (instanceSelection) {
@Nullable GameInstanceID instanceId = selectedInstance.get();
return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId);
} else {
return instanceReference.get();
}
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModDownloadListPageSkin(this);
}
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteAddon> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader;
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
listView.getStyleClass().add("no-horizontal-scrollbar");
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
}
};
BorderPane pane = new BorderPane();
GridPane searchPane = new GridPane();
pane.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setHgrow(Priority.ALWAYS);
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
if (control.instanceSelection || !control.downloadSources.isEmpty()) {
searchPane.addRow(rowIndex);
int columns = 0;
Node lastNode = null;
if (control.instanceSelection) {
JFXComboBox<GameInstanceID> instancesComboBox = new JFXComboBox<>();
instancesComboBox.setMaxWidth(Double.MAX_VALUE);
Bindings.bindContent(instancesComboBox.getItems(), control.instances);
selectedItemPropertyFor(instancesComboBox).bindBidirectional(control.selectedInstance);
searchPane.add(new Label(i18n("instance")), columns++, rowIndex);
searchPane.add(lastNode = instancesComboBox, columns++, rowIndex);
}
if (control.downloadSources.getSize() > 1) {
JFXComboBox<String> downloadSourceComboBox = new JFXComboBox<>();
downloadSourceComboBox.setMaxWidth(Double.MAX_VALUE);
downloadSourceComboBox.getItems().setAll(control.downloadSources.get());
downloadSourceComboBox.setConverter(stringConverter(I18n::i18n));
selectedItemPropertyFor(downloadSourceComboBox).bindBidirectional(control.downloadSource);
searchPane.add(new Label(i18n("settings.launcher.download_source")), columns++, rowIndex);
searchPane.add(lastNode = downloadSourceComboBox, columns++, rowIndex);
}
if (columns == 2) {
GridPane.setColumnSpan(lastNode, 3);
}
rowIndex++;
}
JFXTextField nameField = new JFXTextField();
nameField.setPromptText(getSkinnable().supportChinese.get() ? i18n("search.hint.chinese") : i18n("search.hint.english"));
if (getSkinnable().supportChinese.get()) {
FXUtils.installFastTooltip(nameField, i18n("search.hint.chinese"));
} else {
FXUtils.installFastTooltip(nameField, i18n("search.hint.english"));
}
JFXComboBox<String> gameVersionField = new JFXComboBox<>();
gameVersionField.setMaxWidth(Double.MAX_VALUE);
gameVersionField.setEditable(true);
gameVersionField.getItems().setAll(GameVersionNumber.getDefaultGameVersions());
Label lblGameVersion = new Label(i18n("world.game_version"));
searchPane.addRow(rowIndex++, new Label(i18n("mods.name")), nameField, lblGameVersion, gameVersionField);
ObjectBinding<Boolean> hasVersion = BindingMapping.of(getSkinnable().instanceReference)
.map(instanceReference -> instanceReference.instanceId() == null);
lblGameVersion.managedProperty().bind(hasVersion);
lblGameVersion.visibleProperty().bind(hasVersion);
gameVersionField.managedProperty().bind(hasVersion);
gameVersionField.visibleProperty().bind(hasVersion);
FXUtils.installFastTooltip(gameVersionField, i18n("search.enter"));
FXUtils.onChangeAndOperate(getSkinnable().instanceReference, instanceReference -> {
if (instanceReference.instanceId() != null) {
GridPane.setColumnSpan(nameField, 3);
} else {
GridPane.setColumnSpan(nameField, 1);
}
});
StackPane categoryStackPane = new StackPane();
JFXComboBox<CategoryIndented> categoryComboBox = new JFXComboBox<>();
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryStackPane.getChildren().setAll(categoryComboBox);
categoryComboBox.prefWidthProperty().bind(categoryStackPane.widthProperty());
categoryComboBox.getStyleClass().add("fit-width");
categoryComboBox.setPromptText(i18n("addon.category"));
categoryComboBox.getSelectionModel().select(0);
categoryComboBox.setConverter(stringConverter(getSkinnable()::getLocalizedCategoryIndent));
FXUtils.onChangeAndOperate(getSkinnable().downloadSource, downloadSource -> {
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryComboBox.getSelectionModel().select(0);
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
.thenAcceptAsync(Schedulers.javafx(), categories -> {
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
return;
}
List<CategoryIndented> result = new ArrayList<>();
result.add(CategoryIndented.ALL);
for (RemoteAddonRepository.Category category : Lang.toIterable(categories)) {
resolveCategory(category, 0, result);
}
categoryComboBox.getItems().setAll(result);
categoryComboBox.getSelectionModel().select(0);
}).start();
});
StackPane sortStackPane = new StackPane();
JFXComboBox<RemoteAddonRepository.SortType> sortComboBox = new JFXComboBox<>();
sortStackPane.getChildren().setAll(sortComboBox);
sortComboBox.prefWidthProperty().bind(sortStackPane.widthProperty());
sortComboBox.getStyleClass().add("fit-width");
sortComboBox.setConverter(stringConverter(sortType -> i18n("addon.sort." + sortType.name().toLowerCase(Locale.ROOT))));
sortComboBox.getItems().setAll(RemoteAddonRepository.SortType.values());
sortComboBox.getSelectionModel().select(0);
searchPane.addRow(rowIndex++, new Label(i18n("addon.category")), categoryStackPane, new Label(i18n("search.sort")), sortStackPane);
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
EventHandler<ActionEvent> searchAction = e -> {
iconLoader.clearInvalidCache();
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
control.pageOffset.set(0);
}
currentFilterID.set(filterID.get());
int pageOffset = control.pageOffset.get();
getSkinnable().search(gameVersionField.getSelectionModel().getSelectedItem(),
Optional.ofNullable(categoryComboBox.getSelectionModel().getSelectedItem())
.map(CategoryIndented::category)
.orElse(null),
pageOffset == -1 ? 0 : pageOffset,
nameField.getText(),
sortComboBox.getSelectionModel().getSelectedItem());
};
control.listenerHolder.add(FXUtils.observeWeak(
() -> filterID.set(filterID.get() + 1),
control.downloadSource,
gameVersionField.getSelectionModel().selectedItemProperty(),
categoryComboBox.getSelectionModel().selectedItemProperty(),
nameField.textProperty(),
sortComboBox.getSelectionModel().selectedItemProperty()
));
HBox actionsBox = new HBox(8);
GridPane.setColumnSpan(actionsBox, 4);
actionsBox.setAlignment(Pos.CENTER);
{
AggregatedObservableList<Node> actions = new AggregatedObservableList<>();
Holder<Runnable> changeButton = new Holder<>();
JFXButton firstPageButton = FXUtils.newBorderButton(i18n("search.first_page"));
firstPageButton.setOnAction(event -> {
control.pageOffset.set(0);
searchAction.handle(event);
changeButton.value.run();
});
JFXButton previousPageButton = FXUtils.newBorderButton(i18n("search.previous_page"));
previousPageButton.setOnAction(event -> {
int pageOffset = control.pageOffset.get();
if (pageOffset > 0) {
control.pageOffset.set(pageOffset - 1);
searchAction.handle(event);
changeButton.value.run();
}
});
Label pageDescription = new Label();
pageDescription.textProperty().bind(Bindings.createStringBinding(() -> {
int pageCount = control.pageCount.get();
return i18n("search.page_n", control.pageOffset.get() + 1, pageCount == -1 ? "-" : String.valueOf(pageCount));
}, control.pageOffset, control.pageCount));
JFXButton nextPageButton = FXUtils.newBorderButton(i18n("search.next_page"));
nextPageButton.setOnAction(event -> {
int nv = control.pageOffset.get() + 1;
if (nv < control.pageCount.get()) {
control.pageOffset.set(nv);
searchAction.handle(event);
changeButton.value.run();
}
});
JFXButton lastPageButton = FXUtils.newBorderButton(i18n("search.last_page"));
lastPageButton.setOnAction(event -> {
control.pageOffset.set(control.pageCount.get() - 1);
searchAction.handle(event);
changeButton.value.run();
});
firstPageButton.setDisable(true);
previousPageButton.setDisable(true);
lastPageButton.setDisable(true);
nextPageButton.setDisable(true);
changeButton.value = () -> {
int pageOffset = control.pageOffset.get();
int pageCount = control.pageCount.get();
boolean disableAll = pageCount >= -1 && pageCount <= 1;
boolean disablePrevious = disableAll || pageOffset == 0;
firstPageButton.setDisable(disablePrevious);
previousPageButton.setDisable(disablePrevious);
boolean disableNext = disableAll || pageOffset == pageCount - 1;
nextPageButton.setDisable(disableNext);
lastPageButton.setDisable(disableNext);
listView.scrollTo(0);
};
FXUtils.onChange(control.pageCount, pageCountN -> {
int pageCount = pageCountN.intValue();
if (pageCount != -1) {
if (control.pageOffset.get() + 1 >= pageCount) {
control.pageOffset.set(pageCount - 1);
}
}
changeButton.value.run();
});
FXUtils.onChange(control.pageOffset, pageOffsetN -> {
changeButton.value.run();
});
Pane placeholder = new Pane();
HBox.setHgrow(placeholder, Priority.SOMETIMES);
JFXButton searchButton = FXUtils.newRaisedButton(i18n("search"));
searchButton.setOnAction(searchAction);
actions.appendList(FXCollections.observableArrayList(firstPageButton, previousPageButton, pageDescription, nextPageButton, lastPageButton, placeholder, searchButton));
actions.appendList(control.actions);
Bindings.bindContent(actionsBox.getChildren(), actions.getAggregatedList());
}
searchPane.addRow(rowIndex++, actionsBox);
FXUtils.onChange(control.downloadSource, v -> searchAction.handle(null));
nameField.setOnAction(searchAction);
gameVersionField.setOnAction(searchAction);
categoryComboBox.setOnAction(searchAction);
sortComboBox.setOnAction(searchAction);
}
SpinnerPane spinnerPane = new SpinnerPane();
pane.setCenter(spinnerPane);
{
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
spinnerPane.failedReasonProperty().bind(
Bindings.createStringBinding(() -> {
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else if (!getSkinnable().isLoading() && getSkinnable().pageCount.get() >= 0 && getSkinnable().items.isEmpty()) {
return i18n("search.no_results_found");
} else {
return null;
}
},
getSkinnable().failedProperty(),
getSkinnable().loadingProperty(),
getSkinnable().pageCount,
getSkinnable().items)
);
spinnerPane.setOnFailedAction(e -> {
if (getSkinnable().isFailed() && getSkinnable().retrySearch != null) {
getSkinnable().retrySearch.run();
}
});
spinnerPane.setContent(listView);
Bindings.bindContent(listView.getItems(), getSkinnable().items);
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.setCellFactory(x -> new DownloadListCell(getSkinnable(), listView, iconLoader));
}
this.getChildren().setAll(pane);
}
private record CategoryIndented(int indent, RemoteAddonRepository.Category category) {
private static final CategoryIndented ALL = new CategoryIndented(0, null);
}
private static void resolveCategory(RemoteAddonRepository.Category category, int indent, List<CategoryIndented> result) {
result.add(new CategoryIndented(indent, category));
for (RemoteAddonRepository.Category subcategory : category.subcategories()) {
resolveCategory(subcategory, indent + 1, result);
}
}
}
private static final class DownloadListCell extends ListCell<RemoteAddon> {
private final DownloadListPage page;
private final JFXListView<RemoteAddon> listView;
private final RemoteImageLoader iconLoader;
private static final Insets PADDING = new Insets(9, 9, 0, 9);
private static final Insets LAST_PADDING = new Insets(9, 9, 9, 9);
private final RipplerContainer graphic;
private final StackPane wrapper = new StackPane();
private final TwoLineListItem content = new TwoLineListItem();
private final ImageContainer imageContainer = new ImageContainer(40);
/// Creates a download card with its icon and text laid out by the same row.
private DownloadListCell(DownloadListPage page, JFXListView<RemoteAddon> listView, RemoteImageLoader iconLoader) {
this.page = page;
this.listView = listView;
this.iconLoader = iconLoader;
setPadding(PADDING);
content.setPadding(new Insets(8));
content.setCursor(Cursor.HAND);
TwoLineListItem.setMargin(imageContainer, new Insets(0, 8, 0, 0));
imageContainer.setMouseTransparent(true);
content.setLeading(imageContainer);
this.graphic = new RipplerContainer(content);
wrapper.getChildren().setAll(this.graphic);
wrapper.getStyleClass().add("card-no-padding");
FXUtils.onClicked(wrapper, () -> {
RemoteAddon item = getItem();
if (item != null) {
Controllers.navigate(new DownloadPage(page, item, page.getInstanceOptional(), page.callback));
}
});
setPrefWidth(0);
FXUtils.limitCellWidth(listView, this);
}
/// Updates addon text and replaces category tags together so their labels can be reused.
@Override
protected void updateItem(@Nullable RemoteAddon item, boolean empty) {
@Nullable RemoteAddon oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.resetFeedback();
if (empty || item == null) {
setGraphic(null);
} else {
setPadding(
getIndex() == listView.getItems().size() - 1
? LAST_PADDING
: PADDING
);
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(page.type);
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(item.slug()) : null;
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : item.title());
@Nullable String description = item.description();
if (description != null) {
description = description.replaceAll("\\R", " ");
}
content.setSubtitle(description);
content.getTags().setAll(item.categories().stream()
.filter(page::shouldDisplayCategory)
.map(category -> new Tag(page.getLocalizedCategory(category, null)))
.toList());
iconLoader.load(imageContainer.imageProperty(), item.iconUrl());
setGraphic(wrapper);
}
}
}
}
@@ -1,722 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends Control implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final RemoteAddonRepository repository;
private final RemoteAddon addon;
/// Localized project metadata, or null when no registry or matching project is available.
private final @Nullable AddonProjectInfo mod;
private final HMCLGameInstance.Optional instanceReference;
private final DownloadCallback callback;
private final DownloadListPage page;
private final RemoteAddon.Type type;
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.type = Objects.requireNonNullElse(addon.type(), page.type);
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(this.type);
this.mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
this.instanceReference = instanceReference;
this.callback = callback;
loadAddonVersions();
this.state.set(State.fromTitle(addon.title()));
}
private void loadAddonVersions() {
setLoading(true);
setFailed(false);
Task.supplyAsync(() -> {
Stream<RemoteAddon.Version> versions = addon.data().loadVersions(repository, page.getDownloadProvider());
return sortVersions(versions);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
this.versions = result;
loaded.set(true);
setFailed(false);
} else {
setFailed(true);
}
setLoading(false);
}).start();
}
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> sortVersions(Stream<RemoteAddon.Version> versions) {
SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> classifiedVersions
= new SimpleMultimap<>(HashMap::new, ArrayList::new);
versions.forEach(version -> {
for (String gameVersion : version.gameVersions()) {
classifiedVersions.put(gameVersion, version);
}
});
for (String gameVersion : classifiedVersions.keys()) {
List<RemoteAddon.Version> versionList = classifiedVersions.get(gameVersion);
versionList.sort(Comparator.comparing(RemoteAddon.Version::datePublished).reversed());
}
return classifiedVersions;
}
public RemoteAddon getAddon() {
return addon;
}
public HMCLGameInstance.Optional getInstanceOptional() {
return instanceReference;
}
public boolean isLoading() {
return loading.get();
}
public BooleanProperty loadingProperty() {
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
}
public boolean isFailed() {
return failed.get();
}
public BooleanProperty failedProperty() {
return failed;
}
public void setFailed(boolean failed) {
this.failed.set(failed);
}
public void download(RemoteAddon addon, RemoteAddon.Version file) {
if (this.callback == null) {
saveAs(file);
} else {
this.callback.download(page.getDownloadProvider(), instanceReference.repository(), instanceReference.instanceId(), addon, file);
}
}
public void saveAs(RemoteAddon.Version file) {
String extension = StringUtils.substringAfterLast(file.file().filename(), '.');
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("button.save_as"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*." + extension));
fileChooser.setInitialFileName(file.file().filename());
Path dest = Controllers.showSaveDialog(fileChooser);
if (dest == null) {
return;
}
Controllers.taskDialog(
Task.composeAsync(() -> {
var task = new FileDownloadTask(file.file().url(), dest, file.file().getIntegrityCheck());
task.setName(file.name());
return task;
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected Skin<?> createDefaultSkin() {
return new DownloadPageSkin(this);
}
private static class DownloadPageSkin extends SkinBase<DownloadPage> {
protected DownloadPageSkin(DownloadPage control) {
super(control);
VBox pane = new VBox(8);
pane.getStyleClass().add("gray-background");
pane.setPadding(new Insets(10));
HBox descriptionPane = new HBox(8);
descriptionPane.setMinHeight(Region.USE_PREF_SIZE);
descriptionPane.setAlignment(Pos.CENTER);
pane.getChildren().add(descriptionPane);
descriptionPane.getStyleClass().add("card-non-transparent");
{
var imageContainer = new ImageContainer(40);
if (StringUtils.isNotBlank(getSkinnable().addon.iconUrl())) {
imageContainer.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.iconUrl(), 80, 80, true, true));
}
descriptionPane.getChildren().add(imageContainer);
TwoLineListItem content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
@Nullable AddonProjectInfo mod = getSkinnable().mod;
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : getSkinnable().addon.title());
content.setSubtitle(getSkinnable().addon.description());
content.getSubtitleLabel().setWrapText(true);
getSkinnable().addon.categories().stream()
.filter(category -> getSkinnable().page.shouldDisplayCategory(category))
.map(category -> getSkinnable().page.getLocalizedCategory(category, null))
.forEach(content::addTag);
descriptionPane.getChildren().add(content);
@Nullable String mcmodUrl = mod != null ? mod.mcmodUrl() : null;
if (mcmodUrl != null) {
JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod"));
openMcmodButton.setExternalLink(mcmodUrl);
descriptionPane.getChildren().add(openMcmodButton);
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
}
JFXHyperlink openUrlButton = new JFXHyperlink(control.page.getLocalizedOfficialPage());
openUrlButton.setExternalLink(getSkinnable().addon.pageUrl());
descriptionPane.getChildren().add(openUrlButton);
openUrlButton.setMinWidth(Region.USE_PREF_SIZE);
}
SpinnerPane spinnerPane = new SpinnerPane();
VBox.setVgrow(spinnerPane, Priority.ALWAYS);
pane.getChildren().add(spinnerPane);
{
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
spinnerPane.failedReasonProperty().bind(Bindings.createStringBinding(() -> {
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else {
return null;
}
}, getSkinnable().failedProperty()));
spinnerPane.setOnFailedAction(e -> getSkinnable().loadAddonVersions());
ComponentList list = new ComponentList();
ScrollPane scrollPane = new ScrollPane(list);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.setOverflowHidden(scrollPane, 8);
StackPane.setAlignment(scrollPane, Pos.TOP_CENTER);
spinnerPane.setContent(scrollPane);
FXUtils.onChangeAndOperate(control.loaded, loaded -> {
if (control.versions == null) return;
@Nullable HMCLGameInstance instance = control.instanceReference.instance();
if (instance != null) {
String gameVersion = instance.getVersion().toString();
if (!GameVersionNumber.unknown().equals(instance.getVersion())
&& control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
if (addonVersions != null && !addonVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = instance.getModLoaders();
resolve:
for (RemoteAddon.Version addonVersion : addonVersions) {
if (getSkinnable().type == RemoteAddon.Type.MOD) {
for (AddonLoader loader : addonVersion.loaders()) {
if (loader.type() instanceof ModLoaderType modLoaderType && targetLoaders.contains(modLoaderType)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break resolve;
}
}
} else {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break;
}
}
}
}
} else if (getSkinnable().type == RemoteAddon.Type.MODPACK) {
control.versions
.keys()
.stream()
.max(Comparator.comparing(GameVersionNumber::asGameVersion))
.flatMap(gameVersion ->
control.versions.get(gameVersion)
.stream()
.max(Comparator.comparing(RemoteAddon.Version::datePublished))
.map(v -> Pair.pair(gameVersion, v))
).ifPresent(pair ->
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", pair.key())),
new AddonItem(control.addon, pair.value(), control)
)
);
}
final class Versions {
static ComponentSublist createSublist(DownloadPage control, String title, List<String> target) {
var sublist = new ComponentSublist(() -> {
ArrayList<AddonItem> items = new ArrayList<>();
for (String gv : target) {
List<RemoteAddon.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteAddon.Version v : versions) {
items.add(new AddonItem(control.addon, v, control));
}
}
}
return items;
});
sublist.setTitle(title);
sublist.getStyleClass().add("no-padding");
return sublist;
}
final List<String> releases = new ArrayList<>();
final List<String> snapshots = new ArrayList<>();
}
TreeMap<GameVersionNumber, Versions> map = new TreeMap<>(Collections.reverseOrder());
for (String version : control.versions.keys()) {
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(version);
GameVersionNumber releaseOfSnapshot = GameVersionNumber.getReleaseOfSnapshot(gameVersion);
GameVersionNumber releaseVersion = Objects.requireNonNullElse(releaseOfSnapshot, gameVersion);
Versions lists = map.computeIfAbsent(releaseVersion, v -> new Versions());
if (releaseOfSnapshot == null) {
lists.releases.add(version);
} else {
lists.snapshots.add(version);
}
}
for (Map.Entry<GameVersionNumber, Versions> entry : map.entrySet()) {
GameVersionNumber gameVersion = entry.getKey();
Versions versions = entry.getValue();
if (!versions.releases.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.release", gameVersion), versions.releases));
if (!versions.snapshots.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.snapshot", gameVersion), versions.snapshots));
}
});
}
getChildren().setAll(pane);
}
}
/// Displays an addon dependency and opens its download page when activated.
private static final class DependencyAddonItem extends LineButton {
/// Returns the localized dependency type, or its lowercase enum name for other types.
public static String translateDependencyType(RemoteAddon.DependencyType dependencyType) {
return switch (dependencyType) {
case EMBEDDED -> i18n("addon.dependency.embedded");
case OPTIONAL -> i18n("addon.dependency.optional");
case REQUIRED -> i18n("addon.dependency.required");
default -> dependencyType.name().toLowerCase(Locale.ROOT);
};
}
/// The dependency represented by this row.
public final RemoteAddon addon;
/// Creates a dependency row that opens the addon's download page.
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) {
this.addon = addon;
container.setPadding(new Insets(10, 24, 10, 24));
container.getTitleLabel().setMinWidth(0);
setLargeTitle(true);
var imageView = new ImageContainer(40);
imageView.setMouseTransparent(true);
setMargin(imageView, new Insets(0, 8, 0, 0));
setLeading(imageView);
FXUtils.setLimitHeight(this, 60);
RemoteAddon.@Nullable Type type = addon.type();
@Nullable DownloadCallback callback = type == null ? null : switch (type) {
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
default -> null; // Mods, modpacks, worlds, and custom types are not downloadable from this UI
};
setOnAction((e) -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(page, addon, instanceReference, callback));
});
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : addon.title());
setSubtitle(addon.description());
container.getSubtitleLabel().setWrapText(false);
for (String category : addon.categories()) {
if (page.shouldDisplayCategory(category))
getTags().add(new Tag(page.getLocalizedCategory(category, null), Tag.Type.NORMAL));
}
if (StringUtils.isNotBlank(addon.iconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
}
}
}
private static final class AddonItem extends StackPane {
/// Creates a version summary that opens its details when clicked.
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage) {
this(mod, dataItem, selfPage, true);
}
/// Creates a version summary, optionally enabling navigation to its details.
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage, boolean interactive) {
VBox pane = new VBox(8);
pane.setPadding(new Insets(8, 0, 8, 0));
{
HBox descPane = new HBox(8);
descPane.setPadding(new Insets(0, 8, 0, 8));
descPane.setAlignment(Pos.CENTER_LEFT);
descPane.setPickOnBounds(false);
{
StackPane graphicPane = new StackPane();
graphicPane.setMouseTransparent(true);
TwoLineListItem content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
content.setTitle(dataItem.name());
content.setSubtitle(I18n.formatDateTime(dataItem.datePublished()));
@Nullable SVG svg = null;
@Nullable String styleClass = null;
if (dataItem.versionType() != null) {
switch (dataItem.versionType()) {
case Alpha:
content.addTag(i18n("addon.channel.alpha"));
svg = SVG.ALPHA_CIRCLE;
styleClass = "alpha";
break;
case Beta:
content.addTag(i18n("addon.channel.beta"));
svg = SVG.BETA_CIRCLE;
styleClass = "beta";
break;
case Release:
content.addTag(i18n("addon.channel.release"));
svg = SVG.RELEASE_CIRCLE;
styleClass = "release";
break;
default:
styleClass = "";
break;
}
}
if (svg != null) {
SVGContainer icon = svg.createIcon(24);
icon.getStyleClass().addAll("addon-channel-icon", styleClass);
graphicPane.getChildren().setAll(icon);
}
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : dataItem.loaders()) {
tags.add(I18n.translateLoaderName(loader));
}
content.addTags(tags);
descPane.getChildren().setAll(graphicPane, content);
}
pane.getChildren().add(descPane);
}
RipplerContainer container = new RipplerContainer(pane);
container.setPressFeedbackEnabled(interactive);
if (interactive)
FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage)));
getChildren().setAll(container);
// Workaround for https://github.com/HMCL-dev/HMCL/issues/2129
this.setMinHeight(50);
}
}
private static final class AddonVersion extends JFXDialogLayout {
/// Creates version details with dependency downloads and a non-navigating summary.
public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) {
RemoteAddon.Type type = selfPage.type;
String title = switch (type) {
case RESOURCE_PACK -> "resourcepack.download.title";
case SHADER_PACK -> "shaderpack.download.title";
default -> "mods.download.title";
};
this.setHeading(new HBox(new Label(i18n(title, version.name()))));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
var addonItem = new AddonItem(mod, version, selfPage, false);
box.getChildren().setAll(addonItem);
JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog"));
changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider())));
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(version, selfPage.repository, versionPageBtn);
HBox additionalBox = new HBox(changelogButton, versionPageBtn);
box.getChildren().add(additionalBox);
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
ComponentList dependenciesList = new ComponentList();
loadDependencies(version, selfPage, spinnerPane, dependenciesList);
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setContent(dependenciesList);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.onChangeAndOperate(scrollPane.widthProperty(), d -> FXUtils.setLimitWidth(dependenciesList, d.doubleValue()));
FXUtils.smoothScrolling(scrollPane);
FXUtils.setOverflowHidden(scrollPane, 8);
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton downloadButton = null;
if (selfPage.callback != null) {
downloadButton = new JFXButton(i18n("mods.install"));
downloadButton.getStyleClass().add("dialog-accept");
downloadButton.setOnAction(e -> {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.download(mod, version);
});
}
JFXButton saveAsButton = new JFXButton(i18n("mods.save_as"));
saveAsButton.getStyleClass().add("dialog-accept");
saveAsButton.setOnAction(e -> {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.saveAs(version);
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
if (downloadButton == null) {
this.setActions(saveAsButton, cancelButton);
} else {
this.setActions(downloadButton, saveAsButton, cancelButton);
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, cancelButton::fire);
}
private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) {
spinnerPane.setLoading(true);
Task.composeAsync(() -> {
// TODO: Massive tasks may cause OOM.
EnumMap<RemoteAddon.DependencyType, Pair<Label, List<DependencyAddonItem>>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class);
AtomicBoolean hasBroken = new AtomicBoolean(false);
List<Task<?>> queue = new ArrayList<>(version.dependencies().size());
for (RemoteAddon.Dependency dependency : version.dependencies()) {
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE) continue;
if (dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
hasBroken.set(true);
continue;
}
if (!dependencies.containsKey(dependency.getType())) {
Label title = new Label(DependencyAddonItem.translateDependencyType(dependency.getType()));
title.setPadding(new Insets(0, 8, 0, 8));
List<DependencyAddonItem> list = new ArrayList<>();
dependencies.put(dependency.getType(), Pair.pair(title, list));
}
queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider()))
.setSignificance(Task.TaskSignificance.MINOR)
.thenAcceptAsync(Schedulers.javafx(), dep -> {
if (dep == RemoteAddon.BROKEN) {
hasBroken.set(true);
return;
}
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference);
var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue()));
dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener);
dependencies.get(dependency.getType()).value().add(dependencyAddonItem);
})
.setSignificance(Task.TaskSignificance.MINOR));
}
return Task.allOf(queue).thenSupplyAsync(() -> {
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
if (types.value().isEmpty()) return Stream.empty();
return Stream.concat(
Stream.of(types.key()),
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
});
if (!hasBroken.get()) {
return dependenciesStream;
} else {
Label warning = new Label(i18n("addon.dependencies.has_broken"));
warning.setWrapText(true);
warning.setPadding(new Insets(0, 8, 0, 8));
return Stream.concat(
Stream.of(warning),
dependenciesStream
);
}
});
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
dependenciesList.getContent().setAll(result.toList());
spinnerPane.setFailedReason(null);
} else {
dependenciesList.getContent().setAll();
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository repo, JFXHyperlink button) {
Task.supplyAsync(() -> repo.getVersionPageUrl(version))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
})
.start();
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) {
setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(version, repo, provider, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
Task.supplyAsync(() ->
StringUtils.convertToHtml(
repo.getAddonChangelog(provider, version.projectId(), version.versionId()),
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 FUCK YOU JEI, FUCK YOU CURSEFORGE
)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
}
@FunctionalInterface
public interface DownloadCallback {
void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon addon, RemoteAddon.Version file);
}
}
@@ -41,14 +41,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private final TabController tabController;
/// The settings tab bound to the current game instance.
private final TabItem<GameSettingsPage<GameSettings.Instance>> gameSettingsTab;
/// The component management tab bound to the current game instance.
private final TabItem<InstallerListPage> installerListTab;
/// The installed mods tab bound to the current game instance.
private final TabItem<ModListPage> modListTab;
/// The saved worlds tab bound to the current game instance.
private final TabItem<WorldListPage> worldListTab;
/// The installed resource packs tab bound to the current game instance.
private final TabItem<ResourcePackListPage> resourcePackTab;
/// The screenshots tab bound to the current game instance.
private final TabItem<ScreenshotsPage> screenshotsTab;
private final TransitionPane transitionPane = new TransitionPane();
@@ -79,18 +71,10 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
public GameInstancePage() {
// Child tabs subscribe to instanceProperty() themselves and reload on change.
this.gameSettingsTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance));
this.installerListTab = new TabItem<>(() -> new InstallerListPage(instance));
this.modListTab = new TabItem<>(() -> new ModListPage(instance));
this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance));
this.worldListTab = new TabItem<>(() -> new WorldListPage(instance));
this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance));
tabController = new TabController(transitionPane,
gameSettingsTab,
installerListTab,
modListTab,
resourcePackTab,
worldListTab,
screenshotsTab);
tabController.selectTab(gameSettingsTab);
@@ -118,11 +102,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}));
getSidebar()
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL)
.addNavigationDrawerTab(tabController, installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL)
.addNavigationDrawerTab(tabController, modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE)
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC);
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL);
PopupMenu browseList = new PopupMenu();
JFXPopup browsePopup = new JFXPopup(browseList);
@@ -1,132 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameItem {
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
protected final HMCLGameInstance gameInstance;
private boolean initialized = false;
private StringProperty title;
private StringProperty tag;
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(HMCLGameInstance gameInstance) {
this.gameInstance = gameInstance;
}
public GameDirectory getGameDirectory() {
return gameInstance.getRepository().getGameDirectory();
}
public HMCLGameRepository getRepository() {
return gameInstance.getRepository();
}
public GameInstanceID getInstanceId() {
return gameInstance.getId();
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
public String getId() {
return gameInstance.getId().toString();
}
private void init() {
if (initialized)
return;
initialized = true;
title = new SimpleStringProperty();
tag = new SimpleStringProperty();
subtitle = new SimpleStringProperty();
image = new SimpleObjectProperty<>();
record Result(@Nullable String gameVersion, @Nullable String tag) {
}
CompletableFuture.supplyAsync(() -> {
// Version detection performs blocking JAR reads and may hash the entire file.
GameVersionNumber version = gameInstance.getVersion();
@Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString();
@Nullable String modPackVersion = null;
try {
@Nullable ModpackConfiguration<?> config = gameInstance.readModpackConfiguration();
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + getId(), e);
}
return new Result(gameVersion, modPackVersion);
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
if (exception == null) {
if (result.tag != null) {
tag.set(result.tag);
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
GameComponentAnalyzer analyzer = gameInstance.getAnalyzer();
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) continue;
if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) {
libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId()));
if (mark.version() != null)
libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), ""));
}
}
subtitle.set(libraries.toString());
} else {
LOG.warning("Failed to read version info from " + getId(), exception);
}
}, Schedulers.javafx());
title.set(getId());
image.set(gameInstance.getIconImage());
}
public ReadOnlyStringProperty titleProperty() {
init();
return title;
}
public ReadOnlyStringProperty tagProperty() {
init();
return tag;
}
public ReadOnlyStringProperty subtitleProperty() {
init();
return subtitle;
}
public ReadOnlyObjectProperty<Image> imageProperty() {
init();
return image;
}
}
@@ -1,191 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class GameListCell extends ListCell<GameListItem> {
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final TwoLineListItem content;
private final JFXRadioButton chkSelected;
private final JFXButton btnUpgrade;
/// Opens the settings for this cell's game instance.
private final JFXButton btnEditInstance;
private final JFXButton btnManage;
private final HBox right;
private final StringProperty tag = new SimpleStringProperty();
/// Creates a game instance cell constrained to the supplied list's viewport width.
public GameListCell(ListView<GameListItem> listView) {
FXUtils.limitCellWidth(listView, this);
TwoLineListItem root = this.content = new TwoLineListItem();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8, 8, 8, 0));
RipplerContainer container = new RipplerContainer(root);
this.graphic = container;
{
this.chkSelected = new JFXRadioButton() {
/// Selects this cell's game instance when the radio button is enabled and unselected.
@Override
public void fire() {
if (!isDisable() && !isSelected()) {
fireEvent(new ActionEvent());
@Nullable GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getGameInstance());
}
}
}
};
}
{
this.imageView = new ImageContainer(32);
imageView.setMouseTransparent(true);
HBox leading = new HBox(8, chkSelected, imageView);
leading.setAlignment(Pos.CENTER_LEFT);
leading.setPickOnBounds(false);
TwoLineListItem.setMargin(leading, new Insets(0, 8, 0, 0));
content.setLeading(leading);
FXUtils.onChangeAndOperate(tag, tag -> {
if (StringUtils.isNotBlank(tag))
content.getTags().setAll(new Tag(tag));
else
content.getTags().clear();
});
}
{
this.right = new HBox();
content.setTrailing(right);
right.setAlignment(Pos.CENTER_RIGHT);
right.setPickOnBounds(false);
this.btnUpgrade = FXUtils.newToggleButton4(SVG.UPDATE);
btnUpgrade.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item != null)
item.update();
});
FXUtils.installFastTooltip(btnUpgrade, i18n("instance.update"));
right.getChildren().add(btnUpgrade);
this.btnEditInstance = FXUtils.newToggleButton4(SVG.SETTINGS);
btnEditInstance.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item != null)
item.modifyGameSettings();
});
FXUtils.installFastTooltip(btnEditInstance, i18n("instance.manage.manage"));
right.getChildren().add(btnEditInstance);
this.btnManage = FXUtils.newToggleButton4(SVG.MORE_VERT);
btnManage.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item == null)
return;
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
@Nullable GameListItem item = getItem();
if (item == null)
return;
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
item.getRepository().setSelectedInstance(item.getGameInstance());
}
} else if (e.getButton() == MouseButton.SECONDARY) {
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
@Override
public void updateItem(GameListItem item, boolean empty) {
GameListItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.resetFeedback();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
this.right.getChildren().clear();
this.chkSelected.selectedProperty().unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.chkSelected.selectedProperty().bind(item.selectedProperty());
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
if (item.canUpdate())
this.right.getChildren().add(btnUpgrade);
this.right.getChildren().addAll(btnEditInstance, btnManage);
}
}
private static JFXPopup getPopup(GameListItem item) {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
}
}
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jetbrains.annotations.Nullable;
public class GameListItem extends GameItem {
private final boolean isModpack;
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(HMCLGameInstance gameInstance) {
super(gameInstance);
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
this.isModpack = gameInstance.isModpack();
selected.bind(Bindings.createBooleanBinding(
() -> {
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
@Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance();
return selectedInstance != null && selectedInstance.getId().equals(instanceId);
},
GameDirectoryManager.selectedGameDirectoryProperty(),
GameDirectoryManager.selectedInstanceProperty()));
}
public ReadOnlyBooleanProperty selectedProperty() {
return selected;
}
public void rename() {
Instances.renameInstance(gameInstance);
}
public void duplicate() {
Instances.duplicateInstance(gameInstance);
}
public void remove() {
Instances.deleteInstance(gameInstance);
}
public void browse() {
Instances.openFolder(gameInstance);
}
public void testGame() {
Instances.testGame(gameInstance);
}
public void launch() {
Instances.launch(gameInstance);
}
public void modifyGameSettings() {
Instances.modifyGameSettings(gameInstance);
}
public void generateLaunchScript() {
Instances.generateLaunchScript(gameInstance);
}
public boolean canUpdate() {
return isModpack;
}
public void update() {
Instances.updateInstance(gameInstance);
}
}
@@ -1,248 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("instance.manage")));
/// Creates the fixed-directory instance list.
public GameListPage() {
getSidebar()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
setContent(new GameList());
FXUtils.applyDragListener(this, file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getNameWithoutExtension(file)), files -> {
Path file = files.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file), i18n("install.modpack"));
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
});
}
public void modifyGlobalGameSettings() {
Instances.modifyGlobalSettings(GameDirectoryManager.getSelectedRepository());
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
private static class GameList extends ListPageBase<GameListItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private final ObservableList<GameListItem> sourceList = FXCollections.observableArrayList();
private final FilteredList<GameListItem> filteredList = new FilteredList<>(sourceList);
public GameList() {
setItems(filteredList);
GameDirectoryManager.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Instances.installServerModpack());
}
@FXThread
private void loadVersions(HMCLGameRepository repository) {
listenerHolder.clear();
setLoading(true);
setFailedReason(null);
List<GameListItem> instanceItems = repository.getDisplayInstances()
.map(GameListItem::new)
.toList();
sourceList.setAll(instanceItems);
if (instanceItems.isEmpty()) {
setFailedReason(i18n("instance.empty.hint"));
}
setLoading(false);
}
private Predicate<GameListItem> createPredicate(String searchText) {
if (searchText == null || searchText.isEmpty()) {
return item -> true;
}
if (searchText.startsWith("regex:")) {
String regex = searchText.substring("regex:".length());
try {
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
return item -> pattern.matcher(item.getId()).find();
} catch (PatternSyntaxException e) {
return item -> false;
}
} else {
return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
}
}
public void refreshList() {
GameDirectoryManager.getSelectedRepository().refreshAsync().start();
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameListSkin(this);
}
private static class GameListSkin extends SkinBase<GameList> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Creates the instance list with search controls and viewport-constrained rows.
public GameListSkin(GameList skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
JFXListView<GameListItem> listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::installServerModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
center.failedReasonProperty().bind(skinnable.failedReasonProperty());
listView.setCellFactory(GameListCell::new);
listView.setItems(skinnable.getItems());
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
}
}
}
@@ -1,187 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.Tag;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Displays game instances in a popup selection list.
///
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
private static final String KEY = GameListPopupMenu.class.getName() + ".popup";
public static boolean hideShowing(Node owner) {
JFXPopup popup = (JFXPopup) owner.getProperties().get(KEY);
if (popup != null && popup.isShowing()) {
popup.hide();
return true;
} else {
return false;
}
}
/// Shows an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
List<HMCLGameInstance> instances) {
if (!hideShowing(owner)) {
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances);
}
}
/// Shows and returns an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
/// @return the shown popup
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
List<HMCLGameInstance> instances) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(instances.stream()
.map(GameItem::new)
.toList());
JFXPopup popup = new JFXPopup(menu);
owner.getProperties().put(KEY, popup);
popup.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> owner.getProperties().remove(KEY, popup));
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY, true);
return popup;
}
private final JFXListView<GameItem> listView = new JFXListView<>();
private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems());
public GameListPopupMenu() {
this.setMaxHeight(365);
this.getStyleClass().add("popup-menu-content");
listView.setCellFactory(Cell::new);
listView.setFixedCellSize(50);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
Label placeholder = new Label(i18n("instance.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> getChildren().setAll(empty ? placeholder : listView));
}
public ObservableList<GameItem> getItems() {
return listView.getItems();
}
private static final class Cell extends ListCell<GameItem> {
private final Region graphic;
private final RipplerContainer ripplerContainer;
private final ImageContainer imageView;
private final TwoLineListItem content;
private final StringProperty tag = new SimpleStringProperty();
/// Creates a popup row with a leading icon and the shared advanced-item text styling.
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
this.imageView = new ImageContainer(32);
this.imageView.setMouseTransparent(true);
this.content = new TwoLineListItem();
content.getStyleClass().add("container");
content.setLeading(imageView);
FXUtils.onChangeAndOperate(tag, tag -> {
if (StringUtils.isNotBlank(tag))
content.getTags().setAll(new Tag(tag));
else
content.getTags().clear();
});
this.ripplerContainer = new RipplerContainer(content);
StackPane rootPane = new StackPane();
rootPane.getStyleClass().add("advanced-list-item");
rootPane.getChildren().setAll(ripplerContainer);
rootPane.maxWidthProperty().bind(listView.widthProperty().subtract(5));
FXUtils.onClicked(rootPane, () -> {
@Nullable GameItem item = getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getGameInstance());
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
});
this.graphic = rootPane;
}
@Override
protected void updateItem(GameItem item, boolean empty) {
GameItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.ripplerContainer.resetFeedback();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
}
}
}
}
@@ -1,108 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.util.MissingResourceException;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, true, true);
page.supportChinese.set(false);
return page;
}
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, boolean curseForge, boolean modrinth) {
super(type, new Repository(), callback, instanceSelection);
((Repository) repository).downloadSource.bind(downloadSource);
supportChinese.set(true);
boolean supportedCurseForge = curseForge && CurseForgeRemoteAddonRepository.isAvailable();
downloadSources.setAll("addon.modrinth");
if (supportedCurseForge) {
downloadSources.add("addon.curseforge");
}
if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) {
if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else if (modrinth) {
downloadSource.set("addon.modrinth");
} else {
throw new AssertionError("Should not be here.");
}
} else {
if (modrinth) {
downloadSource.set("addon.modrinth");
} else if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else {
throw new AssertionError("Should not be here.");
}
}
}
private static class Repository extends LocalizedRemoteAddonRepository {
public Repository() {
}
public final StringProperty downloadSource = new SimpleStringProperty();
@Override
protected RemoteAddonRepository getBackedRemoteModRepository() {
if ("addon.modrinth".equals(downloadSource.get())) {
return ModrinthRemoteAddonRepository.getInstance();
} else {
return CurseForgeRemoteAddonRepository.getInstance();
}
}
}
@Override
protected String getLocalizedCategory(String category, Object self) {
if (category.isEmpty()) {
return "";
}
String key = ("addon.modrinth".equals(downloadSource.get()) ? "modrinth" : "curse") + ".category." + category;
try {
return I18n.getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.warning("Cannot find key " + key + " in resource bundle");
if (self instanceof CurseForgeRemoteAddonRepository.Category curseCategory) {
return curseCategory.getName();
}
return category;
}
}
@Override
protected String getLocalizedOfficialPage() {
if ("addon.modrinth".equals(downloadSource.get())) {
return i18n("addon.modrinth");
} else {
return i18n("addon.curseforge");
}
}
}
@@ -1,127 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.*;
import org.jetbrains.annotations.Nullable;
import java.util.*;
public class InstallerListPage extends ListPageBase<InstallerItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
/// Creates an installer list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new InstallerListPageSkin();
}
/// Rebuilds the component controls for the selected instance, or clears them if none is selected.
/// Minecraft editing follows the stored manifest's modifiability; other installed components
/// require a recognized patch to expose editing controls.
public void loadInstance(HMCLGameInstance.Optional instance) {
@Nullable HMCLGameInstance gameInstance = instance.instance();
this.gameInstance = gameInstance;
if (gameInstance == null) {
itemsProperty().clear();
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
itemsProperty().clear();
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM);
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
for (InstallerItem component : group.getComponents()) {
// Skip fabric-api and quilt-api and legacyfabric-api
if (component.getComponentType().getPatchId().endsWith("-api")) {
continue;
}
@Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType());
if (libraryVersion != null) {
component.versionProperty().set(new InstallerItem.InstalledState(
libraryVersion,
component.getComponentType() == GameComponentType.GAME
? !gameInstance.getManifest().isModifiable()
: !gameInstance.getAnalyzer().isClear(component.getComponentType()),
false
));
} else {
component.versionProperty().set(null);
}
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
component.getComponentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(component);
}
var currentItems = getItems().stream().map(InstallerItem::getComponentType).toList();
// other third-party libraries which are unable to manage.
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
if (currentItems.contains(mark.componentType())) continue;
// we have done this library above.
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
mark.componentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(installerItem);
}
}
private void reloadCurrentInstance() {
if (gameInstance != null) {
loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId()));
}
}
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
InstallerListPageSkin() {
super(InstallerListPage.this);
}
@Override
protected List<Node> initializeToolbar(InstallerListPage skinnable) {
return Collections.emptyList();
}
}
}
@@ -7,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<Account> action) {
Account account = Accounts.getSelectedAccount();
if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() &&
!(account instanceof AuthlibInjectorAccount && AuthlibInjectorServers.getServers().contains(((AuthlibInjectorAccount) account).getServer()))) {
CreateAccountPane dialog = new CreateAccountPane(AuthlibInjectorServers.getServers().iterator().next());
dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> {
Account newAccount = Accounts.getSelectedAccount();
if (newAccount == null) {
// user cancelled operation
} else {
Platform.runLater(() -> action.accept(newAccount));
}
});
Controllers.dialog(dialog);
} else if (account == null) {
CreateAccountPane dialog = new CreateAccountPane();
if (account == null) {
FirstRunAccountDialog dialog = new FirstRunAccountDialog();
dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> {
Account newAccount = Accounts.getSelectedAccount();
if (newAccount == null) {
@@ -374,7 +379,6 @@ public final class Instances {
}
public static void modifyGlobalSettings(HMCLGameRepository repository) {
Controllers.getSettingsPage().showGameSettings(repository);
Controllers.navigate(Controllers.getSettingsPage());
}
@@ -1,900 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.mod.ModManager;
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> implements PageAware {
private final ReentrantLock lock = new ReentrantLock();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
private final BooleanProperty isLoadingMods = new SimpleBooleanProperty(false);
private long modLoadRequestId;
/// Creates a mod list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ModListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
mods.forEach(it -> {
try {
modManager.addMod(it);
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse mod file " + it, e);
}
});
loadMods(modManager);
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModListPageSkin(this);
}
public void refresh() {
if (isLoadingMods.get()) {
return;
}
loadMods(modManager);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
return;
}
loadMods(gameInstance.getModManager());
}
private void loadMods(ModManager modManager) {
long requestId = ++modLoadRequestId;
setLoading(true);
isLoadingMods.set(true);
if (this.modManager != modManager) {
getItems().clear();
}
this.modManager = modManager;
CompletableFuture.supplyAsync(() -> {
lock.lock();
try {
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
lock.unlock();
}
}, Schedulers.io()).whenCompleteAsync((list, exception) -> {
if (this.modManager != modManager || requestId != modLoadRequestId) {
return;
}
try {
supportedLoaders.clear();
supportedLoaders.addAll(modManager.getSupportedLoaders());
if (exception == null) {
getItems().setAll(list);
} else {
LOG.warning("Failed to load mods", exception);
getItems().clear();
}
} finally {
isLoadingMods.set(false);
setLoading(false);
}
}, Schedulers.javafx());
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null) return;
// It's guaranteed that succeeded and failed are thread safe here.
List<String> succeeded = new ArrayList<>(res.size());
List<String> failed = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : res) {
try {
modManager.addMod(file);
succeeded.add(FileUtils.getName(file));
} catch (Exception e) {
LOG.warning("Unable to add mod " + file, e);
failed.add(FileUtils.getName(file));
// Actually addMod will not throw exceptions because FileChooser has already filtered files.
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
List<String> prompt = new ArrayList<>(1);
if (!succeeded.isEmpty())
prompt.add(i18n("mods.add.success", String.join(", ", succeeded)));
if (!failed.isEmpty())
prompt.add(i18n("mods.add.failed", String.join(", ", failed)));
Controllers.dialog(String.join("\n", prompt), i18n("mods.add"));
loadMods(modManager);
}).start();
}
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModInfoObject::getModInfo)
.forEach(info -> {
try {
info.delete();
} catch (IOException e) {
failed.add(info.getFile().toString());
LOG.warning("Failed to delete mod \"" + info.getId() + "\"", e);
}
});
loadMods(modManager);
if (!failed.isEmpty())
Controllers.dialog(i18n("mods.delete.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void setSelectedEnabled(ObservableList<ModInfoObject> selectedItems, boolean enabled) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModInfoObject::getModInfo)
.forEach(info -> {
try {
info.setActive(enabled);
} catch (IOException e) {
failed.add(info.getFile().toString());
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("mods.toggle.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
public void openModFolder() {
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getModsDirectory());
}
}
/// Checks the supplied mods for updates and opens the results for the current instance.
/// The target directory is retained even if this page switches instances during the check.
/// Does nothing while the list is loading or no instance is selected.
///
/// @param mods the mods to check
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
if (isLoading() || gameInstance == null) {
return;
}
Path modDirectory = gameInstance.getModManager().getDirectory();
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception instanceof CancellationException) return;
if (exception != null || result == null) {
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
if (exception != null) LOG.warning("Failed to check for updates", exception);
} else if (result.commonUpdates().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), modDirectory, result));
}
})
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("mods.update_modpack_mod.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
public void rollback(LocalModFile from, LocalModFile to) {
try {
modManager.rollback(from, to);
refresh();
} catch (IOException ex) {
Controllers.showToast(i18n("message.failed"));
}
}
public GameDirectory getGameDirectory() {
return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null;
}
public HMCLGameRepository getRepository() {
return gameInstance != null ? gameInstance.getRepository() : null;
}
public GameInstanceID getInstanceId() {
return gameInstance != null ? gameInstance.getId() : null;
}
@NotNullByDefault
private static final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
private final HBox toolbarSelecting;
private final JFXListView<ModListPage.ModInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
public ModListPageSkin(ModListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
pane.getChildren().setAll(root);
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
toolbarSelecting = new HBox();
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
JFXButton refreshButton = createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh);
refreshButton.disableProperty().bind(skinnable.isLoadingMods);
// Toolbar Normal
toolbarNormal.getChildren().setAll(
refreshButton,
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
// Toolbar Selecting
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> {
selectAll.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
};
listView.getSelectionModel().getSelectedItems().addListener(listener);
listView.getItems().addListener(listener);
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
selectAll,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new ModListPage.ModInfoListCell(listView, skinnable));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("mods.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("mods.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super ModListPage.ModInfoObject>) c -> {
if (isSearching.get()) {
search();
}
});
listView.setOnContextMenuRequested(event -> {
ModListPage.ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ModListPage.ModInfoDialog(selectedItem));
}
});
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ModListPage.ModInfoObject item : getSkinnable().getItems()) {
LocalModFile modInfo = item.getModInfo();
if (predicate.test(modInfo.getFileName())
|| predicate.test(modInfo.getName())
|| predicate.test(modInfo.getVersion())
|| predicate.test(modInfo.getGameVersion())
|| predicate.test(modInfo.getId())
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|| predicate.test((item.getProjectInfo() != null ? item.getProjectInfo().displayName() : null))) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ModInfoObject {
private final LocalModFile localModFile;
/// Matching project metadata, or null if the local mod cannot be identified.
private final @Nullable ModProjectInfo projectInfo;
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public LocalModFile getModInfo() {
return localModFile;
}
/// Returns matching project metadata, or null if no project was found.
public @Nullable ModProjectInfo getProjectInfo() {
return projectInfo;
}
private Image getDefaultIcon() {
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
private Image loadIcon() {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
iconPaths.add(this.localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load mod icons", e);
}
return getDefaultIcon();
}
}
private static final class ModInfoDialog extends JFXDialogLayout {
ModInfoDialog(ModInfoObject modInfo) {
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
prefWidthProperty().bind(widthBinding);
maxWidthProperty().bind(widthBinding);
var imageContainer = new ImageContainer(40);
titleContainer.setAlignment(Pos.CENTER_LEFT);
modInfo.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.getTitleLabel().setWrapText(true);
String displayName = modInfo.getModInfo().getName();
if (modInfo.getProjectInfo() != null && I18n.isUseChinese()) {
String chineseName = modInfo.getProjectInfo().mainName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
displayName = chineseName + " (" + displayName + ")";
}
}
title.setTitle(displayName);
StringJoiner subtitle = new StringJoiner("\n");
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
}
title.setSubtitle(subtitle.toString());
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(modInfo.getModInfo().getDescription().toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : versionOptional.get().loaders()) {
String tag = I18n.translateLoaderName(loader);
if (tag != null) tags.add(tag);
}
title.addTagsIfNotExist(tags);
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
getActions().add(officialPageButton);
}
@Nullable String mcmodUrl = modInfo.getProjectInfo() != null ? modInfo.getProjectInfo().mcmodUrl() : null;
if (mcmodUrl == null) {
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
getActions().add(searchButton);
} else {
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
mcmodButton.setExternalLink(mcmodUrl);
getActions().add(mcmodButton);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ModListPage page;
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
private final JFXSwitch activeSwitch = new JFXSwitch();
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getModInfo().isActive());
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
private Tooltip warningTooltip;
/// Creates a selectable mod row with file actions for the supplied page.
ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
super(listView);
this.page = page;
this.getStyleClass().add("mod-info-list-cell");
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
container.setPadding(new Insets(0, 8, 0, 8));
HBox.setHgrow(content, Priority.ALWAYS);
setSelectable();
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
// free up space when not visible
restoreButton.managedProperty().bind(restoreButton.visibleProperty());
HBox actions = new HBox();
actions.setAlignment(Pos.CENTER);
actions.getChildren().setAll(restoreButton, revealButton, infoButton, activeSwitch);
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
container.getChildren().setAll(imageContainer, content, actions);
StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
}
/// Updates mod details, warnings, and tags while retaining reusable tag labels.
@Override
protected void updateControl(@Nullable ModInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
pseudoClassStateChanged(WARNING, false);
if (warningTooltip != null) {
Tooltip.uninstall(this, warningTooltip);
warningTooltip = null;
}
if (empty || dataItem == null) return;
List<String> warning = new ArrayList<>();
var tags = new ArrayList<Tag>(2);
LocalModFile modInfo = dataItem.getModInfo();
@Nullable ModProjectInfo projectInfo = dataItem.getProjectInfo();
ModLoaderType modLoaderType = modInfo.getModLoaderType();
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
String displayName = modInfo.getName();
if (projectInfo != null && I18n.isUseChinese()) {
String chineseName = projectInfo.mainName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
displayName = chineseName + " (" + displayName + ")";
}
}
content.setTitle(displayName);
StringJoiner joiner = new StringJoiner(" | ");
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());
joiner.add(FileUtils.getName(modInfo.getFile()));
content.setSubtitle(joiner.toString());
if (modLoaderType == ModLoaderType.UNKNOWN) {
tags.add(Tag.warning(i18n("mods.unknown")));
} else if (!page.supportedLoaders.contains(modLoaderType)) {
warning.add(i18n("mods.warning.loader_mismatch"));
tags.add(Tag.warning(I18n.translateLoaderType(dataItem.getModInfo().getModLoaderType())));
}
String modVersion = modInfo.getVersion();
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
tags.add(new Tag(modVersion));
}
content.getTags().setAll(tags);
(observedActiveProperty = modInfo.activeProperty()).addListener(weakActiveListener);
activeSwitch.setDisableAnimation(true);
activeSwitch.setSelected(modInfo.isActive());
activeSwitch.setOnAction(event -> {
try {
modInfo.setActive(activeSwitch.isSelected());
} catch (IOException e) {
LOG.warning("Unable to change state of mod file " + modInfo.getFile(), e);
Controllers.showToast(i18n("mods.toggle.failed"));
} finally {
activeSwitch.setSelected(modInfo.isActive());
}
});
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
() -> page.rollback(modInfo, localModFile),
popup.get()))
.toList()
);
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
});
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
if (!warning.isEmpty()) {
pseudoClassStateChanged(WARNING, true);
//noinspection ConstantValue
this.warningTooltip = warning.size() == 1
? new Tooltip(warning.get(0))
: new Tooltip(String.join("\n", warning));
FXUtils.installFastTooltip(this, warningTooltip);
}
}
}
}
@@ -1,689 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> {
private static final String TIP_KEY = "resourcePackWarning";
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
return switch (compatibility) {
case TOO_NEW -> i18n("resourcepack.warning.too_new");
case TOO_OLD -> i18n("resourcepack.warning.too_old");
case INVALID -> i18n("resourcepack.warning.invalid");
case MISSING_PACK_META -> i18n("resourcepack.warning.missing_pack_meta");
case MISSING_GAME_META -> i18n("resourcepack.warning.missing_game_meta");
default -> null;
};
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path resourcePackDirectory;
private ResourcePackManager resourcePackManager;
private final ReentrantLock lock = new ReentrantLock();
/// Creates a resource-pack list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ResourcePackListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new ResourcePackListPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.resourcePackManager = null;
this.resourcePackDirectory = null;
getItems().clear();
return;
}
this.resourcePackManager = gameInstance.getResourcePackManager();
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
refresh();
}
public void refresh() {
if (resourcePackManager == null) return;
setDisable(false);
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
lock.lock();
try {
if (!ResourcePackManager.isMcVersionSupported(resourcePackManager.getMinecraftVersion())) return null;
resourcePackManager.refresh();
return resourcePackManager.arePacksEnabled(resourcePackManager.getLocalFiles().stream())
.map(ResourcePackInfoObject::new)
.toList();
} finally {
lock.unlock();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result != null) {
getItems().setAll(result);
} else { // Unsupported mc version
getItems().clear();
setDisable(true);
}
} else {
LOG.warning("Failed to load resource packs", exception);
getItems().clear();
}
setLoading(false);
}).start();
}
public void addFiles(List<Path> files) {
if (resourcePackManager == null) return;
List<Path> failures = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : files) {
try {
resourcePackManager.importResourcePack(file);
} catch (Exception e) {
LOG.warning("Failed to add resource pack", e);
failures.add(file);
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
if (!failures.isEmpty()) {
StringBuilder failure = new StringBuilder(i18n("resourcepack.add.failed"));
for (Path file: failures) {
failure.append("\n").append(file.toString());
}
Controllers.dialog(failure.toString(), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
refresh();
}).start();
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("resourcepack.add"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
private void onOpenFolder() {
if (resourcePackDirectory != null) {
FXUtils.openFolder(resourcePackDirectory);
}
}
private void setSelectedEnabled(List<ResourcePackInfoObject> selectedItems, boolean enabled) {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.warning"),
MessageDialogPane.MessageType.WARNING,
() -> {
SettingsManager.state().getShownTips().put(TIP_KEY, true);
setSelectedEnabled(selectedItems, true);
}, null);
} else {
if (resourcePackManager == null) return;
if (enabled) {
resourcePackManager.enableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
} else {
resourcePackManager.disableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
}
for (ResourcePackInfoObject item : selectedItems) {
item.enabledProperty().set(enabled);
}
}
}
private void removeSelected(List<ResourcePackInfoObject> selectedItems) {
try {
if (resourcePackManager != null) {
if (resourcePackManager.removeResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList())) {
refresh();
}
}
} catch (IOException e) {
Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to delete resource packs", e);
}
}
/// Checks the supplied resource packs for updates and opens the results for the current instance.
/// The target directory is retained even if this page switches instances during the check.
/// Does nothing when no instance is selected.
///
/// @param resourcePacks the resource packs to check
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
if (gameInstance == null) {
return;
}
Path resourcePackDirectory = gameInstance.getResourcePackManager().getDirectory();
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), resourcePacks);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || result == null) {
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
} else if (result.commonUpdates().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), resourcePackDirectory, result));
}
})
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("resourcepack.update_in_modpack.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
@NotNullByDefault
private static final class ResourcePackListPageSkin extends SkinBase<ResourcePackListPage> {
private final JFXListView<ResourcePackInfoObject> listView;
private final JFXTextField searchField = new JFXTextField();
private final TransitionPane toolbarPane = new TransitionPane();
private final HBox searchBar = new HBox();
private final HBox toolbarNormal = new HBox();
private final HBox toolbarSelecting = new HBox();
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
private ResourcePackListPageSkin(ResourcePackListPage control) {
super(control);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
{
// Toolbar Selecting
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
control.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("button.enable"), SVG.CHECK, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("button.disable"), SVG.CLOSE, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream().map(ResourcePackInfoObject::getFile).toList()
)
),
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
listView.getSelectionModel().selectAll()),
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
FXUtils.onChange(searchField.textProperty(), newValue -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
// Toolbar Normal
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
toolbarNormal.setPickOnBounds(false);
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh),
createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, control::onOpenFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(control.loadingProperty());
listView.setCellFactory(x -> new ResourcePackListCell(listView, control));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("resourcepack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("resourcepack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), control.getItems());
listView.setOnContextMenuRequested(event -> {
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ResourcePackInfoDialog(selectedItem));
}
});
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ResourcePackInfoObject item : getSkinnable().getItems()) {
ResourcePackFile resourcePack = item.getFile();
LocalAddonFile.Description description = resourcePack.getDescription();
Stream<String> descriptionParts = description == null
? Stream.empty()
: description.parts().stream().map(LocalAddonFile.Description.Part::text);
if (predicate.test(resourcePack.getFileNameWithExtension())
|| predicate.test(resourcePack.getFileName())
|| descriptionParts.anyMatch(predicate)) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ResourcePackInfoObject {
private final ResourcePackFile file;
private final BooleanProperty enabled;
private final ItemPropertyAsyncCache<Image, ResourcePackInfoObject> iconCache;
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
this.file = pair.key();
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public ResourcePackFile getFile() {
return file;
}
public BooleanProperty enabledProperty() {
return enabled;
}
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image icon = file.loadIcon();
if (icon != null && !icon.isError() && icon.getWidth() > 0 && icon.getHeight() > 0 && Math.abs(icon.getWidth() - icon.getHeight()) < 1) {
return icon;
}
return getDefaultIcon();
}
}
private static final class ResourcePackListCell extends MDListCell<ResourcePackInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ResourcePackListPage page;
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
private final JFXSwitch activeSwitch;
private BooleanProperty booleanProperty = null;
/// Creates a selectable pack row with compatibility confirmation and file actions.
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
super(listView);
this.page = page;
getStyleClass().add("resource-pack-list-cell");
HBox root = new HBox(8);
root.setPickOnBounds(false);
root.setAlignment(Pos.CENTER_LEFT);
root.setPadding(new Insets(0, 8, 0, 8));
activeSwitch = new JFXSwitch() {
@Override
public void fire() {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && getItem() != null && !getItem().getFile().isCompatible()) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.info"),
MessageDialogPane.MessageType.INFO,
() -> {
super.fire();
SettingsManager.state().getShownTips().put(TIP_KEY, true);
}, null);
} else {
super.fire();
}
}
};
activeSwitch.setOnAction(e -> {
if (getItem() != null) getItem().getFile().setEnabled(activeSwitch.isSelected());
});
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
HBox.setHgrow(content, Priority.ALWAYS);
HBox actions = new HBox();
actions.setAlignment(Pos.CENTER);
actions.getChildren().setAll(btnReveal, btnInfo, activeSwitch);
root.getChildren().setAll(imageContainer, content, actions);
setSelectable();
StackPane.setMargin(root, new Insets(8));
getContainer().getChildren().add(root);
}
/// Updates pack details and reuses the compatibility tag when a warning remains.
@Override
protected void updateControl(@Nullable ResourcePackInfoObject item, boolean empty) {
pseudoClassStateChanged(WARNING, false);
if (empty || item == null) return;
ResourcePackFile file = item.getFile();
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(itemProperty()));
content.setTitle(file.getFileName());
{
var description = file.getDescription();
content.setSubtitle(description != null ? description.toStringSingleLine() : "");
}
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(item)));
if (booleanProperty != null) {
activeSwitch.selectedProperty().unbindBidirectional(booleanProperty);
}
activeSwitch.selectedProperty().bindBidirectional(booleanProperty = item.enabledProperty());
{
var compatibility = file.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
pseudoClassStateChanged(WARNING, true);
content.getTags().setAll(Tag.warning(getWarning(compatibility)));
} else {
content.getTags().clear();
}
}
}
}
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
ResourcePackInfoDialog(ResourcePackInfoObject packInfoObject) {
ResourcePackFile pack = packInfoObject.getFile();
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
packInfoObject.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(pack.getFileName());
title.setSubtitle(pack.getFileNameWithExtension());
var compatibility = pack.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
title.addTagWarning(getWarning(compatibility));
}
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(Objects.requireNonNullElse(pack.getDescription(), "").toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
FXUtils.onChange(description.heightProperty(), newVal -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
}
@@ -1,75 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* @author Glavo
*/
public final class WorldBackupTask extends Task<Path> {
private final World world;
private final Path backupsDir;
private final boolean needLock;
public WorldBackupTask(World world, Path backupsDir, boolean needLock) {
this.world = world;
this.backupsDir = backupsDir;
this.needLock = needLock;
}
@Override
public void execute() throws Exception {
try (FileChannel lockChannel = needLock ? world.lock() : null) {
Files.createDirectories(backupsDir);
String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER);
String baseName = time + "_" + world.getFileName();
Path backupFile = null;
OutputStream outputStream = null;
int count;
for (count = 0; count < 256; count++) {
try {
backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath();
outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
break;
} catch (FileAlreadyExistsException ignored) {
}
}
if (outputStream == null)
throw new IOException("Too many attempts");
try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) {
String rootName = world.getFileName();
Path rootDir = this.world.getFile();
Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
if (path.endsWith("session.lock")) {
return FileVisitResult.CONTINUE;
}
zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/')));
Files.copy(path, zipOutputStream);
zipOutputStream.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
setResult(backupFile);
}
}
}
@@ -1,265 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.BooleanProperty;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.BackupInfo> implements WorldManagePage.WorldRefreshable {
static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
private final World world;
private final Path backupsDir;
private final BooleanProperty readOnly;
private final Pattern backupFileNamePattern;
public WorldBackupsPage(WorldManagePage worldManagePage) {
this.world = worldManagePage.getWorld();
this.backupsDir = worldManagePage.getBackupsDir();
this.readOnly = worldManagePage.readOnlyProperty();
this.backupFileNamePattern = Pattern.compile("(?<datetime>[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?<count>[0-9]+))?\\.zip");
refresh();
}
public void refresh() {
setLoading(true);
Task.supplyAsync(() -> {
if (Files.isDirectory(backupsDir)) {
try (Stream<Path> paths = Files.list(backupsDir)) {
ArrayList<BackupInfo> result = new ArrayList<>();
paths.forEach(path -> {
if (Files.isRegularFile(path)) {
try {
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
if (matcher.matches()) {
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
int count = 0;
if (matcher.group("count") != null) {
count = Integer.parseInt(matcher.group("count"));
}
result.add(new BackupInfo(path, new World(path), time, count));
}
} catch (Throwable e) {
LOG.warning("Failed to load backup file " + path, e);
}
}
});
result.sort(Comparator.naturalOrder());
return result;
}
} else {
return new ArrayList<BackupInfo>();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
this.setLoading(false);
if (exception == null) {
this.setItems(FXCollections.observableList(result));
} else {
LOG.warning("Failed to load backups", exception);
}
}).start();
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldBackupsPageSkin();
}
void createBackup() {
Controllers.taskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> {
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
if (!matcher.matches()) {
throw new AssertionError("Wrong backup file name" + path);
}
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
int count = 0;
if (matcher.group("count") != null) {
count = Integer.parseInt(matcher.group("count"));
}
return Pair.pair(path, new BackupInfo(path, new World(path), time, count));
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
WorldBackupsPage.this.getItems().add(result.getValue());
WorldBackupsPage.this.getItems().sort(Comparator.naturalOrder());
Controllers.dialog(i18n("world.backup.create.success", result.getKey()), null, MessageDialogPane.MessageType.INFO);
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
LOG.warning("Failed to create backup", exception);
Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}), i18n("world.backup"), TaskCancellationAction.NO_CANCEL);
}
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
WorldBackupsPageSkin() {
super(WorldBackupsPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.backup.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
protected List<Node> initializeToolbar(WorldBackupsPage skinnable) {
JFXButton createBackup = createToolbarButton2(i18n("world.backup.create.new_one"), SVG.ARCHIVE, skinnable::createBackup);
createBackup.disableProperty().bind(getSkinnable().readOnly);
return Arrays.asList(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createBackup
);
}
}
public final class BackupInfo extends Control implements Comparable<BackupInfo> {
private final Path file;
private final World backupWorld;
private final LocalDateTime backupTime;
private final int count;
public BackupInfo(Path file, World backupWorld, LocalDateTime backupTime, int count) {
this.file = file;
this.backupWorld = backupWorld;
this.backupTime = backupTime;
this.count = count;
}
public World getBackupWorld() {
return backupWorld;
}
public LocalDateTime getBackupTime() {
return backupTime;
}
@Override
protected Skin<?> createDefaultSkin() {
return new BackupInfoSkin(this);
}
void onReveal() {
FXUtils.showFileInExplorer(file);
}
void onDelete() {
WorldBackupsPage.this.getItems().remove(this);
Task.runAsync(() -> Files.delete(file)).start();
}
@Override
public int compareTo(@NotNull WorldBackupsPage.BackupInfo that) {
int c = this.backupTime.compareTo(that.backupTime);
return c != 0 ? c : Integer.compare(this.count, that.count);
}
}
private static final class BackupInfoSkin extends SkinBase<BackupInfo> {
BackupInfoSkin(BackupInfo skinnable) {
super(skinnable);
World world = skinnable.getBackupWorld();
BorderPane root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
var imageView = new ImageContainer(32);
left.getChildren().add(imageView);
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
{
TwoLineListItem item = new TwoLineListItem();
root.setCenter(item);
if (skinnable.getBackupWorld().getWorldName() != null)
item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName()));
item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")"));
if (world.getGameVersion() != null)
item.addTag(I18n.getDisplayVersion(world.getGameVersion()));
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
right.getChildren().add(btnReveal);
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> skinnable.onReveal());
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
right.getChildren().add(btnDelete);
FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete"));
btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::onDelete, null));
}
getChildren().setAll(new RipplerContainer(root));
}
}
}
@@ -1,65 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardSinglePage;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class WorldExportPage extends WizardSinglePage {
private final StringProperty path = new SimpleStringProperty();
private final StringProperty gameVersion = new SimpleStringProperty();
private final StringProperty worldName = new SimpleStringProperty();
private final World world;
public WorldExportPage(World world, Path export, Runnable onFinish) {
super(onFinish);
this.world = world;
path.set(export.toString());
if (world.getGameVersion() != null)
gameVersion.set(I18n.getDisplayVersion(world.getGameVersion()));
worldName.set(world.getWorldName());
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldExportPageSkin(this);
}
public StringProperty pathProperty() {
return path;
}
public StringProperty gameVersionProperty() {
return gameVersion;
}
public StringProperty worldNameProperty() {
return worldName;
}
public void export() {
onFinish.run();
}
@Override
public String getTitle() {
return i18n("world.export.wizard", world.getFileName());
}
@Override
protected Object finish() {
return Task.runAsync(i18n("world.export.wizard", worldName.get()), () -> world.export(Paths.get(path.get()), worldName.get()));
}
}
@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.binding.Bindings;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
import org.jackhuang.hmcl.ui.construct.LinePane;
import org.jackhuang.hmcl.ui.construct.LineTextPane;
import java.nio.file.Files;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class WorldExportPageSkin extends SkinBase<WorldExportPage> {
public WorldExportPageSkin(WorldExportPage skinnable) {
super(skinnable);
VBox container = new VBox();
container.setSpacing(16);
container.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(container, 500);
ComponentList list = new ComponentList();
var chooseFileButton = new LineFileChooserButton();
chooseFileButton.setTitle(i18n("world.export.location"));
chooseFileButton.setType(LineFileChooserButton.Type.SAVE_FILE);
chooseFileButton.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
chooseFileButton.locationProperty().bindBidirectional(skinnable.pathProperty());
var worldNamePane = new LinePane();
worldNamePane.setTitle(i18n("world.name"));
JFXTextField txtWorldName = new JFXTextField();
txtWorldName.textProperty().bindBidirectional(skinnable.worldNameProperty());
txtWorldName.setPrefWidth(300);
worldNamePane.setRight(txtWorldName);
LineTextPane gameVersionPane = new LineTextPane();
gameVersionPane.setTitle(i18n("world.game_version"));
gameVersionPane.textProperty().bind(skinnable.gameVersionProperty());
list.getContent().setAll(chooseFileButton, worldNamePane, gameVersionPane);
container.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("world.export")),
list
);
JFXButton btnExport = FXUtils.newRaisedButton(i18n("button.export"));
btnExport.disableProperty().bind(Bindings.createBooleanBinding(() -> txtWorldName.getText().isEmpty() || Files.exists(Paths.get(chooseFileButton.getLocation())),
txtWorldName.textProperty().isEmpty(), chooseFileButton.locationProperty()));
btnExport.setOnAction(e -> skinnable.export());
HBox bottom = new HBox();
bottom.setAlignment(Pos.CENTER_RIGHT);
bottom.getChildren().setAll(btnExport);
container.getChildren().add(bottom);
getChildren().setAll(container);
}
}
@@ -1,670 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.effect.BoxBlur;
import javafx.scene.image.Image;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.glavo.nbt.tag.*;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable {
private final WorldManagePage worldManagePage;
private boolean isReadOnly;
private final World world;
private CompoundTag levelData;
private CompoundTag playerData;
private final ImageContainer iconImageView = new ImageContainer(32);
public WorldInfoPage(WorldManagePage worldManagePage) {
this.worldManagePage = worldManagePage;
this.world = worldManagePage.getWorld();
refresh();
}
private void updateControls() {
CompoundTag dataTag = (CompoundTag) levelData.get("Data");
CompoundTag playerTag = playerData;
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
setContent(scrollPane);
VBox rootPane = new VBox();
rootPane.setFillWidth(true);
scrollPane.setContent(rootPane);
FXUtils.smoothScrolling(scrollPane);
rootPane.getStyleClass().add("card-list");
ComponentList worldInfo = new ComponentList();
{
var worldNamePane = new LinePane();
{
worldNamePane.setTitle(i18n("world.name"));
JFXTextField worldNameField = new JFXTextField();
setRightTextField(worldNamePane, worldNameField, 200);
if (dataTag.get("LevelName") instanceof StringTag worldNameTag) {
var worldName = new SimpleStringProperty(worldNameTag.get());
FXUtils.bindString(worldNameField, worldName);
worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName);
worldName.addListener((observable, oldValue, newValue) -> {
if (StringUtils.isNotBlank(newValue)) {
try {
world.setWorldName(newValue);
worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())));
} catch (Exception e) {
LOG.warning("Failed to set world name", e);
}
}
});
} else {
worldNameField.setDisable(true);
}
}
var gameVersionPane = new LineTextPane();
{
gameVersionPane.setTitle(i18n("world.info.game_version"));
gameVersionPane.setText(world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString());
}
var iconPane = new LinePane();
{
iconPane.setTitle(i18n("world.icon"));
{
iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
JFXButton editIconButton = FXUtils.newToggleButton4(SVG.EDIT, 20);
JFXButton resetIconButton = FXUtils.newToggleButton4(SVG.RESTORE, 20);
{
editIconButton.setDisable(isReadOnly);
editIconButton.setOnAction(event -> Controllers.confirm(
I18n.i18n("world.icon.change.tip"),
I18n.i18n("world.icon.change"),
MessageDialogPane.MessageType.INFO,
this::changeWorldIcon,
null
));
FXUtils.installFastTooltip(editIconButton, i18n("button.edit"));
resetIconButton.setDisable(isReadOnly);
resetIconButton.setOnAction(event -> this.clearWorldIcon());
FXUtils.installFastTooltip(resetIconButton, i18n("button.reset"));
}
HBox hBox = new HBox(8);
hBox.setAlignment(Pos.CENTER_LEFT);
hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton);
iconPane.setRight(hBox);
}
var seedPane = new LinePane();
{
seedPane.setTitle(i18n("world.info.random_seed"));
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
JFXButton visibilityButton = FXUtils.newToggleButton4(SVG.VISIBILITY_OFF);
{
visibilityButton.setAlignment(Pos.CENTER);
visibilityButton.setOnAction((event) -> visibility.set(!visibility.get()));
}
Label seedLabel = new Label();
{
FXUtils.copyOnDoubleClick(seedLabel);
seedLabel.setAlignment(Pos.CENTER_RIGHT);
seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : "");
BoxBlur blur = new BoxBlur();
blur.setIterations(3);
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
visibilityButton.setGraphic(icon.createIcon(16));
seedLabel.setEffect(isVisibility ? null : blur);
});
}
HBox right = new HBox(8);
{
right.setAlignment(Pos.CENTER_RIGHT);
right.getChildren().setAll(visibilityButton, seedLabel);
seedPane.setRight(right);
}
}
var worldSpawnPoint = new LineTextPane();
{
worldSpawnPoint.setTitle(i18n("world.info.spawn"));
String value;
// Valid after 1.21.9-pre1
if (dataTag.get("spawn") instanceof CompoundTag spawnTag && spawnTag.get("pos") instanceof IntArrayTag posTag) {
value = (spawnTag.get("dimension") instanceof StringTag dimensionTag)
? Dimension.of(dimensionTag).formatPosition(posTag)
: Dimension.OVERWORLD.formatPosition(posTag);
}
// Valid before 1.21.9-pre1
else if (dataTag.get("SpawnX") instanceof IntTag intX
&& dataTag.get("SpawnY") instanceof IntTag intY
&& dataTag.get("SpawnZ") instanceof IntTag intZ) {
value = Dimension.OVERWORLD.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
} else {
value = null;
}
worldSpawnPoint.setText(value);
}
var lastPlayedPane = new LineTextPane();
{
lastPlayedPane.setTitle(i18n("world.info.last_played"));
lastPlayedPane.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
}
var timePane = new LineTextPane();
{
timePane.setTitle(i18n("world.info.time"));
if (dataTag.get("Time") instanceof LongTag timeTag) {
Duration duration = Duration.ofSeconds(timeTag.getValue() / 20);
timePane.setText(i18n("world.info.time.format", duration.toDays(), duration.toHoursPart(), duration.toMinutesPart()));
}
}
var allowCheatsButton = new LineSwitch();
{
allowCheatsButton.setTitle(i18n("world.info.allow_cheats"));
allowCheatsButton.setDisable(isReadOnly);
bindTagAndSwitch(dataTag.get("allowCommands"), allowCheatsButton);
}
var generateFeaturesButton = new LineSwitch();
{
generateFeaturesButton.setTitle(i18n("world.info.generate_features"));
generateFeaturesButton.setDisable(isReadOnly);
// Valid before (1.16)20w20a
if (dataTag.get("MapFeatures") instanceof ByteTag generateFeaturesTag) {
bindTagAndSwitch(generateFeaturesTag, generateFeaturesButton);
}
// Valid after (1.16)20w20a
else if (world.getNormalizedWorldGenSettingsData() != null) {
CompoundTag unifiedWorldGenSettingsDataTag = world.getNormalizedWorldGenSettingsData();
Tag tag = unifiedWorldGenSettingsDataTag.get("generate_features"); // Valid between (1.16)20w20a and 26.1-snapshot-6
if (tag == null)
tag = unifiedWorldGenSettingsDataTag.get("generate_structures"); // Valid after 26.1-snapshot-6
bindTagAndSwitch(tag, generateFeaturesButton);
} else {
generateFeaturesButton.setDisable(true);
}
}
var difficultyButton = new LineSelectButton<Difficulty>();
{
difficultyButton.setTitle(i18n("world.info.difficulty"));
difficultyButton.setDisable(isReadOnly);
difficultyButton.setItems(Difficulty.items);
Difficulty difficulty;
// Valid before 26.1-snapshot-6
if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue((byte) newValue.ordinal());
saveWorldData();
}
});
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("difficulty") instanceof StringTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue(newValue.getTagStringValue());
saveWorldData();
}
});
} else {
difficultyButton.setDisable(true);
}
}
var difficultyLockPane = new LineSwitch();
{
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
difficultyLockPane.setDisable(isReadOnly);
// Valid before 26.1-snapshot-6
if (dataTag.get("DifficultyLocked") instanceof ByteTag difficultyLockedTag) {
bindTagAndSwitch(difficultyLockedTag, difficultyLockPane);
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("locked") instanceof ByteTag lockedTag) {
bindTagAndSwitch(lockedTag, difficultyLockPane);
} else {
difficultyLockPane.setDisable(true);
}
}
worldInfo.getContent().setAll(
worldNamePane, gameVersionPane, iconPane, seedPane, worldSpawnPoint, lastPlayedPane, timePane,
allowCheatsButton, generateFeaturesButton, difficultyButton, difficultyLockPane);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info")), worldInfo);
}
if (playerTag != null) {
ComponentList playerInfo = new ComponentList();
var locationPane = new LineTextPane();
{
locationPane.setTitle(i18n("world.info.player.location"));
Dimension dimension = Dimension.of(playerTag.get("Dimension"));
if (dimension != null && playerTag.get("Pos") instanceof ListTag<?> posTag) {
locationPane.setText(dimension.formatPosition(posTag));
}
}
var lastDeathLocationPane = new LineTextPane();
{
lastDeathLocationPane.setTitle(i18n("world.info.player.last_death_location"));
// Valid after 22w14a; prior to this version, the game did not record the last death location data.
if (playerTag.get("LastDeathLocation") instanceof CompoundTag LastDeathLocationTag) {
Dimension dimension = Dimension.of(LastDeathLocationTag.get("dimension"));
if (dimension != null && LastDeathLocationTag.get("pos") instanceof IntArrayTag posTag) {
lastDeathLocationPane.setText(dimension.formatPosition(posTag));
}
}
}
var spawnPane = new LineTextPane();
{
spawnPane.setTitle(i18n("world.info.player.spawn"));
if (playerTag.get("respawn") instanceof CompoundTag respawnTag
&& respawnTag.get("dimension") instanceof StringTag dimensionTag
&& respawnTag.get("pos") instanceof IntArrayTag intArrayTag
&& intArrayTag.size() >= 3) { // Valid after 25w07a
spawnPane.setText(Dimension.of(dimensionTag).formatPosition(intArrayTag));
} else if (playerTag.get("SpawnX") instanceof IntTag intX
&& playerTag.get("SpawnY") instanceof IntTag intY
&& playerTag.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a
// SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld.
spawnPane.setText((playerTag.get("SpawnDimension") instanceof StringTag dimensionTag ? Dimension.of(dimensionTag) : Dimension.OVERWORLD)
.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()));
}
}
var playerGameTypePane = new LineSelectButton<GameType>();
{
playerGameTypePane.setTitle(i18n("world.info.player.game_type"));
playerGameTypePane.setDisable(worldManagePage.isReadOnly());
playerGameTypePane.setItems(GameType.items);
// Valid before 26.1-snapshot-6
Tag rawHardcoreTag = dataTag.get("hardcore");
// Valid after 26.1-snapshot-6
if (rawHardcoreTag == null && dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingsTag) {
rawHardcoreTag = difficultySettingsTag.get("hardcore");
}
if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag && rawHardcoreTag instanceof ByteTag hardcoreTag) {
boolean isHardcore = hardcoreTag.getValue() == 1;
GameType gameType = GameType.of(playerGameTypeTag.getValue(), isHardcore);
if (gameType != null) {
playerGameTypePane.setValue(gameType);
playerGameTypePane.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
if (newValue == GameType.HARDCORE) {
playerGameTypeTag.setValue(0); // survival (hardcore worlds are survival+hardcore flag)
hardcoreTag.setValue((byte) 1);
} else {
playerGameTypeTag.setValue(newValue.ordinal());
hardcoreTag.setValue((byte) 0);
}
saveWorldData();
}
});
} else {
playerGameTypePane.setDisable(true);
}
} else {
playerGameTypePane.setDisable(true);
}
}
var healthPane = new LinePane();
{
healthPane.setTitle(i18n("world.info.player.health"));
setRightTextField(healthPane, 50, playerTag.get("Health"));
}
var foodLevelPane = new LinePane();
{
foodLevelPane.setTitle(i18n("world.info.player.food_level"));
setRightTextField(foodLevelPane, 50, playerTag.get("foodLevel"));
}
var foodSaturationPane = new LinePane();
{
foodSaturationPane.setTitle(i18n("world.info.player.food_saturation_level"));
setRightTextField(foodSaturationPane, 50, playerTag.get("foodSaturationLevel"));
}
var xpLevelPane = new LinePane();
{
xpLevelPane.setTitle(i18n("world.info.player.xp_level"));
setRightTextField(xpLevelPane, 50, playerTag.get("XpLevel"));
}
playerInfo.getContent().setAll(
locationPane, lastDeathLocationPane, spawnPane, playerGameTypePane,
healthPane, foodLevelPane, foodSaturationPane, xpLevelPane
);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.player")), playerInfo);
}
}
private void setRightTextField(LinePane linePane, int perfWidth, Tag tag) {
JFXTextField textField = new JFXTextField();
setRightTextField(linePane, textField, perfWidth);
if (tag instanceof IntTag intTag) {
bindTagAndTextField(intTag, textField);
} else if (tag instanceof FloatTag floatTag) {
bindTagAndTextField(floatTag, textField);
} else {
textField.setDisable(true);
}
}
private void setRightTextField(LinePane linePane, JFXTextField textField, int perfWidth) {
textField.setDisable(isReadOnly);
textField.setPrefWidth(perfWidth);
linePane.setRight(textField);
}
/// Saves boolean byte-tag edits through the switch, disabling it for missing or invalid tags.
private void bindTagAndSwitch(@Nullable Tag tag, LineSwitch switchControl) {
if (tag instanceof ByteTag byteTag) {
byte value = byteTag.getValue();
if (value == 0 || value == 1) {
switchControl.setSelected(value == 1);
switchControl.selectedProperty().addListener((o, oldValue, newValue) -> {
try {
byteTag.setValue((byte) (newValue ? 1 : 0));
saveWorldData();
} catch (Exception e) {
switchControl.setSelected(oldValue);
LOG.warning("Exception happened when saving world info", e);
}
});
} else {
switchControl.setDisable(true);
}
} else {
switchControl.setDisable(true);
}
}
private void bindTagAndTextField(IntTag intTag, JFXTextField jfxTextField) {
jfxTextField.setText(intTag.getValue().toString());
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
Integer integer = Lang.toIntOrNull(newValue);
if (integer != null) {
intTag.setValue(integer);
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving world data", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true));
}
private void bindTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) {
jfxTextField.setText(new DecimalFormat("0.#").format(floatTag.getValue()));
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
Float floatValue = Lang.toFloatOrNull(newValue);
if (floatValue != null) {
floatTag.setValue(floatValue);
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving world data", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
}
private void saveWorldData() {
LOG.info("Saving data of world " + world.getWorldName());
try {
this.world.writeWorldData();
} catch (IOException e) {
LOG.warning("Failed to save world data", e);
}
}
@Override
public void refresh() {
setFailedReason(null);
try {
this.isReadOnly = worldManagePage.isReadOnly();
this.levelData = world.getLevelData();
this.playerData = world.getPlayerData();
updateControls();
} catch (Exception e) {
LOG.warning("Failed to refresh world info", e);
setFailedReason(i18n("world.info.failed"));
}
}
private record Dimension(String name) {
static final Dimension OVERWORLD = new Dimension(null);
static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether"));
static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end"));
static Dimension of(Tag tag) {
if (tag instanceof IntTag intTag) {
return switch (intTag.get()) {
case 0 -> OVERWORLD;
case -1 -> THE_NETHER;
case 1 -> THE_END;
default -> null;
};
} else if (tag instanceof StringTag stringTag) {
String id = stringTag.getValue();
return switch (id) {
case "overworld", "minecraft:overworld" -> OVERWORLD;
case "the_nether", "minecraft:the_nether" -> THE_NETHER;
case "the_end", "minecraft:the_end" -> THE_END;
default -> new Dimension(id);
};
} else {
return null;
}
}
String formatPosition(Tag tag) {
if (tag instanceof ListTag<?> listTag && listTag.size() == 3) {
if (listTag.getTag(0) instanceof DoubleTag x
&& listTag.getTag(1) instanceof DoubleTag y
&& listTag.getTag(2) instanceof DoubleTag z) {
return formatPosition(x.get(), y.get(), z.get());
}
return null;
}
if (tag instanceof IntArrayTag intArrayTag) {
int x = intArrayTag.get(0);
int y = intArrayTag.get(1);
int z = intArrayTag.get(2);
return formatPosition(x, y, z);
}
return null;
}
String formatPosition(int x, int y, int z) {
return this == OVERWORLD
? "(%d, %d, %d)".formatted(x, y, z)
: "%s (%d, %d, %d)".formatted(name, x, y, z);
}
String formatPosition(double x, double y, double z) {
return this == OVERWORLD
? "(%.2f, %.2f, %.2f)".formatted(x, y, z)
: "%s (%.2f, %.2f, %.2f)".formatted(name, x, y, z);
}
}
private enum Difficulty {
PEACEFUL, EASY, NORMAL, HARD;
static final ObservableList<Difficulty> items = FXCollections.observableList(Arrays.asList(values()));
static Difficulty of(int d) {
return (d >= 0 && d < items.size()) ? items.get(d) : null;
}
static Difficulty of(String name) {
for (Difficulty d : items) if (d.name().toLowerCase(Locale.ROOT).equals(name)) return d;
return null;
}
String getTagStringValue() {
return this.name().toLowerCase(Locale.ROOT);
}
@Override
public String toString() {
return i18n("world.info.difficulty." + name().toLowerCase(Locale.ROOT));
}
}
private enum GameType {
SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR, HARDCORE;
static final ObservableList<GameType> items = FXCollections.observableList(Arrays.asList(values()));
static GameType of(int d, boolean hardcore) {
if (hardcore && d == 0) return HARDCORE; // hardcore + survival
return d >= 0 && d < 4 ? items.get(d) : null;
}
@Override
public String toString() {
return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT));
}
}
private void changeWorldIcon() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.icon.choose.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
fileChooser.setInitialFileName("icon.png");
Path iconPath = Controllers.showOpenDialog(fileChooser);
if (iconPath == null) return;
Image image;
try {
image = FXUtils.loadImage(iconPath);
} catch (Exception e) {
LOG.warning("Failed to load image", e);
Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR);
return;
}
if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) {
Path output = world.getFile().resolve("icon.png");
saveWorldIcon(iconPath, image, output);
} else {
Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR);
}
}
private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) {
Image oldImage = iconImageView.getImage();
try {
FileUtils.copyFile(sourcePath, targetPath);
iconImageView.setImage(image);
Controllers.showToast(i18n("world.icon.change.succeed.toast"));
} catch (IOException e) {
LOG.warning("Failed to save world icon " + e.getMessage());
iconImageView.setImage(oldImage);
}
}
private void clearWorldIcon() {
Path output = world.getFile().resolve("icon.png");
try {
Files.deleteIfExists(output);
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
} catch (IOException e) {
LOG.warning("Failed to delete world icon ", e);
}
}
}
@@ -1,426 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldListPage extends ListPageBase<World> {
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private Path savesDir;
private List<World> worlds;
private @Nullable HMCLGameInstance gameInstance;
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
private int refreshCount = 0;
/// Creates a world list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public WorldListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
installWorld(modpacks.get(0));
});
showAll.addListener(e -> updateWorldList());
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<WorldListPage> createDefaultSkin() {
return new WorldListPageSkin();
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null;
refresh();
}
private void updateWorldList() {
if (worlds == null || gameInstance == null) {
getItems().clear();
} else if (showAll.get()) {
getItems().setAll(worlds);
} else {
GameVersionNumber gameVersion = gameInstance.getVersion();
getItems().setAll(worlds.stream()
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.toList());
}
}
public void refresh() {
if (gameInstance == null || savesDir == null)
return;
int currentRefresh = ++refreshCount;
HMCLGameInstance gameInstance = this.gameInstance;
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
// Ensure the game version number is parsed
gameInstance.getVersion();
return World.getWorlds(savesDir);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (refreshCount != currentRefresh) {
// A newer refresh task is running, discard this result
return;
}
supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion()));
worlds = result;
updateWorldList();
if (exception != null)
LOG.warning("Failed to load world list page", exception);
setLoading(false);
}).start();
}
public void openSavesFolder() {
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getSavesDirectory());
}
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("world.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.world"), "*.zip"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null || res.isEmpty()) return;
installWorld(res.get(0));
}
private void installWorld(Path zipFile) {
// Only accept one world file because user is required to confirm the new world name
// Or too many input dialogs are popped.
Task.supplyAsync(() -> new World(zipFile))
.whenComplete(Schedulers.javafx(), world -> {
Controllers.prompt(i18n("world.name.enter"), (name, handler) -> {
Task.runAsync(() -> world.install(savesDir, name))
.whenComplete(Schedulers.javafx(), () -> {
handler.resolve();
refresh();
}, e -> {
if (e instanceof FileAlreadyExistsException)
handler.reject(i18n("world.add.failed", i18n("world.add.already_exists")));
else if (e instanceof IOException && e.getCause() instanceof InvalidPathException)
handler.reject(i18n("world.add.failed", i18n("install.new_game.malformed")));
else
handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage()));
}).start();
}, world.getWorldName(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
}, e -> {
LOG.warning("Unable to parse world file " + zipFile, e);
Controllers.dialog(i18n("world.add.invalid"));
}).start();
}
private void showManagePage(World world) {
if (gameInstance != null) {
Controllers.navigate(new WorldManagePage(world, gameInstance));
}
}
public void export(World world) {
WorldManageUIUtils.export(world);
}
public void delete(World world) {
WorldManageUIUtils.delete(world, this::refresh);
}
public void copy(World world) {
WorldManageUIUtils.copyWorld(world, this::refresh);
}
public void reveal(World world) {
FXUtils.openFolder(world.getFile());
}
public void launch(World world) {
if (gameInstance != null) {
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
}
public void generateLaunchScript(World world) {
if (gameInstance != null) {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
}
public BooleanProperty showAllProperty() {
return showAll;
}
public ReadOnlyBooleanProperty supportQuickPlayProperty() {
return supportQuickPlay;
}
private final class WorldListPageSkin extends ToolbarListPageSkin<World, WorldListPage> {
WorldListPageSkin() {
super(WorldListPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
protected List<Node> initializeToolbar(WorldListPage skinnable) {
JFXCheckBox chkShowAll = new JFXCheckBox(i18n("world.show_all"));
chkShowAll.selectedProperty().bindBidirectional(skinnable.showAllProperty());
return Arrays.asList(
chkShowAll,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openSavesFolder)
);
}
@Override
protected ListCell<World> createListCell(JFXListView<World> listView) {
return new WorldListCell(getSkinnable());
}
}
private static final class WorldListCell extends ListCell<World> {
private final WorldListPage page;
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final Tooltip leftTooltip;
private final TwoLineListItem content;
private final JFXButton btnLaunch;
/// Creates a world row with launch and management actions for the supplied page.
public WorldListCell(WorldListPage page) {
this.page = page;
var root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
this.leftTooltip = new Tooltip();
FXUtils.installSlowTooltip(left, leftTooltip);
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
this.imageView = new ImageContainer(32);
left.getChildren().add(imageView);
}
{
this.content = new TwoLineListItem();
root.setCenter(content);
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
btnLaunch = FXUtils.newToggleButton4(SVG.ROCKET_LAUNCH);
btnLaunch.visibleProperty().bind(page.supportQuickPlayProperty());
btnLaunch.managedProperty().bind(btnLaunch.visibleProperty());
right.getChildren().add(btnLaunch);
FXUtils.installFastTooltip(btnLaunch, i18n("instance.launch"));
btnLaunch.setOnAction(event -> {
World world = getItem();
if (world != null)
page.launch(world);
});
JFXButton btnMore = FXUtils.newToggleButton4(SVG.MORE_VERT);
right.getChildren().add(btnMore);
btnMore.setOnAction(event -> {
World world = getItem();
if (world != null)
showPopupMenu(world, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight());
});
}
this.graphic = new RipplerContainer(root);
graphic.setOnMouseClicked(event -> {
if (event.getClickCount() != 1)
return;
World world = getItem();
if (world == null)
return;
if (event.getButton() == MouseButton.PRIMARY)
page.showManagePage(world);
else if (event.getButton() == MouseButton.SECONDARY)
showPopupMenu(world, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
});
}
/// Updates world details and replaces version and lock tags together.
@Override
protected void updateItem(@Nullable World world, boolean empty) {
@Nullable World oldWorld = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(world, empty);
if (oldWorld == world && oldEmpty == empty) return;
this.graphic.resetFeedback();
if (empty || world == null) {
content.getTags().clear();
setGraphic(null);
imageView.setImage(null);
leftTooltip.setText("");
content.setTitle("");
content.setSubtitle("");
} else {
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
leftTooltip.setText(world.getFile().toString());
content.setTitle(world.getWorldName() != null ? parseColorEscapes(world.getWorldName()) : "");
var tags = new ArrayList<Tag>(2);
if (world.getGameVersion() != null)
tags.add(new Tag(I18n.getDisplayVersion(world.getGameVersion())));
if (world.isLocked()) {
tags.add(new Tag(i18n("world.locked")));
btnLaunch.setDisable(true);
} else {
btnLaunch.setDisable(false);
}
content.getTags().setAll(tags);
content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
setGraphic(graphic);
}
}
// Popup Menu
public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
boolean worldLocked = world.isLocked();
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
if (supportQuickPlay) {
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch_and_enter_world"), () -> page.launch(world), popup);
launchItem.setDisable(worldLocked);
popupMenu.getContent().addAll(
launchItem,
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> page.generateLaunchScript(world), popup),
new MenuSeparator()
);
}
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), () -> page.showManagePage(world), popup));
if (ChunkBaseApp.isSupported(world)) {
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
);
if (ChunkBaseApp.supportEndCity(world)) {
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup));
}
}
IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup);
exportMenuItem.setDisable(worldLocked);
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> page.delete(world), popup);
deleteMenuItem.setDisable(worldLocked);
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
duplicateMenuItem.setDisable(worldLocked);
popupMenu.getContent().addAll(
new MenuSeparator(),
exportMenuItem,
deleteMenuItem,
duplicateMenuItem
);
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup)
);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup);
popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
}
}
}
@@ -1,250 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
import javafx.beans.property.*;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays world information, backups, and management actions.
public final class WorldManagePage extends DecoratorAnimatedPage implements DecoratorPage {
private final World world;
private final Path backupsDir;
private final HMCLGameInstance gameInstance;
private final boolean supportQuickPlay;
/// The active world lock, or null when the world is read-only.
private @Nullable FileChannel sessionLockChannel;
private final ObjectProperty<State> state;
private boolean isFirstNavigation = true;
private final BooleanProperty refreshable = new SimpleBooleanProperty(true);
private final BooleanProperty readOnly = new SimpleBooleanProperty(false);
private final TransitionPane transitionPane = new TransitionPane();
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController = new TabController(transitionPane);
/// The tab displaying information about this world.
private final TabItem<WorldInfoPage> worldInfoTab;
/// The tab for managing backups of this world.
private final TabItem<WorldBackupsPage> worldBackupsTab;
/// The tab for managing data packs in this world.
private final TabItem<DataPackListPage> dataPackTab;
/// Loads world metadata and creates its tabs and sidebar actions.
public WorldManagePage(World world, HMCLGameInstance gameInstance) {
this.world = world;
this.gameInstance = gameInstance;
this.backupsDir = gameInstance.getBackupsDirectory();
updateSessionLockChannel();
try {
this.world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world data of world: " + this.world.getFile(), e);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail());
}
this.worldInfoTab = new TabItem<>(() -> new WorldInfoPage(this));
this.worldBackupsTab = new TabItem<>(() -> new WorldBackupsPage(this));
this.dataPackTab = new TabItem<>(() -> new DataPackListPage(this));
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion());
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
setContent(transitionPane);
initializeSidebar();
setSidebarBottom(createToolBar());
}
/// Reloads world metadata and refreshes only tab pages whose content is already initialized.
@Override
public void refresh() {
updateSessionLockChannel();
try {
world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world data of world: " + world.getFile(), e);
closePageForLoadingFail();
return;
}
for (var tab : tabController.getTabs()) {
if (tab.isInitialized() && tab.getNode() instanceof WorldRefreshable r) {
r.refresh();
}
}
}
private void closePageForLoadingFail() {
Platform.runLater(() -> {
fireEvent(new PageCloseEvent());
Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR);
});
}
private void updateSessionLockChannel() {
if (sessionLockChannel == null || !sessionLockChannel.isOpen()) {
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
readOnly.set(sessionLockChannel == null);
}
}
private void onNavigated(Navigator.NavigationEvent event) {
if (isFirstNavigation)
isFirstNavigation = false;
else
refresh();
}
public void onExited(Navigator.NavigationEvent event) {
try {
WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException ignored) {
}
}
public void launch() {
fireEvent(new PageCloseEvent());
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
public void generateLaunchScript() {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
public void setTitle(String title) {
this.state.set(new DecoratorPage.State(title, null, true, true, true));
}
public World getWorld() {
return world;
}
public Path getBackupsDir() {
return backupsDir;
}
public boolean isReadOnly() {
return readOnly.get();
}
public BooleanProperty readOnlyProperty() {
return readOnly;
}
@Override
public BooleanProperty refreshableProperty() {
return refreshable;
}
/// Registers supported world tabs and selects the information tab.
private void initializeSidebar() {
tabController.getTabs().addAll(worldInfoTab, worldBackupsTab);
tabController.selectTab(worldInfoTab);
getSidebar().addNavigationDrawerTab(tabController, worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL)
.addNavigationDrawerTab(tabController, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL);
if (world.supportDataPacks()) {
tabController.getTabs().add(dataPackTab);
getSidebar().addNavigationDrawerTab(tabController, dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL);
}
}
/// Creates world actions and binds write operations to the lock state.
private AdvancedListBox createToolBar() {
AdvancedListBox toolbar = new AdvancedListBox();
{
if (supportQuickPlay) {
toolbar.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> launch(), advancedListItem -> advancedListItem.disableProperty().bind(readOnlyProperty()));
}
if (ChunkBaseApp.isSupported(world)) {
PopupMenu chunkBasePopupMenu = new PopupMenu();
JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu);
chunkBasePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup)
);
if (ChunkBaseApp.supportEndCity(world)) {
chunkBasePopupMenu.getContent().add(
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup));
}
toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem ->
chunkBaseMenuItem.setOnAction(e ->
chunkBasePopup.show(chunkBaseMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
chunkBaseMenuItem.getWidth(), 0)));
}
toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()));
{
PopupMenu managePopupMenu = new PopupMenu();
JFXPopup managePopup = new JFXPopup(managePopupMenu);
if (supportQuickPlay) {
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch"), () -> launch(), managePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> generateLaunchScript(), managePopup),
new MenuSeparator()
);
}
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup),
new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup),
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup)
);
toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem ->
{
managePopupMenuItem.setOnAction(e ->
managePopup.show(managePopupMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
managePopupMenuItem.getWidth(), 0));
managePopupMenuItem.disableProperty().bind(readOnlyProperty());
});
}
}
return toolbar;
}
public interface WorldRefreshable {
void refresh();
}
}
@@ -1,127 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldManageUIUtils {
private WorldManageUIUtils() {
}
public static void delete(World world, Runnable runnable) {
delete(world, runnable, null);
}
public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) {
Controllers.confirm(
i18n("button.remove.confirm"),
i18n("world.delete"),
() -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel))
.thenRunAsync(world::delete)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
runnable.run();
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}).start(),
null
);
}
public static void export(World world) {
export(world, null);
}
public static void export(World world, FileChannel sessionLockChannel) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
fileChooser.setInitialFileName(world.getWorldName() + ".zip");
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
return;
}
try {
closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException e) {
return;
}
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
}
public static void copyWorld(World world, Runnable runnable) {
Path worldPath = world.getFile();
Controllers.prompt(
i18n("world.duplicate.prompt"),
(result, handler) -> {
Path targetDir = worldPath.resolveSibling(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("world.duplicate.failed.already_exists"));
return;
}
Task.runAsync(Schedulers.io(), () -> world.copy(result))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast")))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> {
if (runnable != null) {
runnable.run();
}
}
).whenComplete(Schedulers.javafx(), (throwable) -> {
if (throwable == null) {
handler.resolve();
} else {
handler.reject(i18n("world.duplicate.failed"));
LOG.warning("Failed to duplicate world " + world.getFile(), throwable);
}
})
.start();
}, "", new RequiredValidator(), new Validator(i18n("world.duplicate.failed.invalid_name"), FileUtils::isNameValid));
}
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
if (sessionLockChannel != null) {
try {
sessionLockChannel.close();
LOG.info("Closed session lock channel of the world " + world.getFileName());
} catch (IOException e) {
throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e);
}
}
}
public static FileChannel getSessionLockChannel(World world) {
try {
FileChannel lock = world.lock();
LOG.info("Acquired lock on world " + world.getFileName());
return lock;
} catch (WorldLockedException ignored) {
return null;
}
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import javafx.animation.Interpolator;
import javafx.animation.Timeline;
import javafx.animation.TranslateTransition;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jetbrains.annotations.NotNullByDefault;
/// Builds the line illustration on the home page: a floating block island with a tree and a blocky figure.
///
/// All shapes are unfilled strokes whose colors come from the `.home-art` style classes, so the drawing
/// follows the launcher palette. The island drifts slowly up and down unless animations are disabled.
@NotNullByDefault
final class HomeArt {
private HomeArt() {
}
/// Creates the illustration, sized 290 by 250 pixels.
///
/// @return the illustration root node
static Node create() {
Group island = new Group(
// Stepped underside of the island, then the grass line along its top.
stroke(new Polygon(30, 150, 250, 150, 250, 166, 232, 166, 232, 184, 212, 184, 212, 204,
176, 204, 176, 222, 104, 222, 104, 204, 68, 204, 68, 184, 48, 184, 48, 166, 30, 166), "home-art-line"),
stroke(new Line(30, 158, 250, 158), "home-art-line"),
// Tree: trunk and three stacked leaf blocks.
stroke(rect(186, 96, 8, 54), "home-art-line"),
stroke(rect(162, 70, 56, 26), "home-art-line"),
stroke(rect(170, 50, 40, 20), "home-art-line"),
stroke(rect(178, 34, 24, 16), "home-art-line"),
// Generic blocky figure: legs, torso, arms and head.
stroke(rect(88, 126, 10, 24), "home-art-figure"),
stroke(rect(102, 126, 10, 24), "home-art-figure"),
stroke(rect(88, 92, 24, 34), "home-art-figure"),
stroke(rect(76, 92, 12, 34), "home-art-figure"),
stroke(rect(112, 92, 12, 34), "home-art-figure"),
stroke(rect(88, 68, 24, 24), "home-art-figure"));
Pane root = new Pane(
stroke(new Line(20, 30, 54, 30), "home-art-line"),
stroke(new Line(30, 38, 52, 38), "home-art-line"),
stroke(new Line(200, 14, 240, 14), "home-art-line"),
stroke(new Line(210, 22, 234, 22), "home-art-line"),
stroke(rect(22, 62, 14, 14), "home-art-line"),
stroke(rect(42, 100, 10, 10), "home-art-line"),
stroke(rect(238, 108, 10, 10), "home-art-line"),
island);
root.setPrefSize(290, 250);
root.setMaxSize(290, 250);
if (AnimationUtils.isAnimationEnabled()) {
TranslateTransition bob = new TranslateTransition(Duration.seconds(4), island);
bob.setFromY(0);
bob.setToY(-5);
bob.setAutoReverse(true);
bob.setCycleCount(Timeline.INDEFINITE);
bob.setInterpolator(Interpolator.EASE_BOTH);
bob.play();
}
return root;
}
/// Creates an unfilled rectangle.
private static Rectangle rect(double x, double y, double width, double height) {
return new Rectangle(x, y, width, height);
}
/// Makes a shape an unfilled outline styled by `styleClass`.
private static <T extends Shape> T stroke(T shape, String styleClass) {
shape.setFill(null);
shape.getStyleClass().addAll("home-art", styleClass);
return shape;
}
}
@@ -4,18 +4,12 @@ package org.jackhuang.hmcl.ui.main;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.game.GameSettingsPage;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -23,14 +17,10 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController;
/// The tab for editing game settings presets.
private final TabItem<GameSettingsPage<GameSettings.Preset>> gameTab;
/// The tab whose content manages installed Java runtimes.
private final TabItem<JavaManagementPage> javaManagementTab;
/// The tab for general launcher settings.
private final TabItem<SettingsPage> settingsTab;
/// The tab for launcher appearance settings.
private final TabItem<PersonalizationPage> personalizationTab;
/// The tab for download settings.
private final TabItem<DownloadSettingsPage> downloadTab;
/// The tab displaying launcher information.
@@ -39,31 +29,22 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
/// Creates the settings tabs and fills the shared navigation sidebar.
public LauncherSettingsPage() {
gameTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Preset.class, null));
javaManagementTab = new TabItem<>(JavaManagementPage::new);
settingsTab = new TabItem<>(SettingsPage::new);
personalizationTab = new TabItem<>(PersonalizationPage::new);
downloadTab = new TabItem<>(DownloadSettingsPage::new);
aboutTab = new TabItem<>(AboutPage::new);
tabController = new TabController(transitionPane,
gameTab,
javaManagementTab,
settingsTab,
personalizationTab,
downloadTab,
aboutTab);
tabController.selectTab(gameTab);
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())));
tabController.selectTab(settingsTab);
getSidebar()
.addNavigationDrawerTab(tabController, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL)
.addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL)
.startCategory(i18n("launcher").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
.addNavigationDrawerTab(tabController, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL)
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
.startCategory(i18n("about").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
setContent(transitionPane);
@@ -79,11 +60,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
tabController.onPageHidden();
}
public void showGameSettings(HMCLGameRepository repository) {
gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
tabController.selectTab(gameTab, false);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
@@ -4,41 +4,40 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import javafx.animation.RotateTransition;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.ServerAddress;
import org.jackhuang.hmcl.util.ServerStatusPing;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.function.Consumer;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -59,14 +58,13 @@ public final class MainPage extends StackPane implements DecoratorPage {
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
.flatMap(HMCLGameRepository::snapshotProperty);
private final JFXButton menuButton;
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
titleNode.setAlignment(Pos.CENTER_LEFT);
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
Region titleIcon = new Region();
titleIcon.getStyleClass().add("title-mark");
Label titleLabel = new Label();
titleLabel.textProperty().bind(Controllers.launcherTitleProperty());
if (I18n.isUpsideDown()) {
@@ -79,116 +77,146 @@ public final class MainPage extends StackPane implements DecoratorPage {
state.setValue(new State(null, titleNode, false, false, true));
setPadding(new Insets(20));
setPadding(new Insets(34, 55, 34, 55));
HBox launchPane = new HBox();
launchPane.getStyleClass().add("launch-pane");
FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList()));
FXUtils.onScroll(launchPane, instances, list -> {
@Nullable HMCLGameInstance currentGame = getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
JFXButton launchButton = new JFXButton();
launchButton.getStyleClass().add("launch-button");
launchButton.setDefaultButton(true);
{
VBox graphic = new VBox();
graphic.setAlignment(Pos.CENTER);
Label launchLabel = new Label();
launchLabel.setStyle("-fx-font-size: 16px;");
Label currentLabel = new Label();
currentLabel.setStyle("-fx-font-size: 12px;");
// Left column: greeting, headline and a short description.
Label eyebrow = new Label(i18n("home.welcome"));
eyebrow.getStyleClass().add("home-eyebrow");
Label headline = new Label(i18n("home.title"));
headline.getStyleClass().add("home-title");
headline.setWrapText(true);
Label subtitle = new Label(i18n("home.subtitle"));
subtitle.getStyleClass().add("home-subtitle");
subtitle.setWrapText(true);
VBox hero = new VBox(8, eyebrow, headline, subtitle);
hero.setPrefWidth(330);
hero.setMaxWidth(330);
headline.setPrefWidth(330);
subtitle.setPrefWidth(330);
hero.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(hero, Pos.TOP_LEFT);
hero.setPadding(new Insets(21, 0, 0, 0));
FXUtils.onChangeAndOperate(currentGameProperty(), new Consumer<>() {
private Tooltip tooltip;
// Line illustration on the right, behind the content.
Node art = HomeArt.create();
StackPane.setAlignment(art, Pos.TOP_RIGHT);
art.setMouseTransparent(true);
((Region) art).setMaxSize(290, 250);
@Override
public void accept(@Nullable HMCLGameInstance currentGame) {
if (currentGame == null) {
launchLabel.setText(i18n("instance.launch.empty"));
currentLabel.setText(null);
graphic.getChildren().setAll(launchLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launchNoGame);
if (tooltip == null)
tooltip = new Tooltip(i18n("instance.launch.empty.tooltip"));
FXUtils.installFastTooltip(launchButton, tooltip);
} else {
launchLabel.setText(i18n("instance.launch"));
currentLabel.setText(currentGame.getId().toString());
graphic.getChildren().setAll(launchLabel, currentLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
if (tooltip != null)
Tooltip.uninstall(launchButton, tooltip);
}
// Bottom card: what will be launched, and the join button.
Label packLabel = factLabel(i18n("home.pack"));
Label packValue = new Label();
packValue.getStyleClass().add("home-fact-value");
Label routeLabel = factLabel(i18n("home.route"));
Label routeValue = new Label();
routeValue.getStyleClass().add("home-fact-value");
VBox packFact = new VBox(3, packLabel, packValue);
VBox routeFact = new VBox(3, routeLabel, routeValue);
Label serverLabel = factLabel(i18n("home.server"));
Label serverValue = new Label();
serverValue.getStyleClass().add("home-fact-value");
VBox serverFact = new VBox(3, serverLabel, serverValue);
HBox facts = new HBox(34, packFact, routeFact, serverFact);
facts.setAlignment(Pos.CENTER_LEFT);
JFXButton joinButton = new JFXButton();
joinButton.getStyleClass().add("join-button");
joinButton.setDefaultButton(true);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox card = new HBox(21, facts, spacer, joinButton);
card.getStyleClass().add("home-card");
card.setAlignment(Pos.CENTER_LEFT);
card.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(card, Pos.BOTTOM_CENTER);
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
if (currentGame == null) {
joinButton.setText(i18n("instance.launch.empty"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launchNoGame);
packValue.setText(i18n("home.not_installed"));
routeFact.setVisible(false);
routeFact.setManaged(false);
serverFact.setVisible(false);
serverFact.setManaged(false);
} else {
joinButton.setText(i18n("server.join"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
packValue.setText(currentGame.getId().toString());
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
routeFact.setVisible(route != null);
routeFact.setManaged(route != null);
routeValue.setText(route != null ? route.name() : "");
refreshServerStatus(route, serverFact, serverValue);
// With more than one route (for example direct and CDN), the route can be switched.
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
boolean switchable = routes.size() > 1;
routeFact.setCursor(switchable ? Cursor.HAND : Cursor.DEFAULT);
if (switchable && route != null) {
routeValue.setText(route.name() + " ▾");
}
routeFact.setOnMouseClicked(switchable ? event -> {
PopupMenu list = new PopupMenu();
JFXPopup popup = new JFXPopup(list);
List<Node> items = new ArrayList<>();
for (ServerEndpoint.Route candidate : routes) {
items.add(new IconedMenuItem(null, candidate.name(), () -> {
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
routeValue.setText(candidate.name() + " ▾");
refreshServerStatus(candidate, serverFact, serverValue);
}, popup));
}
});
launchButton.setGraphic(graphic);
list.getContent().setAll(items);
popup.show(routeFact, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -routeFact.getHeight());
} : null);
}
});
menuButton = new JFXButton();
menuButton.getStyleClass().add("menu-button");
menuButton.setOnAction(e -> {
if (GameListPopupMenu.hideShowing(menuButton)) {
return;
}
getChildren().addAll(art, hero, card);
}
JFXPopup popup = GameListPopupMenu.showAndGetPopup(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight(),
instances
);
Node graphic = menuButton.getGraphic();
if (graphic != null) {
if (AnimationUtils.isAnimationEnabled()) {
Duration duration = Duration.millis(200);
RotateTransition rotateOpen = new RotateTransition(duration, graphic);
rotateOpen.setToAngle(-180);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateOpen);
popup.setOnHidden(windowEvent -> {
RotateTransition rotateClose = new RotateTransition(duration, graphic);
rotateClose.setToAngle(0);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateClose);
});
} else {
graphic.setRotate(-180);
popup.setOnHidden(windowEvent -> graphic.setRotate(0));
}
}
});
FXUtils.installFastTooltip(menuButton, i18n("instance.switch"));
menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30));
EventHandler<MouseEvent> secondaryClickHandle = event -> {
if (event.getButton() == MouseButton.SECONDARY && event.getClickCount() == 1) {
menuButton.fire();
event.consume();
}
};
launchButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
menuButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
launchPane.getChildren().setAll(launchButton, menuButton);
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
///
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
String address = route == null ? "" : route.address().trim();
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
serverFact.setVisible(pingable);
serverFact.setManaged(pingable);
if (!pingable) {
return;
}
serverValue.setText("…");
Schedulers.io().execute(() -> {
String text;
try {
ServerAddress parsed = ServerAddress.parse(address);
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
} catch (IOException | IllegalArgumentException e) {
text = i18n("home.server.unreachable");
}
String result = text;
FXUtils.runInFX(() -> serverValue.setText(result));
});
}
getChildren().addAll(launchPane);
/// Creates the small caption above a fact value in the home card.
private static Label factLabel(String text) {
Label label = new Label(text);
label.getStyleClass().add("home-fact-label");
return label;
}
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance());
Instances.joinServer(repository.getSelectedInstance());
}
/// Opens the server modpack install/update flow when no instance is selected.
File diff suppressed because it is too large. Load diff
@@ -4,8 +4,6 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.Accounts;
@@ -21,14 +19,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -40,12 +35,12 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
/// Creates the home page and its navigation entries.
public RootPage() {
getStyleClass().remove("gray-background");
getLeft().getStyleClass().add("gray-background");
getLeft().getStyleClass().add("home-sidebar");
// first item in left sidebar
AccountAdvancedListItem accountListItem = new AccountAdvancedListItem();
accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage()));
FXUtils.onSecondaryButtonClicked(accountListItem, () -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0));
// Clicking the account opens a popup above it (the entry sits at the bottom-left): switch, add or manage.
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
// second item in left sidebar
@@ -53,38 +48,19 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
gameInstanceItem.setOnAction(e -> {
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Controllers.navigate(Controllers.getGameListPage());
Instances.installServerModpack();
} else {
Instances.modifyGameSettings(instance);
}
});
FXUtils.onScroll(gameInstanceItem, getMainPage().getInstances(), list -> {
@Nullable HMCLGameInstance currentGame = getMainPage().getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
FXUtils.onSecondaryButtonClicked(gameInstanceItem, () -> showGameListPopupMenu(gameInstanceItem));
if (AnimationUtils.isAnimationEnabled()) {
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
}
// third item in left sidebar
AdvancedListItem gameListItem = new AdvancedListItem();
gameListItem.setLeftIcon(SVG.FORMAT_LIST_BULLETED);
gameListItem.setTitle(i18n("instance.manage"));
gameListItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage()));
FXUtils.onSecondaryButtonClicked(gameListItem, () -> showGameListPopupMenu(gameListItem));
if (AnimationUtils.isAnimationEnabled()) {
gameListItem.setPrepareAction(Controllers::prepareGameListPage);
}
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
launcherSettingsItem.setOnAction(e -> {
Controllers.getSettingsPage().showGameSettings(GameDirectoryManager.getSelectedRepository());
Controllers.navigate(Controllers.getSettingsPage());
});
@@ -92,15 +68,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
}
// the left sidebar
// The sidebar holds the modpack and settings entries; the account sits at the bottom-left.
getSidebar()
.startCategory(i18n("account").toUpperCase(Locale.ROOT))
.add(accountListItem)
.startCategory(i18n("instance").toUpperCase(Locale.ROOT))
.add(gameInstanceItem)
.add(gameListItem)
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem);
setSidebarBottom(accountListItem);
// the root page, with the sidebar in left, navigator in center.
setContent(getMainPage());
@@ -133,14 +105,4 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
}
return mainPage;
}
/// Opens the instance selection menu beside the sidebar entry.
private void showGameListPopupMenu(Region gameListItem) {
GameListPopupMenu.show(gameListItem,
JFXPopup.PopupVPosition.TOP,
JFXPopup.PopupHPosition.LEFT,
gameListItem.getWidth(),
0,
getMainPage().getInstances());
}
}
@@ -1,61 +0,0 @@
* {
-monet-primary: #4352A5;
-monet-on-primary: #FFFFFF;
-monet-primary-container: #5C6BC0;
-monet-on-primary-container: #F8F6FF;
-monet-primary-fixed: #DEE0FF;
-monet-primary-fixed-dim: #BAC3FF;
-monet-on-primary-fixed: #00105B;
-monet-on-primary-fixed-variant: #2F3F92;
-monet-secondary: #575C7F;
-monet-on-secondary: #FFFFFF;
-monet-secondary-container: #D0D5FD;
-monet-on-secondary-container: #565B7D;
-monet-secondary-fixed: #DEE0FF;
-monet-secondary-fixed-dim: #BFC4EC;
-monet-on-secondary-fixed: #141938;
-monet-on-secondary-fixed-variant: #3F4566;
-monet-tertiary: #775200;
-monet-on-tertiary: #FFFFFF;
-monet-tertiary-container: #976900;
-monet-on-tertiary-container: #FFF6EE;
-monet-tertiary-fixed: #FFDEAC;
-monet-tertiary-fixed-dim: #F6BD58;
-monet-on-tertiary-fixed: #281900;
-monet-on-tertiary-fixed-variant: #5F4100;
-monet-error: #BA1A1A;
-monet-on-error: #FFFFFF;
-monet-error-container: #FFDAD6;
-monet-on-error-container: #93000A;
-monet-surface: #FBF8FF;
-monet-on-surface: #1B1B21;
-monet-surface-dim: #DBD9E1;
-monet-surface-bright: #FBF8FF;
-monet-surface-container-lowest: #FFFFFF;
-monet-surface-container-low: #F5F2FA;
-monet-surface-container: #EFEDF5;
-monet-surface-container-high: #E9E7EF;
-monet-surface-container-highest: #E3E1E9;
-monet-surface-variant: #E2E1EF;
-monet-on-surface-variant: #454651;
-monet-background: #FBF8FF;
-monet-on-background: #1B1B21;
-monet-outline: #767683;
-monet-outline-variant: #C6C5D3;
-monet-shadow: #000000;
-monet-scrim: #000000;
-monet-inverse-surface: #303036;
-monet-inverse-on-surface: #F2EFF7;
-monet-inverse-primary: #BAC3FF;
-monet-surface-tint: #4858AB;
-monet-primary-seed: #5C6BC0;
-monet-primary-transparent-50: #4352A580;
-monet-primary-transparent-80: #4352A5CC;
-monet-secondary-container-transparent-50: #D0D5FD80;
-monet-surface-transparent-50: #FBF8FF80;
-monet-surface-transparent-80: #FBF8FFCC;
-monet-on-surface-variant-transparent-38: #45465161;
-monet-surface-container-low-transparent-80: #F5F2FACC;
-monet-secondary-container-transparent-80: #D0D5FDCC;
-monet-inverse-surface-transparent-80: #303036CC;
}
+10 -3
View File
@@ -2,7 +2,14 @@
* SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
* SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
*/
/* Body text uses the serif face; headings and controls use the sans face. Both are SIL OFL fonts. */
.root {
/* https://github.com/HMCL-dev/HMCL/pull/3423 */
-fx-font-family: -fx-base-font-family;
}
-fx-font-family: "Noto Serif SC";
}
.button, .jfx-button, .toggle-button, .jfx-toggle-button, .tab-label,
.jfx-layout-heading, .jfx-decorator-title, .method-title, .advanced-list-item .title,
.two-line-list-item .title, .announcement .title, .bold, .html-h3, .html-bold, .class-title,
.navigation-drawer-item, .jfx-tool-bar .label {
-fx-font-family: "Noto Sans SC";
}
+190 -12
View File
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -791,6 +791,24 @@ instance.game.support_status.unsupported=Unsupported
instance.game.support_status.untested=Untested
instance.game.type=Type
instance.launch=Launch Game
home.welcome=Welcome back
home.title=Join our world
home.subtitle=One click syncs the modpack, prepares Java and connects you to the server.
home.pack=Modpack
home.route=Route
home.not_installed=Not installed
launcher.name=Server Launcher
account.add=Add account
account.open_manage=Manage accounts
first_run.title=Pick a game name
first_run.description=Your name is used when you join the server. You can sign in with a Microsoft account instead.
first_run.name=Game name
first_run.accept=Start playing
first_run.microsoft=Sign in with Microsoft
first_run.invalid=Use 1 to 16 letters, digits or underscores.
home.server=Server
home.server.unreachable=Unreachable
server.join=Join Server
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty.installing=Installing Game
@@ -611,6 +611,24 @@ instance.game.snapshots=預版
# instance.game.support_status.untested=
instance.game.type=版類
instance.launch=戲
home.welcome=歸來
home.title=入吾世
home.subtitle=一點即同步、備器、入服。
home.pack=整合包
home.route=路
home.not_installed=未裝
launcher.name=服啟
account.add=增帳
account.open_manage=理帳
first_run.title=署名
first_run.description=入服用此名。可改登微軟之帳。
first_run.name=名
first_run.accept=始
first_run.microsoft=登正版
first_run.invalid=一至十六字,限英文數字底綫。
home.server=服
home.server.unreachable=不通
server.join=入服
# instance.launch_and_enter_world=
instance.launch.empty=啟戲
instance.launch.empty.installing=裝戲
@@ -614,6 +614,24 @@ instance.game.support_status.unsupported=不支持
instance.game.support_status.untested=未经测试
instance.game.type=版本类型
instance.launch=启动游戏
home.welcome=欢迎回来
home.title=进入我们的世界
home.subtitle=点一下就会同步整合包、准备好 Java,并直接连上服务器。
home.pack=整合包
home.route=线路
home.not_installed=尚未安装
launcher.name=服务器启动器
account.add=添加账号
account.open_manage=管理账号
first_run.title=起个游戏名
first_run.description=进服时会用这个名字。想用正版账号的话,可以改为登录微软账号。
first_run.name=游戏名
first_run.accept=开始游戏
first_run.microsoft=登录正版账号
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
home.server=服务器
home.server.unreachable=连接不上
server.join=进入服务器
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty.installing=安装游戏
@@ -608,6 +608,24 @@ instance.game.support_status.unsupported=不支援
instance.game.support_status.untested=未經測試
instance.game.type=版本類型
instance.launch=啟動遊戲
home.welcome=歡迎回來
home.title=進入我們的世界
home.subtitle=點一下就會同步整合包、準備好 Java,並直接連上伺服器。
home.pack=整合包
home.route=線路
home.not_installed=尚未安裝
launcher.name=伺服器啟動器
account.add=新增帳號
account.open_manage=管理帳號
first_run.title=取個遊戲名
first_run.description=進服時會使用這個名字。想用正版帳號的話,可以改為登入微軟帳號。
first_run.name=遊戲名
first_run.accept=開始遊戲
first_run.microsoft=登入正版帳號
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
home.server=伺服器
home.server.unreachable=無法連線
server.join=進入伺服器
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty.installing=安裝遊戲
@@ -601,7 +601,7 @@ public final class JFXSwitchTest {
/// Loads the same shared component styles and color palette used by the application.
private static void applyStylesheets(Scene scene) {
scene.getStylesheets().addAll(
Objects.requireNonNull(JFXSwitchTest.class.getResource("/assets/css/blue.css")).toExternalForm(),
Objects.requireNonNull(JFXSwitchTest.class.getResource("/assets/css/warm.css")).toExternalForm(),
Objects.requireNonNull(JFXSwitchTest.class.getResource("/assets/css/root.css")).toExternalForm());
}
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/// Tests how [ServerEndpoint] resolves routes without an installed modpack manifest.
public final class ServerEndpointTest {
@AfterEach
void clearProperty() {
System.clearProperty("hmcl.server.address");
}
@Test
void noConfigurationMeansNoRoute(@TempDir Path dir) {
assertTrue(ServerEndpoint.routes(dir.resolve("modpack.json")).isEmpty());
assertNull(ServerEndpoint.selected(dir.resolve("modpack.json")));
}
@Test
void fallsBackToDefaultAddress(@TempDir Path dir) {
System.setProperty("hmcl.server.address", " play.example.net:25565 ");
var routes = ServerEndpoint.routes(dir.resolve("modpack.json"));
assertEquals(1, routes.size());
assertEquals("play.example.net:25565", routes.get(0).address());
assertEquals(routes.get(0), ServerEndpoint.selected(dir.resolve("modpack.json")));
}
@Test
void selectionIsRememberedNextToTheConfiguration(@TempDir Path dir) throws Exception {
System.setProperty("hmcl.server.address", "wss://cdn.example.net/mc");
Path file = dir.resolve("modpack.json");
ServerEndpoint.select(file, new ServerEndpoint.Route("cdn", "wss://cdn.example.net/mc"));
assertEquals("cdn", Files.readString(dir.resolve("server-route.txt")));
}
}
@@ -1,44 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
/// Tests for built-in launcher wallpapers.
@NotNullByDefault
public final class BuiltinBackgroundTest {
/// Checks precomputed MonetFX seed colors against dynamic extraction.
@Test
public void precomputedThemeColorsMatchDynamicExtraction() {
List<Executable> checks = Arrays.stream(BuiltinBackground.values())
.<Executable>map(background -> () -> assertEquals(
WallpaperColorExtractor.extract(loadWallpaper(background), ThemeColor.DEFAULT),
background.themeColor(),
background.id()))
.toList();
assertAll(checks);
}
/// Loads the bundled wallpaper for a built-in background.
private static Image loadWallpaper(BuiltinBackground background) throws Exception {
String fileName = background.id() + ".jpg";
@Nullable InputStream input = BuiltinBackgroundTest.class.getResourceAsStream(
"/assets/img/wallpapers/" + fileName);
if (input == null) {
throw new AssertionError("Missing built-in wallpaper: " + fileName);
}
return FXUtils.loadImage(input, fileName);
}
}
@@ -1,39 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.geometry.Insets;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
/// Tests launcher background opacity storage.
@NotNullByDefault
public final class LauncherBackgroundTest {
/// Verifies opacity changes reuse the unmodified JavaFX background.
@Test
public void changesOpacityWithoutChangingBackground() {
Background background = new Background(
new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY));
LauncherBackground changed = new LauncherBackground(background, 0.4);
assertSame(background, changed.background());
assertEquals(0.4, changed.opacity());
assertEquals(1.0, ((Color) changed.background().getFills().get(0).getFill()).getOpacity());
}
/// Verifies invalid node opacity values are rejected.
@Test
public void rejectsInvalidOpacity() {
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, -0.1));
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, 1.1));
assertThrows(IllegalArgumentException.class, () -> new LauncherBackground(Background.EMPTY, Double.NaN));
}
}
@@ -1,156 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.setting.LauncherSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/// Tests for theme-pack import, export, and resolution behavior.
@NotNullByDefault
public final class ThemePackManagerTest {
/// Theme-pack image assets keep their entry names when exported from unpacked theme directories.
@Test
public void exportsUnpackedThemeResourceWithEntryName() throws Exception {
Field launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings");
launcherSettingsField.setAccessible(true);
Object previousLauncherSettings = launcherSettingsField.get(null);
String packId = "example.unpacked-export";
Path themePacksDirectory = ThemePackManager.THEME_PACKS_DIRECTORY.toAbsolutePath().normalize();
Path themeDirectory = themePacksDirectory.resolve(packId).normalize();
if (!themeDirectory.startsWith(themePacksDirectory)) {
throw new AssertionError("Theme-pack test directory escapes the theme-pack directory: " + themeDirectory);
}
try {
if (Files.exists(themeDirectory)) {
FileUtils.forceDelete(themeDirectory);
}
Path wallpaper = themeDirectory.resolve("assets/wallpapers/wallpaper.png");
Files.createDirectories(wallpaper.getParent());
byte[] body = "unpacked image bytes".getBytes(StandardCharsets.US_ASCII);
Files.write(wallpaper, body);
Files.writeString(themeDirectory.resolve(ThemePackExporter.MANIFEST_ENTRY), """
{
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
"id": "example.unpacked-export",
"version": "1.0.0",
"name": "Unpacked",
"theme": {
"background": {
"type": "image",
"path": "assets/wallpapers/wallpaper.png"
}
}
}
""");
LauncherSettings launcherSettings = new LauncherSettings();
launcherSettings.selectedThemeProperty().set(new ThemeReference(packId, null));
launcherSettingsField.set(null, launcherSettings);
ThemePackManager.ExportedThemePack exported =
ThemePackManager.createCurrent("example.export", "1.0", "Example", "Tester");
Theme theme = exported.manifest().themes().get(0);
ThemeBackgroundSettings background = Objects.requireNonNull(theme.appearance().background());
ThemeBackground.Image image = assertInstanceOf(ThemeBackground.Image.class, background.source());
assertEquals("assets/wallpapers/wallpaper.png", image.path());
assertEquals(1, exported.assets().size());
assertEquals("assets/wallpapers/wallpaper.png", exported.assets().get(0).entryName());
try (InputStream input = exported.assets().get(0).source().openStream()) {
assertArrayEquals(body, input.readAllBytes());
}
} finally {
launcherSettingsField.set(null, previousLauncherSettings);
if (Files.exists(themeDirectory)) {
FileUtils.forceDelete(themeDirectory);
}
}
}
/// Network launcher backgrounds are downloaded and exported as local theme-pack image assets.
@Test
public void exportsNetworkBackgroundAsImageAsset() throws Exception {
Field launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings");
launcherSettingsField.setAccessible(true);
Object previousLauncherSettings = launcherSettingsField.get(null);
byte[] body = "fake image bytes".getBytes(StandardCharsets.US_ASCII);
ExecutorService executor = Executors.newSingleThreadExecutor();
try (ServerSocket server = new ServerSocket(0)) {
Future<?> serverTask = executor.submit(() -> {
try (Socket socket = server.accept();
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream()) {
int matched = 0;
byte[] endOfHeaders = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
int nextByte;
while (matched < endOfHeaders.length && (nextByte = input.read()) >= 0) {
matched = nextByte == endOfHeaders[matched] ? matched + 1 : 0;
}
byte[] header = ("HTTP/1.1 200 OK\r\n"
+ "Content-Type: image/png\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n").getBytes(StandardCharsets.US_ASCII);
output.write(header);
output.write(body);
}
return null;
});
LauncherSettings launcherSettings = new LauncherSettings();
launcherSettings.backgroundTypeProperty().set(BackgroundType.NETWORK);
launcherSettings.networkBackgroundImageUrlProperty().set(
"http://127.0.0.1:" + server.getLocalPort() + "/wallpaper.png");
launcherSettings.networkBackgroundImageCachePolicyProperty().set(NetworkBackgroundImageCachePolicy.DISABLED);
launcherSettings.getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND);
launcherSettingsField.set(null, launcherSettings);
ThemePackManager.ExportedThemePack exported =
ThemePackManager.createCurrent("example.export", "1.0", "Example", "Tester");
try {
Theme theme = exported.manifest().themes().get(0);
ThemeBackgroundSettings background = Objects.requireNonNull(theme.appearance().background());
ThemeBackground.Image image = assertInstanceOf(ThemeBackground.Image.class, background.source());
assertEquals("assets/wallpapers/wallpaper.png", image.path());
assertEquals(1, exported.assets().size());
try (InputStream input = exported.assets().get(0).source().openStream()) {
assertArrayEquals(body, input.readAllBytes());
}
serverTask.get(5, TimeUnit.SECONDS);
} finally {
for (Path temporaryFile : exported.temporaryFiles()) {
Files.deleteIfExists(temporaryFile);
}
}
} finally {
launcherSettingsField.set(null, previousLauncherSettings);
executor.shutdownNow();
}
}
}
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.Brightness;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/// Tests for theme-pack manifest parsing and resolution behavior.
@NotNullByDefault
public final class ThemePackManifestTest {
/// Invalid optional metadata fields are ignored instead of rejecting the whole manifest.
@Test
public void invalidOptionalManifestFieldsAreIgnored() {
ThemePackManifest manifest = Objects.requireNonNull(JsonUtils.fromJson("""
{
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
"id": "example.invalid-optionals",
"version": "1.0.0",
"name": "Example",
"authors": "invalid",
"description": "",
"icon": "../bad.png",
"theme": {
"id": "../bad",
"name": "",
"description": [],
"icon": "../theme.png"
}
}
""", ThemePackManifest.class));
assertTrue(manifest.authors().isEmpty());
assertNull(manifest.description());
assertNull(manifest.icon());
Theme theme = manifest.themes().get(0);
assertNull(theme.id());
assertNull(theme.name());
assertNull(theme.description());
assertNull(theme.icon());
}
/// Applying a theme without a background should resolve to the launcher default background.
@Test
public void emptyThemeUsesDefaultBackgroundAfterApplying() throws Exception {
ThemePackManifest manifest = Objects.requireNonNull(JsonUtils.fromJson("""
{
"$schema": "https://schemas.glavo.site/hmcl/theme-pack/1.0.0",
"id": "example.empty-theme",
"version": "1.0.0",
"name": "Example",
"theme": {}
}
""", ThemePackManifest.class));
Theme theme = manifest.themes().get(0);
ThemePackManager.ResolvedBackground background = ThemePackManager.resolveBackgroundAfterApplyingTheme(
new ThemePackManager.InstalledThemePack(Path.of("example.hmcl-theme"), manifest),
theme,
new ThemeResolveContext(Brightness.LIGHT, "linux", "en"));
assertEquals(BackgroundType.DEFAULT, background.type());
assertEquals(1.0, background.opacity(), 0.0);
assertNull(theme.appearance().windowTransparent());
}
}
@@ -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://");
}
}
@@ -29,12 +29,26 @@ public class ServerModpackManifest implements ModpackManifest, Validation {
private final String fileApi;
private final List<ModpackConfiguration.FileInformation> files;
private final List<Addon> 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<Route> routes;
public ServerModpackManifest() {
this("", "", "", "", "", Collections.emptyList(), Collections.emptyList());
}
public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List<ModpackConfiguration.FileInformation> files, List<Addon> addons) {
this(name, author, version, description, fileApi, files, addons, null);
}
public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List<ModpackConfiguration.FileInformation> files, List<Addon> 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<ModpackConfiguration.FileInformation> files, List<Addon> addons, @Nullable String serverAddress, @Nullable List<Route> 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<Route> 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;
@@ -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");
}
}
@@ -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();
}
}